Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad1f5f33b | ||
|
|
9d7e311320 | ||
|
|
9cc8194d7b | ||
|
|
3ee5aa6809 | ||
|
|
ca19ad277a | ||
|
|
fb1da912c9 | ||
|
|
113fb8b7a9 | ||
|
|
e511c4153f | ||
|
|
c0e66f94e3 | ||
|
|
7f081ad457 | ||
|
|
c7ed894900 | ||
|
|
98427b9620 | ||
|
|
cab01c3d7f | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 |
@@ -20,15 +20,35 @@ harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-numeric-gate-20260911/** -text
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
## Cooperation through Gitea MCP
|
||||
# Agent working agreement
|
||||
|
||||
Use Gitea MCP to coordinate with other agents working on this project.
|
||||
## Working method
|
||||
|
||||
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
|
||||
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
|
||||
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
|
||||
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
|
||||
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
|
||||
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
|
||||
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
|
||||
- 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.
|
||||
|
||||
Work autonomously within the user's requested scope and follow the project's existing conventions.
|
||||
## 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.
|
||||
|
||||
Generated
+31
@@ -2107,6 +2107,25 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2139,11 +2158,23 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+38
-606
@@ -1,616 +1,49 @@
|
||||
You are the principal investigator, chief systems engineer, product architect, and engineering orchestrator responsible for taking this project from its current state to a coherent, validated, working release.
|
||||
# Project goal
|
||||
|
||||
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
|
||||
## Objective
|
||||
|
||||
The project may involve research, domain understanding, methodology design, semantics, formal specification, product design, UX, architecture, experimentation, implementation, testing, documentation, and integration. Treat all of these as first-class work when they contribute to the 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.
|
||||
|
||||
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
|
||||
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.
|
||||
|
||||
## Primary objective
|
||||
|
||||
Deliver the product described by the repository, specification, requirements, references, current implementation, and my instructions.
|
||||
|
||||
Persist until the intended outcome is genuinely achieved.
|
||||
|
||||
Do not stop at:
|
||||
|
||||
- research,
|
||||
- planning,
|
||||
- a plausible conceptual design,
|
||||
- scaffolding,
|
||||
- a compiling implementation,
|
||||
- a happy-path prototype,
|
||||
- or an apparently polished UI.
|
||||
|
||||
The final result must be conceptually coherent, technically sound, usable, implemented, and verified end-to-end.
|
||||
|
||||
Bias toward action and completion.
|
||||
|
||||
Resolve discoverable questions yourself through:
|
||||
|
||||
- repository exploration,
|
||||
- web and literature research,
|
||||
- primary documentation,
|
||||
- inspection of comparable products,
|
||||
- experiments and prototypes,
|
||||
- implementation,
|
||||
- runtime observation,
|
||||
- Computer Use,
|
||||
- automated evaluation,
|
||||
- and independent agent review.
|
||||
|
||||
Ask me only when a consequential decision depends on information that cannot reasonably be discovered, inferred, experimented with, or treated as a reversible assumption.
|
||||
|
||||
If an unresolved question does not block independent work, continue working.
|
||||
|
||||
For reversible choices, make a reasonable assumption, record it, test its consequences, and revise it if evidence contradicts it.
|
||||
|
||||
## Operating model
|
||||
|
||||
Use an adaptive orchestrator-worker workflow.
|
||||
|
||||
Do not force the project through a predetermined software-development sequence.
|
||||
|
||||
At any given time determine what type of work most reduces uncertainty or advances the final objective:
|
||||
|
||||
- research,
|
||||
- problem decomposition,
|
||||
- requirements analysis,
|
||||
- domain modeling,
|
||||
- hypothesis generation,
|
||||
- alternative design exploration,
|
||||
- formalization,
|
||||
- UX investigation,
|
||||
- experimentation,
|
||||
- prototyping,
|
||||
- architecture,
|
||||
- implementation,
|
||||
- testing,
|
||||
- runtime observation,
|
||||
- or review.
|
||||
|
||||
Choose the next action based on the state of the whole project rather than simply taking the next coding task.
|
||||
|
||||
## Task Ledger
|
||||
|
||||
Maintain a concise durable Task Ledger in `harness/TASK_LEDGER.md`, unless an equivalent artifact already exists.
|
||||
|
||||
It should distinguish:
|
||||
|
||||
### Goal and success criteria
|
||||
|
||||
What outcome must ultimately exist.
|
||||
|
||||
### Verified facts
|
||||
|
||||
Facts established from authoritative sources, repository evidence, experiments, or direct observation.
|
||||
|
||||
### Given facts
|
||||
|
||||
Requirements or constraints explicitly supplied by me.
|
||||
|
||||
### Assumptions
|
||||
|
||||
Things currently treated as true but not yet verified.
|
||||
|
||||
### Open questions
|
||||
|
||||
Important unknowns.
|
||||
|
||||
### Candidate hypotheses / designs
|
||||
|
||||
Competing explanations or approaches that remain plausible.
|
||||
|
||||
### Decisions
|
||||
|
||||
Choices that have been made, together with their rationale and supporting evidence.
|
||||
|
||||
### Current strategy
|
||||
|
||||
The current high-level route toward the goal.
|
||||
|
||||
Do not silently convert an assumption into a fact.
|
||||
|
||||
Do not silently convert the first plausible idea into a decision.
|
||||
|
||||
Update the Task Ledger when important knowledge changes.
|
||||
|
||||
## Progress Ledger
|
||||
|
||||
Maintain `harness/STATE.md` as the Progress Ledger.
|
||||
|
||||
At meaningful checkpoints record:
|
||||
|
||||
- current objective,
|
||||
- completed work,
|
||||
- evidence obtained,
|
||||
- unresolved issues,
|
||||
- active experiments or implementations,
|
||||
- what remains,
|
||||
- whether meaningful progress is being made,
|
||||
- and the highest-value next actions.
|
||||
|
||||
Periodically ask:
|
||||
|
||||
1. Are we measurably closer to the actual end goal?
|
||||
2. Are we solving the right problem?
|
||||
3. Are we repeating an approach that is not working?
|
||||
4. Has new evidence invalidated part of the plan?
|
||||
5. Are important unknowns being hidden underneath implementation work?
|
||||
6. Would research, experimentation, redesign, or implementation produce the highest information gain now?
|
||||
|
||||
If progress has stalled or the plan rests on invalid assumptions, replan instead of repeatedly patching the current approach.
|
||||
|
||||
## Durable decisions and evidence
|
||||
|
||||
Maintain `harness/DECISIONS.md` for important long-lived decisions.
|
||||
|
||||
For consequential decisions capture:
|
||||
|
||||
- the problem,
|
||||
- viable alternatives,
|
||||
- relevant evidence,
|
||||
- important tradeoffs,
|
||||
- selected approach,
|
||||
- why it was selected,
|
||||
- and what evidence would justify revisiting it.
|
||||
|
||||
Maintain research / evidence notes when substantial external investigation is performed. Prefer primary and authoritative sources.
|
||||
|
||||
Do not create documentation merely for ceremony. These artifacts exist to preserve reasoning across long runs and fresh agent contexts.
|
||||
|
||||
## Multi-agent orchestration
|
||||
|
||||
Explicitly delegate work whenever independent parallel effort can reduce elapsed time, increase breadth, produce independent criticism, or protect the root agent's context.
|
||||
|
||||
Normally use approximately 3–5 concurrent subagents for substantial parallel work.
|
||||
|
||||
Do not spawn agents merely to satisfy a role list.
|
||||
|
||||
Create roles dynamically according to the problem.
|
||||
|
||||
Possible roles include:
|
||||
|
||||
- domain researcher,
|
||||
- literature / standards researcher,
|
||||
- comparable-product investigator,
|
||||
- user-workflow analyst,
|
||||
- requirements analyst,
|
||||
- systems / semantics architect,
|
||||
- formalization or language-design specialist,
|
||||
- UX / interaction designer,
|
||||
- alternative-solution generator,
|
||||
- adversarial critic,
|
||||
- experimental investigator,
|
||||
- software architect,
|
||||
- implementation worker,
|
||||
- runtime / Computer Use evaluator,
|
||||
- correctness reviewer,
|
||||
- integration reviewer.
|
||||
|
||||
Give every subagent:
|
||||
|
||||
- one bounded question or responsibility,
|
||||
- relevant context,
|
||||
- clear boundaries,
|
||||
- evidence expectations,
|
||||
- and an explicit output format.
|
||||
|
||||
Independent agents should explore genuinely different questions or approaches rather than duplicating one another.
|
||||
|
||||
Use fresh agents for independent criticism when practical.
|
||||
|
||||
Do not let several agents concurrently edit the same files. Partition implementation ownership or use isolated worktrees.
|
||||
|
||||
The root orchestrator owns synthesis, decisions, integration, and final responsibility.
|
||||
|
||||
## Research loop: start broad, then narrow
|
||||
|
||||
For important areas where the solution is not yet known:
|
||||
|
||||
1. Map the problem space before committing to a solution.
|
||||
2. Delegate independent research tracks in parallel.
|
||||
3. Prefer authoritative and primary sources.
|
||||
4. Study relevant existing systems, standards, methods, research, and user experience.
|
||||
5. Separate observations from interpretations.
|
||||
6. Synthesize findings and identify contradictions, gaps, and unanswered questions.
|
||||
7. Decide whether another focused research wave is justified.
|
||||
8. Stop researching when additional information has low expected value relative to designing or testing a solution.
|
||||
|
||||
Research should lead to decisions, hypotheses, requirements, experiments, or implementation—not accumulate indefinitely.
|
||||
|
||||
## Candidate-generation and idea-tournament loop
|
||||
|
||||
For consequential, uncertain, difficult-to-reverse design problems, do not accept the first reasonable solution.
|
||||
|
||||
Use an idea-tournament process.
|
||||
|
||||
### Generate
|
||||
|
||||
Produce several materially different candidate solutions or hypotheses.
|
||||
|
||||
Aim for diversity rather than cosmetic variations of one idea.
|
||||
|
||||
### Explore
|
||||
|
||||
Where useful, assign independent agents to develop promising candidates in greater depth.
|
||||
|
||||
### Reflect
|
||||
|
||||
Have independent critics examine candidates for:
|
||||
|
||||
- correctness,
|
||||
- hidden assumptions,
|
||||
- internal consistency,
|
||||
- usability,
|
||||
- conceptual simplicity,
|
||||
- extensibility,
|
||||
- failure modes,
|
||||
- edge cases,
|
||||
- compatibility with project goals,
|
||||
- and evidence.
|
||||
|
||||
Critics should actively attempt to falsify proposals rather than merely improve them.
|
||||
|
||||
### Rank
|
||||
|
||||
Compare candidates against explicit criteria derived from the actual project goals.
|
||||
|
||||
Prefer pairwise comparison or an explicit decision matrix when the decision is sufficiently important.
|
||||
|
||||
Do not rank based on eloquence.
|
||||
|
||||
### Evolve
|
||||
|
||||
Improve the strongest candidates.
|
||||
|
||||
Combine complementary ideas when a hybrid is genuinely better.
|
||||
|
||||
Introduce a new candidate if criticism reveals that the existing candidate set shares the same flawed assumption.
|
||||
|
||||
### Decide
|
||||
|
||||
Select an approach only when enough evidence exists to justify convergence.
|
||||
|
||||
Record the decision and why alternatives lost.
|
||||
|
||||
Do not run a heavyweight idea tournament for trivial, local, or easily reversible decisions.
|
||||
|
||||
## Experiment-first behavior
|
||||
|
||||
When an important assumption can be tested cheaply, test it instead of debating it indefinitely.
|
||||
|
||||
Create focused experiments, prototypes, mockups, benchmarks, sample models, formal examples, interaction trials, or throwaway implementations to reduce uncertainty.
|
||||
|
||||
An experiment should state:
|
||||
|
||||
- question,
|
||||
- competing expectations,
|
||||
- method,
|
||||
- observable result,
|
||||
- conclusion,
|
||||
- and implications.
|
||||
|
||||
Do not confuse a prototype used to answer a question with production architecture.
|
||||
|
||||
Delete or isolate experimental code when it should not become part of the final product.
|
||||
|
||||
## Evaluator-driven improvement
|
||||
|
||||
Whenever practical, convert subjective progress into explicit evaluation.
|
||||
|
||||
Before optimizing a solution, determine how quality can be measured.
|
||||
|
||||
Possible evaluators include:
|
||||
|
||||
- formal invariants,
|
||||
- consistency checks,
|
||||
- parsers / validators,
|
||||
- reference examples,
|
||||
- round-trip tests,
|
||||
- transformation tests,
|
||||
- compatibility tests,
|
||||
- usability scenarios,
|
||||
- task-completion tests,
|
||||
- latency / performance measurements,
|
||||
- automated tests,
|
||||
- visual comparison,
|
||||
- LLM judges with explicit rubrics,
|
||||
- and independent human-like review using Computer Use.
|
||||
|
||||
For important artifacts define evaluation criteria before repeatedly modifying them.
|
||||
|
||||
Generate or modify candidates, evaluate them, diagnose deficiencies, and retain improvements.
|
||||
|
||||
Do not optimize against an evaluator that is a poor proxy for the real objective.
|
||||
|
||||
## Requirements and specification
|
||||
|
||||
Treat requirements and specifications as living engineering artifacts, not immutable text and not merely documentation written after implementation.
|
||||
|
||||
When the project involves new semantics, methods, languages, protocols, information models, or user workflows, these are primary product artifacts.
|
||||
|
||||
Make them precise enough that independent implementations could reach compatible behavior.
|
||||
|
||||
Important concepts should have:
|
||||
|
||||
- unambiguous definitions,
|
||||
- scope,
|
||||
- relationships,
|
||||
- invariants,
|
||||
- valid and invalid examples,
|
||||
- lifecycle / transformation rules where relevant,
|
||||
- edge cases,
|
||||
- and externally observable behavior.
|
||||
|
||||
Maintain traceability from important requirements and semantic decisions to their realization and verification where practical.
|
||||
|
||||
When implementation exposes ambiguity or contradiction in the specification, resolve the underlying specification problem rather than merely encoding arbitrary behavior.
|
||||
|
||||
## Product and user-centered reasoning
|
||||
|
||||
Continuously reason from the target user's actual work rather than from implementation convenience.
|
||||
|
||||
Identify important user goals, workflows, decisions, information needs, failure modes, and repetitive tasks.
|
||||
|
||||
When studying an existing product:
|
||||
|
||||
Do not merely clone features.
|
||||
|
||||
For each important capability determine:
|
||||
|
||||
1. What underlying user need does it satisfy?
|
||||
2. What underlying domain concept does it represent?
|
||||
3. What is powerful about the existing solution?
|
||||
4. What causes friction or confusion?
|
||||
5. Which limitations are accidental implementation / history artifacts?
|
||||
6. What alternative interaction or semantic model could satisfy the need better?
|
||||
7. How can the improvement be evaluated?
|
||||
|
||||
Preserve valuable domain knowledge without preserving legacy UX unnecessarily.
|
||||
|
||||
## Planning
|
||||
|
||||
Maintain a living execution plan in `harness/PLAN.md` unless an equivalent already exists.
|
||||
|
||||
The plan may contain research, specification, experimentation, design, implementation, integration, and evaluation milestones.
|
||||
|
||||
Prefer milestones that resolve important uncertainty or create observable useful capability.
|
||||
|
||||
Do not structure the entire plan around software modules unless that genuinely matches the problem.
|
||||
|
||||
Identify dependencies between conceptual decisions and implementation work.
|
||||
|
||||
Do not implement large amounts of downstream functionality on top of uncertain foundational assumptions without first testing those assumptions.
|
||||
|
||||
Revise the plan when evidence demands it.
|
||||
|
||||
## Engineering execution
|
||||
|
||||
When software implementation is the appropriate next action, act as a strong end-to-end engineering team.
|
||||
|
||||
Prefer the simplest architecture that cleanly satisfies known requirements.
|
||||
|
||||
Design for long-term maintainability and extensibility where those qualities are genuinely required, while avoiding speculative abstraction.
|
||||
|
||||
Reuse appropriate existing conventions.
|
||||
|
||||
Keep responsibilities and interfaces explicit.
|
||||
|
||||
Fix root causes rather than accumulating workarounds.
|
||||
|
||||
Avoid unrelated refactoring.
|
||||
|
||||
Delete obsolete scaffolding and abandoned approaches.
|
||||
|
||||
Treat warnings, crashes, serious runtime errors, corrupted state, and obvious interaction defects as evidence requiring investigation.
|
||||
|
||||
For an implementation milestone:
|
||||
|
||||
1. Define expected observable behavior.
|
||||
2. Inspect relevant architecture and constraints.
|
||||
3. Implement the smallest coherent solution.
|
||||
4. Build it.
|
||||
5. Run it.
|
||||
6. Exercise real behavior.
|
||||
7. Collect evidence.
|
||||
8. Diagnose failures.
|
||||
9. Fix root causes.
|
||||
10. Run proportionate automated verification.
|
||||
11. Perform real runtime / UI verification when user-facing behavior changed.
|
||||
12. Update durable state.
|
||||
13. Continue.
|
||||
|
||||
Do not declare functionality complete from code inspection alone.
|
||||
|
||||
## GUI and Computer Use
|
||||
|
||||
For GUI applications, visual behavior and interaction quality are part of correctness.
|
||||
|
||||
Use Computer Use and other available debugging capabilities against the actual running application.
|
||||
|
||||
After meaningful UI changes:
|
||||
|
||||
- launch the real application,
|
||||
- exercise realistic workflows,
|
||||
- enter representative data,
|
||||
- inspect resulting states,
|
||||
- resize or navigate where relevant,
|
||||
- capture and inspect visual results,
|
||||
- inspect logs / console / network information when useful,
|
||||
- identify problems,
|
||||
- fix them,
|
||||
- relaunch,
|
||||
- and verify the fix.
|
||||
|
||||
Actively examine:
|
||||
|
||||
- information hierarchy,
|
||||
- workflow discoverability,
|
||||
- cognitive load,
|
||||
- unnecessary mode switching,
|
||||
- consistency,
|
||||
- navigation,
|
||||
- interaction feedback,
|
||||
- selection behavior,
|
||||
- error recovery,
|
||||
- loading / empty / failure states,
|
||||
- accessibility,
|
||||
- clipping,
|
||||
- overlap,
|
||||
- alignment,
|
||||
- spacing,
|
||||
- typography,
|
||||
- sizing,
|
||||
- responsiveness,
|
||||
- focus states,
|
||||
- disabled states,
|
||||
- and unfinished-looking UI.
|
||||
|
||||
Do not equate technical functionality with usability.
|
||||
|
||||
## Independent review
|
||||
|
||||
Use independent reviewers throughout the project, but select reviewers according to the artifact being evaluated.
|
||||
|
||||
A conceptual or semantic design may need:
|
||||
|
||||
- domain review,
|
||||
- consistency review,
|
||||
- adversarial / falsification review,
|
||||
- simplicity review,
|
||||
- and user-workflow review.
|
||||
|
||||
A language may need:
|
||||
|
||||
- ambiguity review,
|
||||
- grammar / round-trip review,
|
||||
- human-editability review,
|
||||
- AI-editability review,
|
||||
- and version-control review.
|
||||
|
||||
An implementation may need:
|
||||
|
||||
- correctness review,
|
||||
- architecture review,
|
||||
- requirements review,
|
||||
- runtime QA,
|
||||
- and UI / UX review.
|
||||
|
||||
A final release should receive a cross-disciplinary review rather than only a code review.
|
||||
|
||||
Require concrete findings and evidence.
|
||||
|
||||
For material findings:
|
||||
|
||||
1. verify the finding,
|
||||
2. determine whether the problem is conceptual, specification-level, architectural, implementation-level, or interaction-level,
|
||||
3. fix it at the correct layer,
|
||||
4. reevaluate affected downstream artifacts,
|
||||
5. verify again.
|
||||
|
||||
## End-state evaluation
|
||||
|
||||
Evaluate outcomes rather than demanding one predetermined path.
|
||||
|
||||
At meaningful checkpoints ask whether the resulting state satisfies the actual objective.
|
||||
|
||||
Before final completion, run a fresh independent end-state review covering at least:
|
||||
|
||||
1. Requirements completeness.
|
||||
2. Domain / semantic coherence.
|
||||
3. Internal consistency and edge cases.
|
||||
4. Target-user workflows and usability.
|
||||
5. Architecture and long-term maintainability.
|
||||
6. Implementation correctness and regression risk.
|
||||
7. Actual runtime behavior.
|
||||
8. Documentation / specification adequacy.
|
||||
9. Remaining assumptions, unknowns, and risks.
|
||||
|
||||
Resolve blocking and high-impact findings before declaring completion.
|
||||
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
|
||||
|
||||
Do not weaken completion criteria merely because substantial time or context has been consumed.
|
||||
Apply these criteria to the scope requested by the user:
|
||||
|
||||
The project is complete only when all applicable conditions are satisfied:
|
||||
- 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.
|
||||
|
||||
- The intended user problem is coherently addressed.
|
||||
- Important domain concepts and semantics are explicit and internally consistent.
|
||||
- Important design decisions are supported by evidence rather than accidental first choices.
|
||||
- Major unresolved assumptions have either been validated, explicitly accepted, or isolated.
|
||||
- Required specifications accurately describe implemented behavior.
|
||||
- Primary user workflows are implemented.
|
||||
- The application builds and launches reliably.
|
||||
- Real workflows have been exercised on the running application.
|
||||
- Important edge / failure / empty / loading states behave sensibly.
|
||||
- Relevant automated evaluators and tests pass.
|
||||
- UI/interaction quality has been inspected on the actual application.
|
||||
- No known blocker or high-severity defects remain.
|
||||
- Independent cross-disciplinary review has been completed.
|
||||
- Valid material findings have been addressed and reverified.
|
||||
- Architecture is understandable and appropriately extensible.
|
||||
- Temporary and obsolete scaffolding has been removed.
|
||||
- Durable project state reflects reality.
|
||||
- Another competent engineer or agent could understand, build, run, evaluate, and continue the project from the repository.
|
||||
|
||||
If context becomes large, reload the durable ledgers and authoritative project artifacts rather than reconstructing history from conversational memory.
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Only when the completion criteria are satisfied, provide a concise handoff covering:
|
||||
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.
|
||||
|
||||
- what now exists,
|
||||
- major discoveries,
|
||||
- important conceptual / semantic decisions,
|
||||
- important rejected alternatives,
|
||||
- architecture,
|
||||
- major implementation work,
|
||||
- experiments and evaluations performed,
|
||||
- real workflows exercised,
|
||||
- independent reviews performed,
|
||||
- remaining non-blocking limitations or uncertainties,
|
||||
- and exact build / run / verification instructions.
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
Do not provide routine narration merely for the sake of narration.
|
||||
|
||||
Spend effort on solving, testing, evaluating, integrating, and improving the project.
|
||||
|
||||
## Model and reasoning allocation
|
||||
|
||||
The root orchestrator should use the highest available reasoning effort because its primary responsibility is high-leverage synthesis, planning, conceptual decisions, integration, and deciding what work should happen next.
|
||||
|
||||
Do not automatically use maximum reasoning for every delegated task.
|
||||
|
||||
Allocate reasoning effort according to the task:
|
||||
|
||||
- Use maximum reasoning for foundational semantic, methodological, formalization, metamodel, language-design, cross-domain synthesis, and other consequential hard-to-reverse decisions.
|
||||
- Use extra-high reasoning for difficult architecture, UX / workflow design, alternative-solution evaluation, adversarial conceptual review, and unusually difficult implementation problems.
|
||||
- Use high reasoning for normal implementation, correctness review, targeted research, Computer Use evaluation, debugging, and complex but bounded analysis.
|
||||
- Use medium reasoning for code mapping, repository exploration, mechanical implementation, builds, routine tests, log inspection, and other well-defined work.
|
||||
- Use lower effort only for genuinely trivial mechanical tasks.
|
||||
|
||||
Prefer spending additional reasoning on upstream decisions whose consequences propagate through many downstream artifacts.
|
||||
|
||||
Do not spend maximum reasoning merely because a task is long. Distinguish computational volume from conceptual difficulty.
|
||||
|
||||
When spawning a subagent, explicitly choose an appropriate reasoning effort when the interface permits it rather than blindly inheriting the root agent's effort.
|
||||
|
||||
Use GPT-6 Astra model exclusively.
|
||||
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
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -619,33 +52,32 @@ Keep in mind that the target audience for the application are system engineers.
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
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 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
|
||||
@Computer
|
||||
|
||||
### Semantic model / metamodel
|
||||
|
||||
Requirements:
|
||||
|
||||
- Textual notation.
|
||||
- Graphical notation.
|
||||
- Constraints and calculations.
|
||||
@@ -658,7 +90,6 @@ Requirements:
|
||||
#### 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.
|
||||
@@ -672,8 +103,9 @@ Long, homogeneous lists in the UI that require user to scroll far or search for
|
||||
|
||||
## 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
|
||||
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
|
||||
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 -->
|
||||
|
||||
@@ -18,7 +18,7 @@ From the repository root:
|
||||
cargo run --locked -p oa-desktop --bin arcadia-studio
|
||||
```
|
||||
|
||||
Build both desktop and server release executables:
|
||||
Build the desktop, server and source CLI release executables:
|
||||
|
||||
```sh
|
||||
cargo build --release --workspace --locked
|
||||
@@ -51,6 +51,21 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
|
||||
|
||||
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
|
||||
|
||||
## Work with source files from the command line
|
||||
|
||||
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
|
||||
starting the desktop or server:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
`stabilize ROOT` previews the identities needed for persistent editing;
|
||||
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
|
||||
`--write` to save that invocation's candidate, or `--json` for structured
|
||||
diagnostics and changed-file text/diffs. Preview leaves files untouched.
|
||||
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
|
||||
|
||||
## Model operational needs and system responsibilities
|
||||
|
||||
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
|
||||
@@ -63,7 +78,7 @@ The architecture diagram has a compact side palette; expand it to see tool names
|
||||
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.
|
||||
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. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
|
||||
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**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. 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.
|
||||
|
||||
@@ -87,6 +102,8 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
|
||||
|
||||
**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:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "oa-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "arcadia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
oa-core = { path = "../oa-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
|
||||
use oa_core::{
|
||||
Command, language,
|
||||
persistence::MigrationReport,
|
||||
prepare_command,
|
||||
source_files::{self, DiskProject, SourceFileError},
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1024,
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Operation {
|
||||
Check,
|
||||
Stabilize,
|
||||
RenameAlias { old: String, new: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Location {
|
||||
pub path: String,
|
||||
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub coordinate_space: String,
|
||||
pub location: Option<Location>,
|
||||
pub related: Vec<Location>,
|
||||
pub object_id: Option<String>,
|
||||
pub field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
/// Exact captured and proposed UTF-8 text, including line endings.
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub format_version: u32,
|
||||
pub entry: String,
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub exit_code: u8,
|
||||
/// True only after a successful changed save. False on failure does not
|
||||
/// promise rollback: consult the specific error/recovery outcome.
|
||||
pub written: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub changes: Vec<Change>,
|
||||
pub identities_to_stabilize: usize,
|
||||
pub migration: Option<MigrationReport>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn failure(
|
||||
entry: &Path,
|
||||
operation: &str,
|
||||
exit_code: u8,
|
||||
code: &str,
|
||||
message: String,
|
||||
) -> Self {
|
||||
let mut report = Self::empty(entry, operation);
|
||||
report.fail(exit_code, code, message);
|
||||
report
|
||||
}
|
||||
|
||||
fn empty(entry: &Path, operation: &str) -> Self {
|
||||
Self {
|
||||
format_version: 1,
|
||||
entry: entry.to_string_lossy().into_owned(),
|
||||
operation: operation.into(),
|
||||
status: "checked".into(),
|
||||
exit_code: 0,
|
||||
written: false,
|
||||
diagnostics: vec![],
|
||||
changes: vec![],
|
||||
identities_to_stabilize: 0,
|
||||
migration: None,
|
||||
notes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
|
||||
self.status = "rejected".into();
|
||||
self.exit_code = exit_code;
|
||||
self.diagnostics.push(Diagnostic {
|
||||
severity: "error".into(),
|
||||
code: code.into(),
|
||||
message,
|
||||
coordinate_space: "operation".into(),
|
||||
location: None,
|
||||
related: vec![],
|
||||
object_id: None,
|
||||
field: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
|
||||
let d = issue.diagnostic;
|
||||
Diagnostic {
|
||||
severity: match d.severity {
|
||||
Severity::Error => "error",
|
||||
Severity::Warning => "warning",
|
||||
}
|
||||
.into(),
|
||||
code: d.code,
|
||||
message: d.message,
|
||||
coordinate_space: space.into(),
|
||||
location: Some(Location {
|
||||
path: issue.path,
|
||||
start: d.span.start,
|
||||
end: d.span.end,
|
||||
line: Some(d.line),
|
||||
column: Some(d.column),
|
||||
}),
|
||||
related: issue
|
||||
.related
|
||||
.into_iter()
|
||||
.map(|at| Location {
|
||||
path: at.path,
|
||||
start: at.span.start,
|
||||
end: at.span.end,
|
||||
line: None,
|
||||
column: None,
|
||||
})
|
||||
.collect(),
|
||||
object_id: d.object_id.map(|id| id.to_string()),
|
||||
field: d.field,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_error(report: &mut Report, error: SourceFileError) {
|
||||
let code = match &error {
|
||||
SourceFileError::Conflict(_) => "external_change",
|
||||
SourceFileError::RecoveryRequired(_) => "recovery_required",
|
||||
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
|
||||
SourceFileError::Path(_) => "unsafe_path",
|
||||
SourceFileError::Limit(_) => "source_limit",
|
||||
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
|
||||
SourceFileError::Invalid(_) => "invalid_save_candidate",
|
||||
SourceFileError::Io { .. } => "io_error",
|
||||
};
|
||||
report.fail(3, code, error.to_string());
|
||||
}
|
||||
|
||||
/// A candidate owns its original expectations and is consumed by saving.
|
||||
/// Callers cannot replace its source/model/expectations after preparation.
|
||||
pub struct Prepared {
|
||||
report: Report,
|
||||
snapshot: DiskProject,
|
||||
compiled: CompiledSourceProject,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn report(&self) -> &Report {
|
||||
&self.report
|
||||
}
|
||||
|
||||
pub fn into_report(self) -> Report {
|
||||
self.report
|
||||
}
|
||||
|
||||
pub fn write(mut self) -> Report {
|
||||
if !self.mutable {
|
||||
self.report
|
||||
.fail(2, "usage", "check/lint does not accept --write".into());
|
||||
return self.report;
|
||||
}
|
||||
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
|
||||
Ok(_) => {
|
||||
self.report.written = !self.report.changes.is_empty();
|
||||
self.report.status = if self.report.written {
|
||||
"written"
|
||||
} else {
|
||||
"unchanged"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Err(error) => disk_error(&mut self.report, error),
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
|
||||
let name = match operation {
|
||||
Operation::Check => "check",
|
||||
Operation::Stabilize => "stabilize",
|
||||
Operation::RenameAlias { .. } => "rename-alias",
|
||||
};
|
||||
let mut report = Report::empty(entry, name);
|
||||
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
|
||||
disk_error(&mut report, error);
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
let raw = snapshot.reachable_bundle();
|
||||
// The loader may include warnings from stabilized compilation. Only its
|
||||
// discovery/IO diagnostics are authoritative on the original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save"
|
||||
| "source_encoding"
|
||||
| "source_limit"
|
||||
| "invalid_path"
|
||||
| "missing_include"
|
||||
| "project_limit"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
||||
if !diagnostics.contains(&diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save" | "missing_include" | "invalid_path"
|
||||
)
|
||||
});
|
||||
let invalid = diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == Severity::Error);
|
||||
report.diagnostics = diagnostics
|
||||
.into_iter()
|
||||
.map(|d| located(d, "input"))
|
||||
.collect();
|
||||
if invalid {
|
||||
// Do not run declaration analysis or any updater on invalid raw text.
|
||||
// Failed lint may also have allocated provisional object identities.
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = if disk_refusal { 3 } else { 1 };
|
||||
return Err(Box::new(report));
|
||||
}
|
||||
let explicit_ids: std::collections::BTreeSet<_> = raw
|
||||
.files
|
||||
.values()
|
||||
.flat_map(|text| language::analyze(text).declarations)
|
||||
.filter_map(|d| d.id.map(|id| id.to_string()))
|
||||
.collect();
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
if diagnostic
|
||||
.object_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !explicit_ids.contains(id))
|
||||
{
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
}
|
||||
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
report.identities_to_stabilize = raw
|
||||
.files
|
||||
.values()
|
||||
.map(|text| {
|
||||
language::analyze(text)
|
||||
.declarations
|
||||
.into_iter()
|
||||
.filter(|d| d.id.is_none())
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
report.migration = compiled.decoded.migration.clone();
|
||||
let mutable = !matches!(operation, Operation::Check);
|
||||
if let Operation::RenameAlias { old, new } = operation {
|
||||
let project = &compiled.decoded.project;
|
||||
let Some((id, _)) = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == old)
|
||||
else {
|
||||
report.fail(
|
||||
1,
|
||||
"unknown_alias",
|
||||
format!("No declaration has alias '{old}'."),
|
||||
);
|
||||
return Err(Box::new(report));
|
||||
};
|
||||
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
||||
report.fail(1, "semantic_command_rejected", error.to_string());
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
compiled = source_project::update_bundle_with_identity_map(
|
||||
&compiled,
|
||||
&command.project,
|
||||
&command.identity_map,
|
||||
LIMITS,
|
||||
)
|
||||
.map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
}
|
||||
if mutable {
|
||||
for (path, after) in &compiled.bundle.files {
|
||||
let before = raw.files.get(path).cloned().unwrap_or_default();
|
||||
if &before != after {
|
||||
report.changes.push(Change {
|
||||
path: path.clone(),
|
||||
diff: unified_diff(path, &before, after),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
report.status = "preview".into();
|
||||
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
||||
}
|
||||
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
||||
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
||||
Ok(Prepared {
|
||||
report,
|
||||
snapshot,
|
||||
compiled,
|
||||
mutable,
|
||||
})
|
||||
}
|
||||
|
||||
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
||||
/// Linear in input size; no quadratic LCS allocation for large modules.
|
||||
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
||||
let a: Vec<_> = before.split_inclusive('\n').collect();
|
||||
let b: Vec<_> = after.split_inclusive('\n').collect();
|
||||
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let suffix = a[prefix..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(b[prefix..].iter().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let start = prefix.saturating_sub(3);
|
||||
let a_end = (a.len() - suffix + 3).min(a.len());
|
||||
let b_end = (b.len() - suffix + 3).min(b.len());
|
||||
let a_start = if a_end == start { start } else { start + 1 };
|
||||
let b_start = if b_end == start { start } else { start + 1 };
|
||||
let mut out = format!(
|
||||
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
||||
a_end - start,
|
||||
b_end - start
|
||||
);
|
||||
let mut emit = |prefix: char, line: &str| {
|
||||
out.push(prefix);
|
||||
out.push_str(line);
|
||||
if !line.ends_with('\n') {
|
||||
out.push_str("\n\\ No newline at end of file\n");
|
||||
}
|
||||
};
|
||||
for line in &a[start..prefix] {
|
||||
emit(' ', line);
|
||||
}
|
||||
for line in &a[prefix..a.len() - suffix] {
|
||||
emit('-', line);
|
||||
}
|
||||
for line in &b[prefix..b.len() - suffix] {
|
||||
emit('+', line);
|
||||
}
|
||||
for line in &a[a.len() - suffix..a_end] {
|
||||
emit(' ', line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Arguments {
|
||||
pub entry: PathBuf,
|
||||
pub operation: Operation,
|
||||
pub write: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
||||
|
||||
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||
let mut positional = Vec::new();
|
||||
let (mut write, mut json) = (false, false);
|
||||
for arg in args {
|
||||
if arg == "--write" && !write {
|
||||
write = true;
|
||||
} else if arg == "--json" && !json {
|
||||
json = true;
|
||||
} else if arg.to_string_lossy().starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown or repeated option: {}",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let operation = match (command, positional.len()) {
|
||||
("check" | "lint", 2) if !write => Operation::Check,
|
||||
("stabilize", 2) => Operation::Stabilize,
|
||||
("rename-alias", 4) => Operation::RenameAlias {
|
||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
},
|
||||
_ => return Err(HELP.into()),
|
||||
};
|
||||
Ok(Arguments {
|
||||
entry: PathBuf::from(positional[1]),
|
||||
operation,
|
||||
write,
|
||||
json,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use oa_cli::{Report, arguments, prepare};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
|
||||
let mut err = io::stderr().lock();
|
||||
for d in &report.diagnostics {
|
||||
let at = d
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|l| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
l.path,
|
||||
l.line.unwrap_or(0),
|
||||
l.column.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| report.entry.clone());
|
||||
writeln!(
|
||||
err,
|
||||
"{at}: {}[{}] ({}): {}",
|
||||
d.severity, d.code, d.coordinate_space, d.message
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
err,
|
||||
"{}: {} file changes; {} identities to stabilize",
|
||||
report.status,
|
||||
report.changes.len(),
|
||||
report.identities_to_stabilize
|
||||
)?;
|
||||
if let Some(migration) = &report.migration {
|
||||
writeln!(
|
||||
err,
|
||||
"Source migration: schema {} → {} (separate from identity stabilization)",
|
||||
migration.from_schema, migration.to_schema
|
||||
)?;
|
||||
}
|
||||
for note in &report.notes {
|
||||
writeln!(err, "{note}")?;
|
||||
}
|
||||
if show_diff {
|
||||
let mut out = io::stdout().lock();
|
||||
for change in &report.changes {
|
||||
write!(out, "{}", change.diff)?;
|
||||
}
|
||||
out.flush()?;
|
||||
}
|
||||
err.flush()
|
||||
}
|
||||
|
||||
fn run() -> io::Result<u8> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
|
||||
return Ok(0);
|
||||
}
|
||||
if args.len() == 1 && args[0] == "--version" {
|
||||
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
|
||||
return Ok(0);
|
||||
}
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let mut displayed = false;
|
||||
let report = match arguments(&args) {
|
||||
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
||||
Ok(args) => match prepare(&args.entry, args.operation) {
|
||||
Err(report) => *report,
|
||||
Ok(prepared) => {
|
||||
if args.write {
|
||||
if !args.json {
|
||||
human_report(prepared.report(), true)?;
|
||||
displayed = true;
|
||||
}
|
||||
prepared.write()
|
||||
} else {
|
||||
prepared.into_report()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if json {
|
||||
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
|
||||
writeln!(io::stdout())?;
|
||||
} else {
|
||||
human_report(&report, !displayed)?;
|
||||
}
|
||||
Ok(report.exit_code)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
let _ = writeln!(io::stderr(), "Output failed: {error}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use oa_cli::{LIMITS, Operation, Prepared, prepare};
|
||||
use oa_core::{Id, Project, source_files, source_project};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn entry(&self) -> PathBuf {
|
||||
self.0.join("project.arcadia")
|
||||
}
|
||||
fn read(&self, name: &str) -> String {
|
||||
fs::read_to_string(self.0.join(name)).unwrap()
|
||||
}
|
||||
fn prepare(&self, op: Operation) -> Prepared {
|
||||
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
|
||||
}
|
||||
fn model(&self) -> Project {
|
||||
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
|
||||
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
}
|
||||
fn cli(&self, args: &[&str]) -> (i32, Value) {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
|
||||
command
|
||||
.arg(args[0])
|
||||
.arg(self.entry())
|
||||
.args(&args[1..])
|
||||
.arg("--json");
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(
|
||||
output.status.code().unwrap(),
|
||||
serde_json::from_slice(&output.stdout).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
|
||||
|
||||
#[test]
|
||||
fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let fixture = Fixture::new();
|
||||
let source = include_str!(
|
||||
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
);
|
||||
fixture.put("project.arcadia", source);
|
||||
let (code, check) = fixture.cli(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(check["diagnostics"], serde_json::json!([]));
|
||||
let (code, preview) = fixture.cli(&["stabilize"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(preview["identities_to_stabilize"], 40);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert!(
|
||||
preview["changes"][0]["diff"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("+project alarm_response")
|
||||
);
|
||||
let (code, written) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let saved = fixture.read("project.arcadia");
|
||||
assert_eq!(written["changes"][0]["after"], saved);
|
||||
let project = fixture.model();
|
||||
assert_eq!(project.identities().len(), 40);
|
||||
let mut stripped = saved.clone();
|
||||
for (id, _) in project.identities() {
|
||||
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
||||
}
|
||||
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
||||
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(again["changes"], serde_json::json!([]));
|
||||
assert_eq!(again["status"], "unchanged");
|
||||
assert_eq!(fixture.model(), project);
|
||||
assert_eq!(fixture.read("project.arcadia"), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let candidate = fixture.prepare(Operation::Stabilize);
|
||||
let expected = candidate.report().changes[0].after.clone();
|
||||
assert_eq!(candidate.report().identities_to_stabilize, 2);
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
assert_eq!(fixture.read("project.arcadia"), expected);
|
||||
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
|
||||
assert!(!expected.contains("description"));
|
||||
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
|
||||
let report = fixture.prepare(Operation::Stabilize).write();
|
||||
assert_eq!(report.status, "unchanged");
|
||||
assert!(!report.written);
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
|
||||
fixture.put(
|
||||
"participants.arcadia",
|
||||
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
|
||||
);
|
||||
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
|
||||
.into_iter()
|
||||
.map(|p| (p, fixture.read(p)))
|
||||
.collect();
|
||||
let team = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "team")
|
||||
.unwrap()
|
||||
.0;
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "rescue_team".into(),
|
||||
});
|
||||
assert_eq!(candidate.report().changes.len(), 2);
|
||||
assert_eq!(fixture.model(), before, "preview cannot mutate");
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
let mut expected = before;
|
||||
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
|
||||
assert_eq!(
|
||||
fixture.read("participants.arcadia"),
|
||||
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("work.arcadia"),
|
||||
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
for command in [
|
||||
vec!["check"],
|
||||
vec!["stabilize", "--write"],
|
||||
vec!["rename-alias", "a", "b", "--write"],
|
||||
] {
|
||||
let (code, report) = fixture.cli(&command);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
let diagnostics = report["diagnostics"].as_array().unwrap();
|
||||
assert!(!diagnostics.is_empty());
|
||||
for d in diagnostics {
|
||||
assert_eq!(d["coordinate_space"], "input");
|
||||
let at = &d["location"];
|
||||
let start = at["start"].as_u64().unwrap() as usize;
|
||||
assert!(raw.is_char_boundary(start));
|
||||
assert_eq!(
|
||||
at["line"].as_u64().unwrap() as usize,
|
||||
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
|
||||
);
|
||||
let line = raw[..start]
|
||||
.rsplit_once('\n')
|
||||
.map_or(&raw[..start], |(_, tail)| tail);
|
||||
assert_eq!(
|
||||
at["column"].as_u64().unwrap() as usize,
|
||||
line.chars().count() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, result) = fixture.cli(&["lint"]);
|
||||
assert_eq!(code, 0, "engineering warnings do not reject admission");
|
||||
let warning = result["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["code"] == "work_without_behavior_context")
|
||||
.unwrap();
|
||||
assert_eq!(warning["severity"], "warning");
|
||||
assert_eq!(
|
||||
warning["location"]["start"],
|
||||
raw.find("oa_activity").unwrap()
|
||||
);
|
||||
assert_eq!(warning["location"]["line"], 3);
|
||||
assert_eq!(warning["location"]["column"], 5);
|
||||
assert!(warning["object_id"].is_null());
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let pending = fixture.prepare(Operation::Stabilize);
|
||||
let external = format!("{SIMPLE}// editor changed this\n");
|
||||
fixture.put("project.arcadia", &external);
|
||||
let result = pending.write();
|
||||
assert_eq!(result.exit_code, 3);
|
||||
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
|
||||
assert_eq!(fixture.read("project.arcadia"), external);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
assert!(noop.report().changes.is_empty());
|
||||
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
|
||||
fixture.put("project.arcadia", &latest);
|
||||
assert_eq!(noop.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
let source = fixture.read("project.arcadia");
|
||||
// An opaque incomplete journal must never be interpreted/recovered by CLI.
|
||||
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("sentinel"), b"retain").unwrap();
|
||||
for op in [Operation::Check, Operation::Stabilize] {
|
||||
let report = prepare(&fixture.entry(), op).err().unwrap();
|
||||
assert_eq!(report.exit_code, 3);
|
||||
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
|
||||
}
|
||||
assert_eq!(
|
||||
noop.write().exit_code,
|
||||
3,
|
||||
"saver must recheck after preview"
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_alias_and_same_alias_preserve_source() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let source = fixture.read("project.arcadia");
|
||||
for (old, new) in [
|
||||
("team", "rescue"),
|
||||
("team", "invalid alias"),
|
||||
("absent", "valid"),
|
||||
] {
|
||||
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(result["status"], "rejected");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(result["status"], "unchanged");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_missing_identity_and_separate_source_migration() {
|
||||
let fixture = Fixture::new();
|
||||
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
|
||||
fixture.put("project.arcadia", old);
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "crew".into(),
|
||||
});
|
||||
let report = candidate.report();
|
||||
assert_eq!(report.identities_to_stabilize, 2);
|
||||
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
||||
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
||||
assert!(report.changes[0].after.contains("oa_entity crew"));
|
||||
assert_eq!(fixture.read("project.arcadia"), old);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
|
||||
.current_dir(&fixture.0)
|
||||
.args(["check", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(error["diagnostics"][0]["code"], "usage");
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
let (code, _) = fixture.cli(&["check", "--write"]);
|
||||
assert_eq!(code, 2);
|
||||
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
|
||||
let fixture = Fixture::new();
|
||||
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
|
||||
fixture.put("project.arcadia", entry);
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 3, "a disk dependency could not be read");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "missing_include")
|
||||
);
|
||||
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1, "invalid encoding is invalid raw source");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "source_encoding")
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), entry);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("part.arcadia")).unwrap(),
|
||||
[0xff, 0xfe]
|
||||
);
|
||||
for raw in [
|
||||
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
|
||||
"arcadia 2; project p \"P\" {}",
|
||||
] {
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, _) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
|
||||
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
|
||||
fixture.put(
|
||||
"detached.arcadia",
|
||||
"invalid detached draft must stay exact 雨",
|
||||
);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let relation = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "responsible")
|
||||
.unwrap()
|
||||
.0;
|
||||
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let mut expected = before;
|
||||
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert!(
|
||||
fixture
|
||||
.read("project.arcadia")
|
||||
.contains("@ // keep edge comment\nteam")
|
||||
);
|
||||
let captured = fixture.prepare(Operation::RenameAlias {
|
||||
old: "p".into(),
|
||||
new: "rescue".into(),
|
||||
});
|
||||
assert!(
|
||||
captured
|
||||
.report()
|
||||
.changes
|
||||
.iter()
|
||||
.all(|c| c.path != "part.arcadia")
|
||||
);
|
||||
let root_before = fixture.read("project.arcadia");
|
||||
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
|
||||
assert_eq!(captured.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), root_before);
|
||||
assert_eq!(
|
||||
fixture.read("part.arcadia"),
|
||||
"// external unchanged-dependency edit\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("detached.arcadia"),
|
||||
"invalid detached draft must stay exact 雨"
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
|
||||
+306
-19
@@ -5,6 +5,7 @@ mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
@@ -13,6 +14,7 @@ mod transition_context_tests;
|
||||
mod view_copy_tests;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
behavior,
|
||||
document::Document,
|
||||
source_workspace::{self, LIMITS, Workspace},
|
||||
};
|
||||
@@ -317,6 +319,7 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
CheckpointTick,
|
||||
@@ -370,6 +373,8 @@ pub enum Message {
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
FieldReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -444,10 +449,14 @@ pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
reference_authoring: reference_authoring::State,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
menu_sequence: u64,
|
||||
pub canvas_document: Id,
|
||||
pub canvas_epoch: u64,
|
||||
/// Camera batches survive their own updates, never external presentation changes.
|
||||
pub camera_epoch: u64,
|
||||
camera_boundary_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
pub exact_strings: bool,
|
||||
pub document: Document,
|
||||
@@ -546,10 +555,13 @@ impl App {
|
||||
checkpoint_close: None,
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
reference_authoring: Default::default(),
|
||||
source_tree: Default::default(),
|
||||
menu_sequence: 0,
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
camera_epoch: 0,
|
||||
camera_boundary_epoch: 0,
|
||||
selected_bend: None,
|
||||
exact_strings: false,
|
||||
document,
|
||||
@@ -849,14 +861,28 @@ impl App {
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
let created = self.behavior.creation.created_by(self.project(), &command);
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
.created_selection(self.project(), &command)
|
||||
.or_else(|| {
|
||||
self.behavior
|
||||
.topology
|
||||
.created_selection(self.project(), &command)
|
||||
});
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = scenario_selection.or(selected) {
|
||||
if let Some(created) = created {
|
||||
self.layer = created.layer;
|
||||
self.selected = created.behavior;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(created.behavior);
|
||||
self.behavior.diagram = behavior::state_view::State::default();
|
||||
self.behavior.diagram.diagram = created.diagram;
|
||||
self.page = Page::Behavior;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
} else if let Some(id) = scenario_selection.or(selected) {
|
||||
self.selected = id;
|
||||
}
|
||||
self.sync();
|
||||
@@ -1012,7 +1038,10 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1020,6 +1049,10 @@ impl App {
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
behavior::creation::subscription(
|
||||
self.behavior.creation.has_pending() || self.behavior.source_active(),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
@@ -1064,6 +1097,108 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return self.behavior_control(intent);
|
||||
}
|
||||
let camera = matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let task = self.update_inner(message);
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
use behavior::canvas_workspace::{self, Event as Camera, Scope};
|
||||
use behavior::scenario::CanvasEvent as Scenario;
|
||||
use behavior::topology::CanvasEvent as Process;
|
||||
if Scope::input_owner(self) != Some(intent.scope) {
|
||||
return Task::none();
|
||||
}
|
||||
let mut message = *intent.message;
|
||||
let scope = intent.scope;
|
||||
let valid_scenario = |c: behavior::scenario::Context| {
|
||||
c.document == scope.document
|
||||
&& c.scenario == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
let valid_process = |c: behavior::topology::Context| {
|
||||
c.document == scope.document
|
||||
&& c.owner == scope.owner
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
// This whitelist contains only the production presentation controls.
|
||||
// A stamped toolbar cannot smuggle an arbitrary authoring command.
|
||||
match &mut message {
|
||||
Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_scenario(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus))
|
||||
if valid_process(*c) && *lifetime == scope.epoch =>
|
||||
{
|
||||
return canvas_workspace::focus_task(scope, Camera::Focus);
|
||||
}
|
||||
Message::ScenarioCanvas(
|
||||
Scenario::Zoom(c, _)
|
||||
| Scenario::Choose(c, _)
|
||||
| Scenario::Focus(c)
|
||||
| Scenario::Shelf(c, _),
|
||||
) if valid_scenario(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::TopologyCanvas(
|
||||
Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c),
|
||||
) if valid_process(*c) => {
|
||||
c.epoch = self.canvas_epoch;
|
||||
}
|
||||
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
|
||||
if self.topology_context().is_some() => {}
|
||||
_ => return Task::none(),
|
||||
}
|
||||
let task = self.update(message);
|
||||
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
|
||||
// after the action, for Iced's final-view ordering.
|
||||
if self.camera_epoch == scope.epoch {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| {
|
||||
canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name))
|
||||
});
|
||||
Task::batch([task, focus])
|
||||
}
|
||||
fn camera_toolbar_epoch(&self, epoch: u64) -> bool {
|
||||
epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch
|
||||
}
|
||||
fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_epoch,
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
crate::behavior::selected_owner(self),
|
||||
self.page,
|
||||
self.window_size,
|
||||
crate::behavior::canvas_workspace::keyboard_enabled(self),
|
||||
self.behavior.has_pending(),
|
||||
self.draft_dirty(),
|
||||
self.source_dirty(),
|
||||
self.behavior.topology.camera_boundary(),
|
||||
self.behavior.scenario.camera_boundary(),
|
||||
)
|
||||
}
|
||||
fn update_inner(&mut self, message: Message) -> Task<Message> {
|
||||
if let Some(task) = self.reference_message(&message) {
|
||||
return task;
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1286,6 +1421,7 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1355,6 +1491,7 @@ impl App {
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
Message::Home(event) => return self.home_event(event),
|
||||
Message::CheckpointTick => return self.checkpoint_tick(),
|
||||
@@ -1370,6 +1507,17 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::FormShown(_, session) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.take_form_focus(session)
|
||||
{
|
||||
return iced::widget::operation::focus(scenario::NAME_INPUT);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
@@ -1407,6 +1555,49 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.scenario_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.scenario == context.scenario
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
|
||||
}
|
||||
input => {
|
||||
if self.behavior.scenario.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1420,9 +1611,11 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
if self.behavior.scenario.zoom(scale) {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.scenario = state;
|
||||
}
|
||||
CanvasEvent::OpenUse(_, occurrence) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
@@ -1506,6 +1699,37 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::First(_, tool) => {
|
||||
let empty = match tool {
|
||||
scenario::Tool::Lifeline => !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline),
|
||||
scenario::Tool::Message | scenario::Tool::Action => {
|
||||
self.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline)
|
||||
&& !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Occurrence)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !empty {
|
||||
self.status="This scenario already has content. Use its current authoring tools.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
CanvasEvent::BeginOccurrence {
|
||||
tool,
|
||||
slot,
|
||||
@@ -1513,13 +1737,22 @@ impl App {
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
let result = if tool == scenario::Tool::Lifeline {
|
||||
state
|
||||
.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
)
|
||||
};
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -1741,6 +1974,18 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.source_active() {
|
||||
self.behavior.acknowledge_commit();
|
||||
self.status = "Behavior form cancelled; no staged source was created.".into();
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
}
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
@@ -1800,6 +2045,9 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => {
|
||||
return Task::none();
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
@@ -2284,9 +2532,33 @@ impl App {
|
||||
}
|
||||
Message::InspectorTab(tab) => self.inspector_tab = tab,
|
||||
Message::Behavior(event) => {
|
||||
if event.starts_form() && !self.behavior.has_pending() && !self.apply() {
|
||||
if matches!(&event, behavior::Event::Creation(behavior::creation::Event::Open(layer)) if *layer != self.layer)
|
||||
{
|
||||
self.status =
|
||||
"The active layer changed. Choose New in the current layer.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let creating = matches!(
|
||||
&event,
|
||||
behavior::Event::Creation(behavior::creation::Event::Open(..))
|
||||
);
|
||||
let focus_name = matches!(
|
||||
&event,
|
||||
behavior::Event::Creation(behavior::creation::Event::Choose(..))
|
||||
);
|
||||
if creating
|
||||
&& (self.behavior.has_pending() || self.source_dirty() || self.draft_dirty())
|
||||
{
|
||||
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
|
||||
&& (self.source_dirty() || self.draft_dirty())
|
||||
{
|
||||
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let source_was_open = self.behavior.source_open();
|
||||
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
|
||||
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -2303,7 +2575,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Ok(None) => {
|
||||
if !source_was_open && self.behavior.source_open() {
|
||||
return iced::widget::operation::focus(
|
||||
behavior::source_draft::NAME_INPUT,
|
||||
);
|
||||
}
|
||||
if focus_name {
|
||||
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
@@ -2495,13 +2776,19 @@ impl App {
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
if redo {
|
||||
self.document.redo();
|
||||
let changed = if redo {
|
||||
self.document.redo()
|
||||
} else {
|
||||
self.document.undo();
|
||||
self.document.undo()
|
||||
};
|
||||
if changed {
|
||||
self.sync();
|
||||
}
|
||||
self.sync();
|
||||
self.status = if redo {
|
||||
self.status = if !changed && redo {
|
||||
"Nothing to redo"
|
||||
} else if !changed {
|
||||
"Nothing to undo"
|
||||
} else if redo {
|
||||
"Model change redone"
|
||||
} else {
|
||||
"Model change undone"
|
||||
|
||||
@@ -173,7 +173,12 @@ impl App {
|
||||
self.behavior = restored.behavior;
|
||||
self.workflow = restored.workflow;
|
||||
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
|
||||
if let Some(layer) = self.project().layer_of(focus) {
|
||||
if let Some(layer) = self
|
||||
.behavior
|
||||
.creation
|
||||
.pending_layer()
|
||||
.or_else(|| self.project().layer_of(focus))
|
||||
{
|
||||
self.layer = layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
|
||||
self.active_diagram = self.diagram_for(layer, focus);
|
||||
@@ -209,6 +214,16 @@ impl App {
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
|
||||
])
|
||||
} else {
|
||||
lint
|
||||
}
|
||||
|
||||
@@ -15,18 +15,15 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
|
||||
if let Some(context) = self.topology_context() {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
if state.reveal_selection(self.project(), context.owner, self.selected) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.behavior.topology = state;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
@@ -52,6 +49,47 @@ impl App {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Camera(_, lifetime, input) = event {
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
if lifetime != self.camera_epoch
|
||||
|| !self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| !canvas_workspace::keyboard_enabled(self)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
|
||||
input => {
|
||||
if self.behavior.topology.camera(input) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -81,6 +119,7 @@ impl App {
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
@@ -118,14 +157,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before choosing a process tool.".into();
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.zoom(zoom);
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
state.camera_zoom(self.project(), context.owner, zoom);
|
||||
self.behavior.topology = state;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
@@ -149,7 +192,9 @@ impl App {
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior
|
||||
.topology
|
||||
.remember(context.owner, occurrence, target);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! One transient chooser for the currently mounted Inspector or review field.
|
||||
//! Browsing never calls a draft setter or semantic planner.
|
||||
use super::{App, Message, Page, Pending};
|
||||
use crate::{
|
||||
forms::{
|
||||
Draft,
|
||||
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
|
||||
},
|
||||
navigation::InspectorTab,
|
||||
semantic_review::{self, RowAddress, RowEvent},
|
||||
workflow,
|
||||
};
|
||||
use iced::{Element, Rectangle, Task};
|
||||
use oa_core::{FacetSelector, Id, Layer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Host {
|
||||
Inspector,
|
||||
Review {
|
||||
address: Option<RowAddress>,
|
||||
facet: FacetSelector,
|
||||
},
|
||||
}
|
||||
impl Host {
|
||||
fn message(&self, event: chooser::Event) -> Message {
|
||||
match self {
|
||||
Self::Inspector => Message::FieldReferenceEvent(event),
|
||||
Self::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceEvent(event),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Opened {
|
||||
host: Host,
|
||||
request: adapter::Open,
|
||||
document_session: Id,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
chooser: chooser::State,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
opened: Option<Opened>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn reference_input_active(&self) -> bool {
|
||||
self.reference_authoring.opened.is_some()
|
||||
}
|
||||
|
||||
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
|
||||
match host {
|
||||
Host::Inspector
|
||||
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
|
||||
{
|
||||
self.draft.as_ref()
|
||||
}
|
||||
Host::Review { address, facet } => match self.workflow.as_ref()? {
|
||||
workflow::Workflow::Semantic(state) => {
|
||||
state.reference_draft(self.project(), *address, facet, path)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn reference_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.pending.is_none()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
|
||||
}
|
||||
fn reference_valid(&self, opened: &Opened) -> bool {
|
||||
self.reference_available()
|
||||
&& self.canvas_document == opened.document_session
|
||||
&& self.page == opened.page
|
||||
&& self.layer == opened.layer
|
||||
&& self.document.revision() == opened.chooser.stamp().context.revision
|
||||
&& self.project().id == opened.request.document
|
||||
&& self
|
||||
.reference_draft(&opened.host, &opened.request.path)
|
||||
.is_some_and(|draft| draft.reference_matches(&opened.request))
|
||||
}
|
||||
pub(super) fn reconcile_reference_chooser(&mut self) {
|
||||
if self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.is_some_and(|opened| !self.reference_valid(opened))
|
||||
{
|
||||
self.reference_authoring
|
||||
.opened
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.invalidate();
|
||||
self.reference_authoring.opened = None;
|
||||
}
|
||||
}
|
||||
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
|
||||
if !self.reference_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(draft) = self.reference_draft(&host, &request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(chooser) =
|
||||
draft.reference_catalog(self.project(), &request, self.document.revision())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.reference_authoring.opened = Some(Opened {
|
||||
host,
|
||||
request,
|
||||
document_session: self.canvas_document,
|
||||
page: self.page,
|
||||
layer: self.layer,
|
||||
chooser,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
|
||||
let Some(mut opened) = self.reference_authoring.opened.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.reference_valid(&opened) {
|
||||
return Task::none();
|
||||
}
|
||||
if opened.host != host {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
return Task::none();
|
||||
}
|
||||
match opened.chooser.update(event) {
|
||||
Outcome::Ignored | Outcome::Browsed => {
|
||||
self.reference_authoring.opened = Some(opened);
|
||||
Task::none()
|
||||
}
|
||||
Outcome::Closed(reason) => self.reference_focus(
|
||||
&opened,
|
||||
match reason {
|
||||
Close::Escape => Some(0),
|
||||
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
|
||||
Close::Outside | Close::WindowChanged => None,
|
||||
},
|
||||
),
|
||||
Outcome::Write { context, value } => {
|
||||
let intent = adapter::Set {
|
||||
context,
|
||||
generation: opened.request.generation,
|
||||
value,
|
||||
};
|
||||
let result = match &opened.host {
|
||||
Host::Inspector => self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or_else(|| "The property editor closed.".to_owned())
|
||||
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
|
||||
Host::Review { address, facet } => match self.workflow.as_mut() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
state.set_reference(self.document.project(), *address, facet, intent)
|
||||
}
|
||||
_ => Err("The review editor closed.".into()),
|
||||
},
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
// A scoped write rotates field generation. The task targets the
|
||||
// newly rendered field, while a later structural edit makes it inert.
|
||||
self.reference_focus(&opened, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
|
||||
let Some(step) = step else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
|
||||
return Task::none();
|
||||
};
|
||||
if draft.reference_session() != opened.request.session {
|
||||
return Task::none();
|
||||
}
|
||||
trigger::focus(
|
||||
draft.reference_session(),
|
||||
draft.reference_generation(),
|
||||
&opened.request.path,
|
||||
step,
|
||||
)
|
||||
}
|
||||
/// Called before generic modal dispatch: the review owns a modal, and this
|
||||
/// chooser owns only its active field. All browser events stop here.
|
||||
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
|
||||
self.reconcile_reference_chooser();
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Some(self.open_reference(Host::Inspector, request.clone()))
|
||||
}
|
||||
Message::FieldReferenceEvent(event) => {
|
||||
Some(self.reference_event(Host::Inspector, event.clone()))
|
||||
}
|
||||
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event,
|
||||
})) => {
|
||||
let host = Host::Review {
|
||||
address: *address,
|
||||
facet: facet.clone(),
|
||||
};
|
||||
match event {
|
||||
RowEvent::ReferenceOpen(request) => {
|
||||
Some(self.open_reference(host, request.clone()))
|
||||
}
|
||||
RowEvent::ReferenceEvent(event) => {
|
||||
Some(self.reference_event(host, event.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
let opened = self.reference_authoring.opened.as_ref().unwrap();
|
||||
let host = opened.host.clone();
|
||||
let event = opened.chooser.event(Action::Close(Close::Escape));
|
||||
Some(self.reference_event(host, event))
|
||||
}
|
||||
// Global shortcuts are also emitted for keys captured by the search
|
||||
// input. They must not apply drafts, navigate, or undo the model.
|
||||
Message::Save(_)
|
||||
| Message::ShortcutUndo(_)
|
||||
| Message::Request(Pending::Open | Pending::New)
|
||||
| Message::SourceNavigation(_)
|
||||
| Message::Page(Page::Guide)
|
||||
if self.reference_authoring.opened.is_some() =>
|
||||
{
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn reference_overlay<
|
||||
'a,
|
||||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||||
>(
|
||||
&'a self,
|
||||
content: Element<'a, Message, iced::Theme, R>,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let opened = self
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.filter(|opened| self.reference_valid(opened));
|
||||
let host = opened
|
||||
.map(|opened| opened.host.clone())
|
||||
.unwrap_or(Host::Inspector);
|
||||
let focus_scope = if self.reference_available() {
|
||||
match self.workflow.as_ref() {
|
||||
None if self.inspector_tab == InspectorTab::Properties => {
|
||||
self.draft.as_ref().map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: false,
|
||||
})
|
||||
}
|
||||
Some(workflow::Workflow::Semantic(state)) => state
|
||||
.reference_form(self.project())
|
||||
.map(|draft| trigger::FormScope {
|
||||
session: draft.reference_session(),
|
||||
modal: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
chooser::widget::Popup::new(
|
||||
content,
|
||||
opened.map(|opened| &opened.chooser),
|
||||
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
|
||||
move |event| host.message(event),
|
||||
)
|
||||
.focus_scope(focus_scope)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,550 @@
|
||||
use super::*;
|
||||
use crate::forms::Draft;
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const OWNER: Id = Id::from_u128(100);
|
||||
const BASE: u128 = 10_000;
|
||||
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
||||
|
||||
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
|
||||
let generated = p.add_element(kind, layer, name, parent).unwrap();
|
||||
let mut element = p.elements.remove(&generated).unwrap();
|
||||
element.id = id;
|
||||
element.alias = format!("object_{}", id.as_u128());
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.elements.insert(id, element);
|
||||
}
|
||||
fn catalog() -> Project {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
p.name = "Reference catalog".into();
|
||||
element(
|
||||
&mut p,
|
||||
OWNER,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Edited record",
|
||||
None,
|
||||
);
|
||||
for n in (0..1_000).rev() {
|
||||
let id = Id::from_u128(BASE + n);
|
||||
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
|
||||
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(200),
|
||||
Kind::DataType,
|
||||
Layer::Sa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(201),
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Controller",
|
||||
None,
|
||||
);
|
||||
p
|
||||
}
|
||||
fn inspector(p: Project, selected: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(selected));
|
||||
app
|
||||
}
|
||||
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
|
||||
app.reference_draft(host, path).unwrap().reference_open(
|
||||
app.project(),
|
||||
path,
|
||||
Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn open(app: &mut App, host: &Host, path: &str) {
|
||||
let request = request(app, host, path);
|
||||
let message = match host {
|
||||
Host::Inspector => Message::FieldReferenceOpen(request),
|
||||
Host::Review { address, facet } => {
|
||||
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
|
||||
address: *address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceOpen(request),
|
||||
}))
|
||||
}
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert!(
|
||||
app.reference_authoring.opened.is_some(),
|
||||
"field must open: {}",
|
||||
app.status
|
||||
);
|
||||
}
|
||||
fn event(app: &App, action: Action) -> Message {
|
||||
let opened = app.reference_authoring.opened.as_ref().unwrap();
|
||||
opened.host.message(opened.chooser.event(action))
|
||||
}
|
||||
fn search(app: &mut App, query: &str) {
|
||||
let serial = app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.query_serial()
|
||||
+ 1;
|
||||
let event = event(
|
||||
app,
|
||||
Action::Query {
|
||||
text: query.into(),
|
||||
serial,
|
||||
},
|
||||
);
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let event = event(app, Action::Choose(id));
|
||||
let _ = app.update(event);
|
||||
}
|
||||
fn raw(app: &App) -> Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn review(app: &App) -> &semantic_review::State {
|
||||
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
|
||||
panic!("Review is open")
|
||||
};
|
||||
state
|
||||
}
|
||||
fn send_review(app: &mut App, event: RowEvent) {
|
||||
let addressed = review(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(workflow::Event::Semantic(addressed)));
|
||||
}
|
||||
fn review_host(app: &App) -> Host {
|
||||
let semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
..
|
||||
} = review(app).address_event(RowEvent::Preview)
|
||||
else {
|
||||
panic!("Active facet")
|
||||
};
|
||||
Host::Review { address, facet }
|
||||
}
|
||||
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.as_u128()),
|
||||
name: "Rationale".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
|
||||
let p = catalog();
|
||||
let mut app = inspector(p.clone(), OWNER);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
// Malformed escaped text is unrelated to the selected reference. Browsing
|
||||
// and the scoped write must keep its exact spelling and inactive payload.
|
||||
app.draft.as_mut().unwrap().set_exact(true).unwrap();
|
||||
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
|
||||
let raw_before = raw(&app);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1_000
|
||||
);
|
||||
search(&mut app, "controller @controller_0720 OA");
|
||||
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
|
||||
assert_eq!(picker.count(), 1);
|
||||
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
// Captured search shortcuts cannot accidentally save/apply or undo a model.
|
||||
let _ = app.update(Message::Save(false));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(raw(&app), raw_before);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
choose(&mut app, Id::from_u128(BASE + 720));
|
||||
let mut expected_raw = raw_before;
|
||||
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
|
||||
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
|
||||
assert_eq!(raw(&app), expected_raw);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Apply);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"Unrelated invalid text remains an Apply error"
|
||||
);
|
||||
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
|
||||
let _ = app.update(Message::Apply);
|
||||
let mut expected = p;
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record {
|
||||
supertype: Some(Id::from_u128(BASE + 720)),
|
||||
};
|
||||
}
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_ne!(accepted, before);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
|
||||
for (raw_id, expected) in [
|
||||
("-".into(), "invalid"),
|
||||
(Id::from_u128(999).to_string(), "missing"),
|
||||
(Id::from_u128(200).to_string(), "incompatible"),
|
||||
] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(raw_id))
|
||||
.unwrap();
|
||||
let original = raw(&app);
|
||||
let accepted = app.document.bundle().clone();
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let current = &app
|
||||
.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.current;
|
||||
assert!(matches!(
|
||||
(expected, current),
|
||||
("invalid", chooser::Current::InvalidRaw(_))
|
||||
| ("missing", chooser::Current::Missing(_))
|
||||
| ("incompatible", chooser::Current::Incompatible(_))
|
||||
));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert_eq!(raw(&app), original);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let clear = event(&app, Action::Clear);
|
||||
let _ = app.update(clear);
|
||||
assert!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(PATH)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
|
||||
for change in 0..7 {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
open(&mut app, &Host::Inspector, PATH);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
|
||||
match change {
|
||||
0 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
|
||||
.unwrap();
|
||||
}
|
||||
1 => {
|
||||
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
|
||||
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
|
||||
}
|
||||
2 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
3 => {
|
||||
app.hosted.visible = true;
|
||||
}
|
||||
4 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_context(Some(json!({"different":"scope"})));
|
||||
}
|
||||
5 => {
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
Id::from_u128(BASE + 3),
|
||||
"Changed model".into(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app.source_workspace
|
||||
.edit("unfinished source".into(), Default::default());
|
||||
}
|
||||
}
|
||||
let expected = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(delayed);
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let app = inspector(p, Id::from_u128(300));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let request = draft.reference_open(
|
||||
app.project(),
|
||||
"/baseline/semantic/facets/0/references/0/target",
|
||||
Rectangle::default(),
|
||||
);
|
||||
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
|
||||
let mut p = Project::empty();
|
||||
for n in 0..3 {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(BASE + n),
|
||||
Kind::ExchangeItem,
|
||||
Layer::Oa,
|
||||
"Signal item",
|
||||
None,
|
||||
);
|
||||
}
|
||||
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
|
||||
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
|
||||
},
|
||||
};
|
||||
let mut app = inspector(p, OWNER);
|
||||
let list = "/body/Oa/Shared/Event/body/Signal/items";
|
||||
let path = format!("{list}/0");
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
|
||||
let expected = raw(&app);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(list),
|
||||
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
|
||||
);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
open(&mut app, &Host::Inspector, &path);
|
||||
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
|
||||
let _ = app.update(Message::FieldVariant(
|
||||
"/body/Oa/Shared/Event/body".into(),
|
||||
json!({"Change":{"expression":"unfinished"}}),
|
||||
));
|
||||
let expected = raw(&app);
|
||||
let _ = app.update(delayed);
|
||||
assert_eq!(raw(&app), expected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
|
||||
{
|
||||
for mode in 0..3 {
|
||||
let mut p = Project::empty();
|
||||
p.id = Id::from_u128(1);
|
||||
for (id, layer, parent) in [
|
||||
(100, Layer::Oa, None),
|
||||
(101, Layer::Sa, None),
|
||||
(102, Layer::Sa, None),
|
||||
(103, Layer::Oa, Some(Id::from_u128(100))),
|
||||
(104, Layer::Sa, Some(Id::from_u128(101))),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
if parent.is_some() {
|
||||
Kind::DataField
|
||||
} else {
|
||||
Kind::DataType
|
||||
},
|
||||
layer,
|
||||
"Record",
|
||||
parent,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
let facet = match mode {
|
||||
0 => FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
1 => FacetSelector::Data(DataFacet::Constructor),
|
||||
_ => FacetSelector::Child(KeyPair {
|
||||
oa: Some(Id::from_u128(103)),
|
||||
sa: Some(Id::from_u128(104)),
|
||||
}),
|
||||
};
|
||||
let path = if mode == 2 {
|
||||
"/parent"
|
||||
} else {
|
||||
"/body/Sa/Shared/DataType/definition/Record/supertype"
|
||||
};
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let record = if mode == 2 {
|
||||
Id::from_u128(104)
|
||||
} else {
|
||||
Id::from_u128(101)
|
||||
};
|
||||
assert_eq!(app.reference_draft(&host, path).unwrap().original, record);
|
||||
let before = review(&app).recovery_snapshot().unwrap();
|
||||
let plan = serde_json::to_value(&review(&app).plan).unwrap();
|
||||
let proposed = review(&app).proposed.clone();
|
||||
let source = app.document.bundle().clone();
|
||||
open(&mut app, &host, path);
|
||||
search(&mut app, "@object_102 SA");
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
|
||||
assert_eq!(review(&app).proposed, proposed);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let old = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
|
||||
);
|
||||
let switched = review(&app).recovery_snapshot().unwrap();
|
||||
let _ = app.update(old);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
|
||||
send_review(&mut app, RowEvent::Select(facet));
|
||||
open(&mut app, &host, path);
|
||||
choose(&mut app, Id::from_u128(102));
|
||||
let draft = app.reference_draft(&host, path).unwrap();
|
||||
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
|
||||
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
|
||||
assert_eq!(draft.materialize().unwrap(), expected_record);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"A choice only stages the active review candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod mounted;
|
||||
|
||||
#[test]
|
||||
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
|
||||
let mut p = Project::empty();
|
||||
for (id, layer) in [
|
||||
(100, Layer::Oa),
|
||||
(101, Layer::Sa),
|
||||
(102, Layer::Sa),
|
||||
(110, Layer::Oa),
|
||||
(111, Layer::Sa),
|
||||
] {
|
||||
element(
|
||||
&mut p,
|
||||
Id::from_u128(id),
|
||||
Kind::DataType,
|
||||
layer,
|
||||
"Record",
|
||||
None,
|
||||
);
|
||||
}
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(300),
|
||||
Id::from_u128(100),
|
||||
Id::from_u128(101),
|
||||
);
|
||||
trace(
|
||||
&mut p,
|
||||
Id::from_u128(310),
|
||||
Id::from_u128(110),
|
||||
Id::from_u128(111),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let host = review_host(&app);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
|
||||
let old_open = request(&app, &host, path);
|
||||
open(&mut app, &host, path);
|
||||
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
|
||||
for event in [
|
||||
semantic_review::Event::ChooseTrace(crate::app::Choice {
|
||||
id: Id::from_u128(310),
|
||||
label: "Second exact trace".into(),
|
||||
}),
|
||||
semantic_review::Event::OpenTrace,
|
||||
] {
|
||||
let _ = app.update(Message::Workflow(workflow::Event::Semantic(event)));
|
||||
}
|
||||
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
|
||||
let expected = review(&app).recovery_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let _ = app.update(old_choice);
|
||||
let Host::Review { address, facet } = host else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::Workflow(workflow::Event::Semantic(
|
||||
semantic_review::Event::Row {
|
||||
address,
|
||||
facet: Some(facet),
|
||||
event: RowEvent::ReferenceOpen(old_open),
|
||||
},
|
||||
)));
|
||||
assert!(app.reference_authoring.opened.is_none());
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
//! Real field + app-root overlay interaction. The null text renderer lets the
|
||||
//! same retained widget tree exercise focus and geometry without a GPU/window.
|
||||
use super::*;
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{
|
||||
Event as Input, Point, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Layout, Shell, layout,
|
||||
widget::{Id as WidgetId, Operation, Tree, operation},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::{Space, column, container, opaque, scrollable, stack, text_input},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard;
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Fields {
|
||||
values: Vec<(WidgetId, Rectangle, bool)>,
|
||||
scrolls: Vec<(Rectangle, Vector)>,
|
||||
}
|
||||
impl Operation for Fields {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.values.push((id.clone(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&WidgetId>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((bounds, translation));
|
||||
}
|
||||
}
|
||||
impl Fields {
|
||||
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
|
||||
self.values
|
||||
.iter()
|
||||
.find(|(actual, _, _)| actual == id)
|
||||
.map(|(_, bounds, focused)| (*bounds, *focused))
|
||||
.expect("Mounted field identity")
|
||||
}
|
||||
}
|
||||
fn view(
|
||||
app: &App,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
) -> Element<'_, Message, Theme, ()> {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let form = column![
|
||||
text_input("Name", &draft.inputs["/name"].raw)
|
||||
.id("before-reference")
|
||||
.on_input(|raw| Message::Field("/name".into(), raw)),
|
||||
adapter::field(draft, app.project(), PATH),
|
||||
text_input("Description", &draft.inputs["/description"].raw)
|
||||
.id("after-reference")
|
||||
.on_input(|raw| Message::Field("/description".into(), raw)),
|
||||
]
|
||||
.spacing(9);
|
||||
let form = trigger::scope(form, draft.reference_session());
|
||||
let base: Element<'_, Message, Theme, ()> = if scrolled {
|
||||
container(
|
||||
scrollable(column![Space::new().height(500), form])
|
||||
.id("reference-scroll")
|
||||
.height(180),
|
||||
)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
container(form).width(300).height(iced::Fill).into()
|
||||
};
|
||||
let base: Element<'_, Message, Theme, ()> = if nested {
|
||||
scrollable(column![Space::new().height(400), base])
|
||||
.id("outer-reference-scroll")
|
||||
.height(180)
|
||||
.width(300)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let base = if show_review {
|
||||
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
|
||||
Some(workflow::Workflow::Semantic(state)) => {
|
||||
match state.reference_form(app.project()) {
|
||||
Some(draft) => {
|
||||
let mut content = column![];
|
||||
if let Some(raw) = &state.operation_omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", raw)
|
||||
.id("review-rationale")
|
||||
.on_input(|raw| {
|
||||
Message::Workflow(workflow::Event::Semantic(
|
||||
state.address_event(RowEvent::OperationRationale(raw)),
|
||||
))
|
||||
}),
|
||||
);
|
||||
}
|
||||
content = content.push(trigger::scope(
|
||||
adapter::field(draft, app.project(), "/parent").map(|message| {
|
||||
match message {
|
||||
Message::FieldReferenceOpen(request) => {
|
||||
Message::Workflow(workflow::Event::Semantic(
|
||||
state.address_event(RowEvent::ReferenceOpen(request)),
|
||||
))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}),
|
||||
draft.reference_session(),
|
||||
));
|
||||
opaque(container(trigger::modal(content)).width(300).height(180)).into()
|
||||
}
|
||||
None => Space::new().into(),
|
||||
}
|
||||
}
|
||||
_ => Space::new().into(),
|
||||
};
|
||||
stack![base, review].into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
app.reference_overlay(base)
|
||||
}
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
scrolled: bool,
|
||||
nested: bool,
|
||||
show_review: bool,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(360., 320.),
|
||||
scrolled: false,
|
||||
nested: false,
|
||||
show_review: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn frame(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Fields, Option<Rectangle>) {
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (input, point) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
input,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
input,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut Clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut fields = Fields::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut fields);
|
||||
(
|
||||
messages,
|
||||
fields,
|
||||
overlay_node.map(|node| node.children()[0].bounds()),
|
||||
)
|
||||
}
|
||||
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
|
||||
self.frame(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
async fn perform(&mut self, app: &App, task: Task<Message>) {
|
||||
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
|
||||
return;
|
||||
};
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Chooser only requests scoped widget focus")
|
||||
};
|
||||
let mut view = view(app, self.scrolled, self.nested, self.show_review);
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
loop {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), operation.as_mut());
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => operation = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
self.perform(app, task).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn key(key: keyboard::key::Named, shift: bool) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
},
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
|
||||
raw.chars()
|
||||
.map(|character| {
|
||||
let raw = character.to_string();
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(raw.clone().into()),
|
||||
modified_key: keyboard::Key::Character(raw.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(raw.into()),
|
||||
repeat: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("before-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Tab reaches the real reference trigger"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let popup = mount
|
||||
.frame(&app, &[])
|
||||
.2
|
||||
.expect("App root overlay is mounted");
|
||||
assert!(
|
||||
popup.x >= 0.
|
||||
&& popup.y >= 0.
|
||||
&& popup.x + popup.width <= 360.
|
||||
&& popup.y + popup.height <= 320.
|
||||
);
|
||||
let mut inputs = typing("@controller_0720");
|
||||
inputs.push((key(keyboard::key::Named::Escape, false), None));
|
||||
let messages = mount.frame(&app, &inputs).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&id).1,
|
||||
"Escape returns to its exact reference field"
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
|
||||
.0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let (_, fields, popup) = mount.frame(&app, &[]);
|
||||
assert!(popup.is_none());
|
||||
assert!(
|
||||
fields.field(&WidgetId::new(expected)).1,
|
||||
"Chooser Tab stays within the originating form"
|
||||
);
|
||||
}
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
|
||||
{
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut mount = Mount::default();
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("@controller_0720")).0;
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
app.reference_authoring
|
||||
.opened
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chooser
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(messages.len(), 1);
|
||||
let delayed_focus = app.update(messages.into_iter().next().unwrap());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value.pointer(PATH),
|
||||
Some(&json!(Id::from_u128(BASE + 720)))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
// A structural edit occurs after Choose but before its asynchronous focus.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.shape_scoped(PATH, Value::Null)
|
||||
.unwrap();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
mount.perform(&app, delayed_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"A missing old field target makes no focus changes"
|
||||
);
|
||||
assert!(
|
||||
!fields
|
||||
.field(&trigger::field_id(
|
||||
app.draft.as_ref().unwrap().reference_generation(),
|
||||
PATH
|
||||
))
|
||||
.1
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
..Default::default()
|
||||
};
|
||||
mount.frame(&app, &[]);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
|
||||
)
|
||||
.await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let content = fields.field(&id).0;
|
||||
let (viewport, offset) = fields.scrolls[0];
|
||||
assert!(offset.y > 400., "Fixture is genuinely scrolled");
|
||||
let expected = Rectangle {
|
||||
x: content.x - offset.x,
|
||||
y: content.y - offset.y,
|
||||
..content
|
||||
}
|
||||
.intersection(&viewport)
|
||||
.unwrap();
|
||||
let messages = mount.click(&app, expected.center());
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Scrolled field should emit one actual Open")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let close = app.update(Message::Escape);
|
||||
mount.perform(&app, close).await;
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
|
||||
panic!("Focused scrolled field opens without a pointer")
|
||||
};
|
||||
assert_eq!(request.anchor, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
|
||||
for nested in [false, true] {
|
||||
let mut app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
scrolled: true,
|
||||
nested,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"Keyboard reveal must not author any input"
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(fields.field(&WidgetId::new("before-reference")).1);
|
||||
assert!(
|
||||
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
|
||||
"Every enclosing viewport reveals fresh focus"
|
||||
);
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
assert!(
|
||||
fields.field(&id).0.y - offset >= 180.,
|
||||
"The next reference starts below the fold"
|
||||
);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
|
||||
.0;
|
||||
assert!(messages.is_empty());
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(focused);
|
||||
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
|
||||
let messages = mount
|
||||
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
|
||||
.0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"A newly revealed field opens through Enter"
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if nested {
|
||||
mount
|
||||
.perform(
|
||||
&app,
|
||||
iced::widget::operation::snap_to(
|
||||
"outer-reference-scroll",
|
||||
scrollable::RelativeOffset::START,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.scrolls
|
||||
.iter()
|
||||
.all(|(_, offset)| *offset == Vector::ZERO)
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
|
||||
let (bounds, focused) = fields.field(&id);
|
||||
assert!(
|
||||
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
|
||||
);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
|
||||
for backwards in [false, true] {
|
||||
let app = inspector(catalog(), OWNER);
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount::default();
|
||||
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let forward = [
|
||||
WidgetId::new("before-reference"),
|
||||
reference.clone(),
|
||||
WidgetId::new("after-reference"),
|
||||
];
|
||||
let reverse = [
|
||||
WidgetId::new("after-reference"),
|
||||
reference,
|
||||
WidgetId::new("before-reference"),
|
||||
];
|
||||
let expected = if backwards { &reverse } else { &forward };
|
||||
for n in 0..7 {
|
||||
let (messages, fields, popup) =
|
||||
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(fields.field(&expected[n % 3]).1);
|
||||
assert_eq!(
|
||||
fields
|
||||
.values
|
||||
.iter()
|
||||
.filter(|(_, _, focused)| *focused)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(raw(&app), original);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let source = app.document.bundle().clone();
|
||||
let original = raw(&app);
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
|
||||
let session = app.draft.as_ref().unwrap().reference_session();
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let return_focus = app.update(Message::Escape);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().reference_session(),
|
||||
session,
|
||||
"Old Inspector really remains mounted"
|
||||
);
|
||||
assert_eq!(
|
||||
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
|
||||
id
|
||||
);
|
||||
let review_before = review(&app).recovery_snapshot().unwrap();
|
||||
let review_field = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
mount.perform(&app, return_focus).await;
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
!fields.field(&id).1,
|
||||
"A live old field underneath a modal cannot receive the stale focus task"
|
||||
);
|
||||
assert!(fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert!(!fields.field(&review_field).1);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(
|
||||
fields.field(&review_field).1,
|
||||
"Review entry ignores retained focus under its modal"
|
||||
);
|
||||
assert!(
|
||||
!fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Modal focus acquisition deactivates background inputs"
|
||||
);
|
||||
let messages = mount.frame(&app, &typing("x")).0;
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"A character at the focused Review trigger cannot type into the Inspector below it"
|
||||
);
|
||||
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
|
||||
assert!(messages.is_empty());
|
||||
assert!(fields.field(&review_field).1);
|
||||
assert!(!fields.field(&id).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
|
||||
let mut p = catalog();
|
||||
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
|
||||
let mut app = inspector(p, OWNER);
|
||||
let original = raw(&app);
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("after-reference")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let saved = review(&app).recovery_snapshot().unwrap();
|
||||
let id = trigger::field_id(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(
|
||||
fields.field(&WidgetId::new("after-reference")).1,
|
||||
"Fixture retains Inspector focus before the modal receives input"
|
||||
);
|
||||
let messages = mount.click(&app, fields.field(&id).0.center());
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert!(matches!(
|
||||
&app.reference_authoring.opened.as_ref().unwrap().host,
|
||||
Host::Review { .. }
|
||||
));
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
let messages = mount.click(&app, Point::new(359., 319.));
|
||||
assert!(matches!(
|
||||
messages.as_slice(),
|
||||
[Message::Workflow(workflow::Event::Semantic(
|
||||
semantic_review::Event::Row {
|
||||
event: RowEvent::ReferenceEvent(chooser::Event {
|
||||
action: Action::Close(Close::Outside),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
))]
|
||||
));
|
||||
for message in messages {
|
||||
let task = app.update(message);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(task).is_none(),
|
||||
"Outside dismissal must not request focus or apply"
|
||||
);
|
||||
}
|
||||
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
|
||||
assert!(messages.is_empty() && popup.is_none());
|
||||
assert!(!fields.field(&WidgetId::new("after-reference")).1);
|
||||
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(raw(&app), original);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
|
||||
{
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|element| {
|
||||
element.layer() == Layer::Sa
|
||||
&& matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
.id;
|
||||
let inspector_id = Id::new_v4();
|
||||
element(
|
||||
&mut p,
|
||||
inspector_id,
|
||||
Kind::DataType,
|
||||
Layer::Oa,
|
||||
"Inspector record",
|
||||
None,
|
||||
);
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
|
||||
.unwrap();
|
||||
let oa = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Operational message",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(oa);
|
||||
let trace_id = Id::new_v4();
|
||||
trace(&mut p, trace_id, oa, sa);
|
||||
let mut app = inspector(p, inspector_id);
|
||||
let source = app.document.bundle().clone();
|
||||
let inspector_raw = raw(&app);
|
||||
let _ = app.update(Message::StartSemanticReview(trace_id));
|
||||
let _ = app.update(Message::Workflow(workflow::Event::Semantic(
|
||||
semantic_review::Event::Direction(TransitionDirection::SaToOa),
|
||||
)));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Parent);
|
||||
send_review(&mut app, RowEvent::Select(facet.clone()));
|
||||
send_review(
|
||||
&mut app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send_review(&mut app, RowEvent::OperationOmission(true));
|
||||
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
|
||||
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
|
||||
let candidate = serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.recovery_snapshot(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mount = Mount {
|
||||
show_review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let fields = mount.frame(&app, &[]).1;
|
||||
let messages = mount.click(
|
||||
&app,
|
||||
fields.field(&WidgetId::new("review-rationale")).0.center(),
|
||||
);
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
let messages = mount.frame(&app, &typing("Engineering reason")).0;
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
"Engineering reason".chars().count(),
|
||||
"Every key reaches the legitimate sibling inside Review"
|
||||
);
|
||||
assert!(messages.iter().all(|message| matches!(
|
||||
message,
|
||||
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
|
||||
event: RowEvent::OperationRationale(_),
|
||||
..
|
||||
}))
|
||||
)));
|
||||
mount.dispatch(&mut app, messages).await;
|
||||
assert_eq!(
|
||||
review(&app).operation_omission.as_deref(),
|
||||
Some("Engineering reason")
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.frame(&app, &[])
|
||||
.1
|
||||
.field(&WidgetId::new("review-rationale"))
|
||||
.1
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
review(&app)
|
||||
.reference_form(app.project())
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
)
|
||||
.unwrap(),
|
||||
candidate
|
||||
);
|
||||
assert_eq!(raw(&app), inspector_raw);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -14,10 +14,16 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
pub(crate) mod canvas_workspace;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
// Projection acceptance precedes the coordinated scenario renderer integration.
|
||||
#[cfg(test)]
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
pub(crate) mod state_edit;
|
||||
pub(crate) mod state_menu;
|
||||
@@ -35,6 +41,7 @@ mod scenario_scroll {
|
||||
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
|
||||
context: scenario::Context,
|
||||
area: scenario::ScrollArea,
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
@@ -42,28 +49,10 @@ mod scenario_scroll {
|
||||
content: content.into(),
|
||||
context,
|
||||
area,
|
||||
process: false,
|
||||
process,
|
||||
restore,
|
||||
})
|
||||
}
|
||||
pub(super) fn observe_topology<'a>(
|
||||
context: topology::Context,
|
||||
area: scenario::ScrollArea,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Observer {
|
||||
content: content.into(),
|
||||
context: scenario::Context {
|
||||
document: context.document,
|
||||
scenario: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: context.epoch,
|
||||
},
|
||||
area,
|
||||
process: true,
|
||||
restore: None,
|
||||
})
|
||||
}
|
||||
struct Observer<'a, R = Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
context: scenario::Context,
|
||||
@@ -334,7 +323,7 @@ mod scenario_scroll {
|
||||
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
|
||||
Observer {
|
||||
context,
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
area: scenario::ScrollArea::Page,
|
||||
process: false,
|
||||
restore: None,
|
||||
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
||||
@@ -404,7 +393,7 @@ mod scenario_scroll {
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
for area in [ScrollArea::Canvas, ScrollArea::Page] {
|
||||
for area in [ScrollArea::Page] {
|
||||
for returned_height in [600., 250.] {
|
||||
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
@@ -734,16 +723,41 @@ mod scenario_scroll {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Creation(creation::Event),
|
||||
Topology(topology::Event),
|
||||
Scenario(scenario::Event),
|
||||
States(states::Event),
|
||||
Submit,
|
||||
}
|
||||
impl Event {
|
||||
pub fn is_source_back(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
)))
|
||||
)
|
||||
}
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(_))
|
||||
| Self::Scenario(scenario::Event::Source(_))
|
||||
| Self::Topology(topology::Event::Source(_))
|
||||
)
|
||||
}
|
||||
pub fn starts_form(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Topology(topology::Event::Start(..))
|
||||
Self::Creation(creation::Event::Open(..))
|
||||
| Self::Topology(topology::Event::Start(..))
|
||||
| Self::Scenario(scenario::Event::Start(..))
|
||||
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
|
||||
)
|
||||
@@ -752,6 +766,7 @@ impl Event {
|
||||
matches!(
|
||||
self,
|
||||
Self::Submit
|
||||
| Self::Creation(creation::Event::Submit)
|
||||
| Self::Topology(topology::Event::Submit)
|
||||
| Self::Scenario(scenario::Event::Submit)
|
||||
| Self::States(states::Event::Submit)
|
||||
@@ -760,6 +775,7 @@ impl Event {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub(crate) creation: creation::State,
|
||||
pub active_owner: Option<Id>,
|
||||
pub diagram: state_view::State,
|
||||
pub(crate) topology: topology::State,
|
||||
@@ -776,6 +792,7 @@ struct RecoveryDraft {
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
enum RecoveryKind {
|
||||
Creation,
|
||||
State,
|
||||
Scenario,
|
||||
Process,
|
||||
@@ -785,6 +802,7 @@ impl State {
|
||||
/// viewport geometry and document/revision tokens never cross a restart.
|
||||
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
let drafts = [
|
||||
(RecoveryKind::Creation, self.creation.recovery_snapshot()?),
|
||||
(RecoveryKind::State, self.states.recovery_snapshot()?),
|
||||
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
|
||||
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
|
||||
@@ -820,6 +838,9 @@ impl State {
|
||||
}
|
||||
let mut restored = Self::default();
|
||||
match saved.kind {
|
||||
RecoveryKind::Creation => {
|
||||
restored.creation = creation::State::from_recovery(saved.draft)?
|
||||
}
|
||||
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
|
||||
RecoveryKind::Scenario => {
|
||||
restored.scenario = scenario::State::from_recovery(saved.draft)?
|
||||
@@ -834,7 +855,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.pending_owner().is_some()
|
||||
self.creation.has_pending() || self.pending_owner().is_some()
|
||||
}
|
||||
|
||||
pub fn pending_owner(&self) -> Option<Id> {
|
||||
@@ -847,7 +868,9 @@ impl State {
|
||||
/// Prepare the active form without discarding inputs. The caller must execute
|
||||
/// this exact command and acknowledge only a successful document transaction.
|
||||
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
|
||||
if self.topology.pending_owner().is_some() {
|
||||
if self.creation.has_pending() {
|
||||
self.creation.prepare_pending(p).map(Some)
|
||||
} else if self.topology.pending_owner().is_some() {
|
||||
self.topology.prepare_pending(p).map(Some)
|
||||
} else if self.scenario.pending_owner().is_some() {
|
||||
self.scenario.prepare_pending(p).map(Some)
|
||||
@@ -860,7 +883,20 @@ impl State {
|
||||
|
||||
/// Clear the active form after its prepared command commits successfully.
|
||||
/// Rejected commands must leave the form available for repair and retry.
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.creation.source_active()
|
||||
|| self.scenario.source_active()
|
||||
|| self.topology.source_active()
|
||||
}
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
|
||||
}
|
||||
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.creation.acknowledge_commit();
|
||||
self.topology.acknowledge_commit();
|
||||
self.scenario.acknowledge_commit();
|
||||
self.states.acknowledge_commit();
|
||||
@@ -870,7 +906,8 @@ impl State {
|
||||
if self.has_pending()
|
||||
&& matches!(
|
||||
&event,
|
||||
Event::Topology(topology::Event::Start(..))
|
||||
Event::Creation(creation::Event::Open(..))
|
||||
| Event::Topology(topology::Event::Start(..))
|
||||
| Event::Scenario(scenario::Event::Start(..))
|
||||
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
|
||||
)
|
||||
@@ -888,6 +925,7 @@ impl State {
|
||||
return Ok(None);
|
||||
}
|
||||
match event {
|
||||
Event::Creation(event) => self.creation.update(p, event),
|
||||
Event::Topology(event) => self.topology.update(p, event),
|
||||
Event::Scenario(event) => self.scenario.update(p, event),
|
||||
Event::States(event) => self.states.update(p, event),
|
||||
@@ -901,6 +939,43 @@ impl State {
|
||||
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let p = app.project();
|
||||
if let Some(card) = app.behavior.creation.view(p) {
|
||||
return card.map(|event| Message::Behavior(Event::Creation(event)));
|
||||
}
|
||||
if let Some(owner) = app.behavior.pending_owner() {
|
||||
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
|
||||
let unavailable = p.layer_of(owner) != Some(app.layer)
|
||||
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|
||||
|| (app.behavior.topology.pending_owner().is_some()
|
||||
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
|
||||
if unavailable {
|
||||
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
|
||||
if let Some(name) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.pending_name()
|
||||
.or_else(|| app.behavior.topology.pending_name())
|
||||
{
|
||||
out =
|
||||
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
|
||||
}
|
||||
if let Some(child) = app.behavior.scenario.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
|
||||
} else if let Some(child) = app.behavior.topology.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
|
||||
} else {
|
||||
let cancel = if app.behavior.scenario.pending_owner().is_some() {
|
||||
Event::Scenario(scenario::Event::Cancel)
|
||||
} else if app.behavior.topology.pending_owner().is_some() {
|
||||
Event::Topology(topology::Event::Cancel)
|
||||
} else {
|
||||
Event::States(states::Event::Cancel)
|
||||
};
|
||||
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
|
||||
}
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -919,7 +994,16 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.cloned();
|
||||
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
|
||||
let Some(selected) = selected else {
|
||||
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
||||
return out
|
||||
.push(label(
|
||||
"Start a named process, scenario or state machine, then model its behavior here.",
|
||||
))
|
||||
.push(action(
|
||||
"New",
|
||||
Message::Behavior(Event::Creation(creation::Event::Open(app.layer))),
|
||||
))
|
||||
.padding(24)
|
||||
.into();
|
||||
};
|
||||
let e = &p.elements[&selected.id];
|
||||
let compact_canvas = matches!(
|
||||
@@ -933,10 +1017,17 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
out = column![].spacing(6);
|
||||
}
|
||||
out = out.push(
|
||||
pick_list(choices, Some(selected), Message::BehaviorOwner)
|
||||
.width(Fill)
|
||||
.text_size(13)
|
||||
.padding(if compact_canvas { 6 } else { 10 }),
|
||||
row![
|
||||
pick_list(choices, Some(selected), Message::BehaviorOwner)
|
||||
.width(Fill)
|
||||
.text_size(13)
|
||||
.padding(if compact_canvas { 6 } else { 10 }),
|
||||
action(
|
||||
"New",
|
||||
Message::Behavior(Event::Creation(creation::Event::Open(app.layer)))
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return topology::view(app, e.id, out);
|
||||
@@ -1051,6 +1142,7 @@ struct Scene {
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
scroll_offset: iced::Vector,
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1105,6 +1197,7 @@ impl Scene {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
@@ -1767,9 +1860,12 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let world = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
@@ -1796,7 +1892,7 @@ impl canvas::Program<Message> for Scene {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let CanvasTool::Add(tool) = tool {
|
||||
if !tool.canvas_occurrence() {
|
||||
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
|
||||
return None;
|
||||
}
|
||||
state.press = Some(SequencePress::Occurrence {
|
||||
@@ -1806,10 +1902,18 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
|
||||
if let Some(occurrence) = self
|
||||
.hit(point)
|
||||
.filter(|id| self.rows.contains_key(id))
|
||||
.or_else(|| {
|
||||
self.reorder_handle()
|
||||
.filter(|r| r.contains(point))
|
||||
.map(|_| self.selected)
|
||||
})
|
||||
{
|
||||
state.press = Some(SequencePress::Reorder {
|
||||
occurrence: self.selected,
|
||||
parent: self.rows[&self.selected].0,
|
||||
occurrence,
|
||||
parent: self.rows[&occurrence].0,
|
||||
start: cursor.position()? - self.scroll_offset,
|
||||
moved: false,
|
||||
});
|
||||
@@ -1884,6 +1988,18 @@ impl canvas::Program<Message> for Scene {
|
||||
);
|
||||
}
|
||||
}
|
||||
SequencePress::Reorder {
|
||||
occurrence,
|
||||
moved: false,
|
||||
..
|
||||
} => {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, occurrence,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
SequencePress::Reorder {
|
||||
occurrence,
|
||||
parent,
|
||||
@@ -1921,6 +2037,7 @@ impl canvas::Program<Message> for Scene {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.translate(self.camera_pan);
|
||||
f.scale(self.scale);
|
||||
for b in &self.boxes {
|
||||
let outline = matches!(
|
||||
@@ -2032,9 +2149,12 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let point = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let point = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let source = active.then_some(state.source).flatten();
|
||||
let reorder = active.then_some(state.press).flatten().and_then(|press| {
|
||||
if let SequencePress::Reorder { parent, .. } = press {
|
||||
@@ -2079,6 +2199,18 @@ impl canvas::Program<Message> for Scene {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if reorder.is_some()
|
||||
&& slot.is_none()
|
||||
&& let Some(point) = point
|
||||
{
|
||||
f.fill_text(Text {
|
||||
content: "Other operand: move stays in the current sequence".into(),
|
||||
position: Point::new(point.x + 8., point.y + 12.),
|
||||
size: (11. / self.scale).into(),
|
||||
color: Color::from_rgb(0.75, 0.2, 0.15),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(slot) = slot {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Shared presentation and input contract; contains no model commands.
|
||||
use crate::app::{App, Message};
|
||||
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
|
||||
use iced::{Element, Length::Fill, Point, Theme, Vector};
|
||||
use oa_core::Id;
|
||||
mod control;
|
||||
mod focus;
|
||||
mod viewport;
|
||||
pub use control::Control;
|
||||
pub use viewport::Viewport;
|
||||
|
||||
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
!app.busy
|
||||
&& !app.hosted.visible
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
&& app.pending.is_none()
|
||||
&& app.model_menu.is_none()
|
||||
&& !app.reference_input_active()
|
||||
}
|
||||
|
||||
pub fn controls<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
scale: f32,
|
||||
details: Message,
|
||||
focus: Option<Message>,
|
||||
zoom: impl Fn(Option<f32>) -> Message,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
|
||||
let message = message.map(|message| intent(scope, name, message));
|
||||
Control::new(
|
||||
button(text(label).size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
name,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
)
|
||||
};
|
||||
row![
|
||||
control("Details", "Details", Some(details)),
|
||||
tooltip(control("Canvas", "Canvas", focus),
|
||||
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
impl Scope {
|
||||
pub fn current(app: &App) -> Option<Self> {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
} else {
|
||||
let context = app.topology_context()?;
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub fn input_owner(app: &App) -> Option<Self> {
|
||||
if keyboard_enabled(app)
|
||||
&& !app.behavior.has_pending()
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
{
|
||||
Self::current(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Only a live presentation intent may carry a toolbar action across its own pan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub scope: Scope,
|
||||
pub name: &'static str,
|
||||
pub message: Box<Message>,
|
||||
}
|
||||
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
|
||||
Message::BehaviorControl(Intent {
|
||||
scope,
|
||||
name,
|
||||
message: Box::new(message),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Event {
|
||||
CancelGesture,
|
||||
Pan(Vector),
|
||||
Zoom { factor: f32, at: Point, base: f32 },
|
||||
Focus,
|
||||
Traverse(bool),
|
||||
ControlFocus(&'static str),
|
||||
ToggleFocus,
|
||||
Leave,
|
||||
}
|
||||
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
if next.x.is_finite() && next.y.is_finite() && next != *pan {
|
||||
*pan = next;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Event::Zoom { factor, at, base }
|
||||
if factor.is_finite()
|
||||
&& factor > 0.
|
||||
&& base.is_finite()
|
||||
&& base > 0.
|
||||
&& at.x.is_finite()
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
let anchor = Vector::new(at.x, at.y);
|
||||
let next_pan = anchor - (anchor - *pan) * (next / old);
|
||||
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
*pan = next_pan;
|
||||
*zoom = Some(next);
|
||||
*fit = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
|
||||
pub fn palette<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
|
||||
) -> Element<'a, Message> {
|
||||
let mut tools = column![].spacing(3);
|
||||
for (glyph, label, selected, message) in entries {
|
||||
let message = message.map(|message| intent(scope, glyph, message));
|
||||
tools = tools.push(tooltip(
|
||||
container(Control::new(
|
||||
button(text(glyph).size(12))
|
||||
.width(34)
|
||||
.height(30)
|
||||
.padding(3)
|
||||
.style(if selected {
|
||||
button::primary
|
||||
} else {
|
||||
crate::style::nav
|
||||
})
|
||||
.on_press_maybe(message.clone()),
|
||||
scope,
|
||||
glyph,
|
||||
keyboard,
|
||||
message,
|
||||
publish,
|
||||
))
|
||||
.id(format!("behavior-tool:{glyph}")),
|
||||
container(text(label).size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::card),
|
||||
tooltip::Position::Left,
|
||||
));
|
||||
}
|
||||
container(scrollable(tools).id(scope.control_id("palette-scroll")))
|
||||
.width(40)
|
||||
.height(Fill)
|
||||
.padding(3)
|
||||
.style(crate::style::panel)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Header and palette are permanent. Details/editors overlay the same viewport.
|
||||
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: Element<'a, Message, Theme, R>,
|
||||
palette: Element<'a, Message, Theme, R>,
|
||||
canvas: Element<'a, Message, Theme, R>,
|
||||
editor: Option<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
let mut body = stack![canvas];
|
||||
if let Some(editor) = editor {
|
||||
body = body.push(
|
||||
container(opaque(
|
||||
container(editor)
|
||||
.width(300)
|
||||
.height(Fill)
|
||||
.style(crate::style::card),
|
||||
))
|
||||
.align_right(Fill)
|
||||
.height(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
column![header, row![body, palette].height(Fill)]
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -0,0 +1,258 @@
|
||||
//! A real focusable keyboard target around the existing styled pointer button.
|
||||
use super::{Event as CameraEvent, Scope, focus::Member};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
identity: Option<iced::advanced::widget::Id>,
|
||||
focused: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
}
|
||||
}
|
||||
pub struct Control<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
name: &'static str,
|
||||
enabled: bool,
|
||||
activate: Option<Message>,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
name,
|
||||
enabled: enabled && activate.is_some(),
|
||||
activate,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
identity: Some(self.scope.control_id(self.name)),
|
||||
focused: false,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let identity = self.scope.control_id(self.name);
|
||||
if state.identity.as_ref() != Some(&identity) || !self.enabled {
|
||||
*state = Input {
|
||||
identity: Some(identity),
|
||||
focused: false,
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let id = self.scope.control_id(self.name);
|
||||
operation.container(Some(&id), layout.bounds());
|
||||
if self.enabled {
|
||||
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if self.enabled {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers,
|
||||
repeat,
|
||||
..
|
||||
}) = event
|
||||
&& state.focused
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
let action = match key.as_ref() {
|
||||
Key::Named(Named::Tab) => {
|
||||
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
|
||||
}
|
||||
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
|
||||
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
|
||||
Some((self.publish)(CameraEvent::ToggleFocus))
|
||||
}
|
||||
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
|
||||
self.activate.clone()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
shell.publish(action);
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) {
|
||||
if !layout
|
||||
.bounds()
|
||||
.intersection(viewport)
|
||||
.is_some_and(|bounds| cursor.is_over(bounds))
|
||||
{
|
||||
state.focused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.focused = false;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
if self.enabled && tree.state.downcast_ref::<Input>().focused {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::INK,
|
||||
width: 2.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(control: Control<'a, Message, R>) -> Self {
|
||||
Element::new(control)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
|
||||
use super::{Event, Scope};
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
|
||||
pub(super) struct Member(pub Scope);
|
||||
|
||||
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
scope,
|
||||
valid: false,
|
||||
event,
|
||||
members: vec![],
|
||||
focused: None,
|
||||
palette: None,
|
||||
})
|
||||
}
|
||||
struct Collect {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
event: Event,
|
||||
members: Vec<(Id, Rectangle)>,
|
||||
focused: Option<usize>,
|
||||
palette: Option<(Rectangle, Rectangle)>,
|
||||
}
|
||||
impl<T: Send + 'static> Operation<T> for Collect {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
if self.valid
|
||||
&& value
|
||||
.downcast_ref::<Member>()
|
||||
.is_some_and(|m| m.0 == self.scope)
|
||||
&& let Some(id) = id
|
||||
{
|
||||
self.members.push((id.clone(), bounds));
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
|
||||
if value.is_focused() {
|
||||
self.focused = self
|
||||
.members
|
||||
.iter()
|
||||
.position(|(member, _)| Some(member) == id)
|
||||
.or(self.focused);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.scope.control_id("palette-scroll")) {
|
||||
self.palette = Some((bounds, content));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<T> {
|
||||
let canvas = self.scope.control_id("viewport");
|
||||
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
|
||||
// owners expose no members; a delayed request cannot clear their focus.
|
||||
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let target = match self.event {
|
||||
Event::Focus => Some(canvas),
|
||||
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
|
||||
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
|
||||
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
|
||||
Event::Traverse(back) => {
|
||||
let Some(current) = self.focused else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let next = if back {
|
||||
(current + self.members.len() - 1) % self.members.len()
|
||||
} else {
|
||||
(current + 1) % self.members.len()
|
||||
};
|
||||
Some(self.members[next].0.clone())
|
||||
}
|
||||
_ => return operation::Outcome::None,
|
||||
};
|
||||
if target
|
||||
.as_ref()
|
||||
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
|
||||
{
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let reveal = target.as_ref().and_then(|target| {
|
||||
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
|
||||
let (bounds, content) = self.palette?;
|
||||
// Only the palette has member controls inside this scrollable.
|
||||
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
|
||||
(field.center_y() - bounds.y - bounds.height / 2.)
|
||||
.clamp(0., (content.height - bounds.height).max(0.))
|
||||
})
|
||||
});
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope,
|
||||
valid: false,
|
||||
target,
|
||||
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
|
||||
palette: self.scope.control_id("palette-scroll"),
|
||||
reveal,
|
||||
}))
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
scope: Scope,
|
||||
valid: bool,
|
||||
target: Option<Id>,
|
||||
members: Vec<Id>,
|
||||
palette: Id,
|
||||
reveal: Option<f32>,
|
||||
}
|
||||
impl<T> Operation<T> for Apply {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
|
||||
visit(self);
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = value.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.scope);
|
||||
}
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if !self.valid {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| Some(target) == id)
|
||||
{
|
||||
state.focus();
|
||||
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& id == Some(&self.palette)
|
||||
&& let Some(y) = self.reveal
|
||||
{
|
||||
state.scroll_to(operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(y),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every Collect/Apply pass observes current ownership before touching any field.
|
||||
struct Owner(Option<Scope>);
|
||||
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
owner: Option<Scope>,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Scope>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
owner,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &InputEvent,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::futures::{
|
||||
StreamExt,
|
||||
stream::{BoxStream, SelectAll},
|
||||
};
|
||||
use iced::{Rectangle, Size, mouse};
|
||||
use iced_runtime::{
|
||||
Action,
|
||||
user_interface::{Cache, UserInterface},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
|
||||
|
||||
// iced_winit drains messages while collecting immediately ready actions. A
|
||||
// pending Task stream survives until its widget operation sends its result.
|
||||
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
|
||||
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
loop {
|
||||
match stream.poll_next_unpin(&mut context) {
|
||||
std::task::Poll::Ready(Some(action)) => ready.push(action),
|
||||
std::task::Poll::Ready(None) => break,
|
||||
std::task::Poll::Pending => {
|
||||
pending.push(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Geometry {
|
||||
pub containers: HashMap<widget::Id, Rectangle>,
|
||||
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
|
||||
pub text_inputs: Vec<Rectangle>,
|
||||
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
self.focused.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
fn text_input(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: &mut dyn widget::operation::TextInput,
|
||||
) {
|
||||
self.text_inputs.push(bounds);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if let Some(id) = id {
|
||||
self.containers.insert(id.clone(), bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if let Some(id) = id {
|
||||
self.scrolls.insert(id.clone(), (bounds, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Geometry {
|
||||
pub fn bounds(&self, id: &str) -> Rectangle {
|
||||
self.containers[&widget::Id::from(id.to_owned())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the actual App root, production widgets and renderer. Events are
|
||||
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
|
||||
/// This deliberately does not seed any private canvas gesture state.
|
||||
pub(crate) struct Mounted {
|
||||
pub renderer: iced::Renderer,
|
||||
cache: Cache,
|
||||
pub operations: usize,
|
||||
}
|
||||
impl Mounted {
|
||||
pub async fn new(app: &mut App) -> Self {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
|
||||
.await
|
||||
.expect("production mounted tests require the supported WGPU headless renderer");
|
||||
let mut mounted = Self {
|
||||
renderer,
|
||||
cache: Cache::new(),
|
||||
operations: 0,
|
||||
};
|
||||
mounted.settle(app).await;
|
||||
mounted
|
||||
}
|
||||
pub fn geometry(&mut self, app: &App) -> Geometry {
|
||||
let mut geometry = Geometry::default();
|
||||
self.operate(app, &mut geometry);
|
||||
geometry
|
||||
}
|
||||
/// One operation pass, including every nested overlay, via installed Iced.
|
||||
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
ui.operate(&self.renderer, operation);
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
pub async fn settle(&mut self, app: &mut App) {
|
||||
for _ in 0..3 {
|
||||
self.batch(
|
||||
app,
|
||||
&[iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
pub async fn batch(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let messages = self.input(app, events, cursor);
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
for message in messages.iter().cloned() {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
self.drain(app, ready, pending).await;
|
||||
messages
|
||||
}
|
||||
pub fn input(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
let mut messages = vec![];
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
messages
|
||||
}
|
||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||
let mut ready = vec![];
|
||||
let mut pending = Pending::new();
|
||||
enqueue(task, &mut ready, &mut pending);
|
||||
self.drain(app, ready, pending).await;
|
||||
}
|
||||
async fn drain(
|
||||
&mut self,
|
||||
app: &mut App,
|
||||
mut ready: Vec<Action<Message>>,
|
||||
mut pending: Pending,
|
||||
) {
|
||||
loop {
|
||||
let mut outputs = vec![];
|
||||
if !ready.is_empty() {
|
||||
// Build only after all App messages in this cycle have run.
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
for action in ready.drain(..) {
|
||||
match action {
|
||||
Action::Output(message) => outputs.push(message),
|
||||
Action::Widget(mut operation) => {
|
||||
self.operations += 1;
|
||||
loop {
|
||||
ui.operate(&self.renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(next) => operation = next,
|
||||
widget::operation::Outcome::None
|
||||
| widget::operation::Outcome::Some(()) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
action => {
|
||||
panic!("Unexpected native action in mounted App harness: {action:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cache = ui.into_cache();
|
||||
}
|
||||
for message in outputs {
|
||||
enqueue(app.update(message), &mut ready, &mut pending);
|
||||
}
|
||||
if !ready.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(action) = pending.next().await else {
|
||||
break;
|
||||
};
|
||||
ready.push(action);
|
||||
let waker = iced::futures::task::noop_waker();
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
|
||||
ready.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
app.window_size,
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
// update initializes the nested overlay layout used by draw.
|
||||
let mut messages = vec![];
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
mouse::Cursor::Unavailable,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"An empty draw preparation must not drop App messages"
|
||||
);
|
||||
ui.draw(
|
||||
&mut self.renderer,
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: crate::style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
self.renderer.screenshot(
|
||||
Size::new(app.window_size.width as u32, app.window_size.height as u32),
|
||||
1.,
|
||||
crate::style::BG,
|
||||
)
|
||||
}
|
||||
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
|
||||
let mut operation = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::from(id.to_owned()),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
);
|
||||
self.operate(app, &mut operation);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard::{self, Key};
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(named),
|
||||
modified_key: Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Unidentified(
|
||||
keyboard::key::NativeCode::Unidentified,
|
||||
),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
|
||||
for scale in [0.05, 0.5, 1., 2.] {
|
||||
let mut pan = Vector::new(-130., 75.);
|
||||
let mut zoom = Some(scale);
|
||||
let mut fit = false;
|
||||
let at = Point::new(171., 93.);
|
||||
let world = (at - Point::ORIGIN - pan) / scale;
|
||||
change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fit,
|
||||
Event::Zoom {
|
||||
factor: 1.25,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
);
|
||||
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
|
||||
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
|
||||
let before = (pan, zoom, fit);
|
||||
for input in [
|
||||
Event::Pan(Vector::new(f32::INFINITY, 0.)),
|
||||
Event::Zoom {
|
||||
factor: f32::NAN,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
Event::Zoom {
|
||||
factor: 0.,
|
||||
at,
|
||||
base: scale,
|
||||
},
|
||||
] {
|
||||
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
|
||||
assert_eq!((pan, zoom, fit), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
epoch: 0,
|
||||
};
|
||||
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
|
||||
Space::new().width(Fill).height(Fill),
|
||||
scope,
|
||||
1.,
|
||||
true,
|
||||
|e| e,
|
||||
)
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
|
||||
)
|
||||
.move_to(Point::new(30., 80.));
|
||||
let start = Point::new(90., 160.);
|
||||
let middle = Point::new(125., 175.);
|
||||
let end = Point::new(145., 185.);
|
||||
let mut messages = vec![];
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: middle },
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&iced::Event::Mouse(event),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Available(end),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(Size::new(700., 600.)),
|
||||
);
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
|
||||
let total = messages
|
||||
.iter()
|
||||
.fold(Vector::ZERO, |total, event| match event {
|
||||
Event::Pan(delta) => total + *delta,
|
||||
_ => total,
|
||||
});
|
||||
assert_eq!(total, end - start);
|
||||
}
|
||||
|
||||
pub(crate) fn scope(app: &App) -> Scope {
|
||||
if let Some(context) = app.scenario_context() {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
} else {
|
||||
let context = app.topology_context().expect("mounted behavior owner");
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
|
||||
let id = scope(app).control_id(name);
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
|
||||
}
|
||||
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
|
||||
for _ in 0..32 {
|
||||
if focused(mounted, app, name) {
|
||||
return;
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
panic!("Keyboard traversal did not reach {name}");
|
||||
}
|
||||
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
|
||||
messages.iter().find(|m| matches!(m,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
|
||||
)).expect("actual nonzero camera delta from a mounted gesture").clone()
|
||||
}
|
||||
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
pub(crate) async fn release_middle(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
point: Point,
|
||||
) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
|
||||
let width = size.width as usize;
|
||||
assert_eq!(before.len(), width * size.height as usize * 4);
|
||||
assert_eq!(after.len(), before.len());
|
||||
let mut changed = 0;
|
||||
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
|
||||
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
|
||||
if x as f32 - bounds.x > 2.
|
||||
&& bounds.x + bounds.width - x as f32 > 2.
|
||||
&& y as f32 - bounds.y > 2.
|
||||
&& bounds.y + bounds.height - y as f32 > 2.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (y * width + x) * 4;
|
||||
if before[start..start + 3] != after[start..start + 3] {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
changed >= bounds.width.min(bounds.height) as usize,
|
||||
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn character(value: &str) -> iced::Event {
|
||||
let mut event = key(iced::keyboard::key::Named::Space);
|
||||
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
text,
|
||||
..
|
||||
}) = &mut event
|
||||
{
|
||||
*key = iced::keyboard::Key::Character(value.into());
|
||||
*modified_key = key.clone();
|
||||
*text = Some(value.into());
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
/// Inspect the action without stripping the lifetime from retained packets.
|
||||
pub(crate) fn intent_action(message: &Message) -> &Message {
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => &intent.message,
|
||||
message => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
|
||||
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
|
||||
pub(crate) async fn retained_focus_apply(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
) -> Box<dyn widget::Operation> {
|
||||
let entry = mounted.input(
|
||||
app,
|
||||
&[key(iced::keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
entry.len(),
|
||||
1,
|
||||
"actual initial F6 must emit one canvas focus request"
|
||||
);
|
||||
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
|
||||
.expect("F6 returns a widget Task");
|
||||
let Some(Action::Widget(mut operation)) = stream.next().await else {
|
||||
panic!("F6 first action must be Collect");
|
||||
};
|
||||
mounted.operate(app, &mut *operation);
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(apply) => apply,
|
||||
_ => panic!("Live Collect must produce its guarded Apply pass"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
|
||||
use iced::keyboard::key::Named;
|
||||
let geometry = mounted.geometry(app);
|
||||
let last_bounds = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert_eq!(offset.y, 0.);
|
||||
assert!(
|
||||
last_bounds.y >= rail.y + rail.height,
|
||||
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
|
||||
);
|
||||
mounted
|
||||
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(mounted, app, last).await;
|
||||
let geometry = mounted.geometry(app);
|
||||
let mut visible = geometry.containers[&scope(app).control_id(last)];
|
||||
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
|
||||
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
|
||||
visible.x -= offset.x;
|
||||
visible.y -= offset.y;
|
||||
assert!(
|
||||
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
|
||||
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
|
||||
);
|
||||
let messages = mounted
|
||||
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
|
||||
),
|
||||
"the revealed last tool is keyboard activated"
|
||||
);
|
||||
assert!(focused(mounted, app, last));
|
||||
let on = mounted.pixels(app);
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let off = mounted.pixels(app);
|
||||
assert_drawn_focus(&off, &on, app.window_size, visible);
|
||||
}
|
||||
|
||||
pub(crate) async fn retained_toolbar_boundaries(
|
||||
mounted: &mut Mounted,
|
||||
app: &mut App,
|
||||
tool: &'static str,
|
||||
) {
|
||||
use iced::keyboard::key::Named;
|
||||
for boundary in ["Chooser", "Dirty", "Picker"] {
|
||||
let mut packets = vec![];
|
||||
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
|
||||
packets.push(capture_toolbar(mounted, app, name));
|
||||
}
|
||||
let lifetime = app.camera_epoch;
|
||||
let epoch = app.canvas_epoch;
|
||||
let process = app.behavior.topology.camera_boundary();
|
||||
let scenario = app.behavior.scenario.camera_boundary();
|
||||
match boundary {
|
||||
"Chooser" => {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
let id = crate::forms::reference_chooser::trigger::field_id(
|
||||
draft.reference_generation(),
|
||||
"/parent",
|
||||
);
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(field, _, _)| field.as_ref() == Some(&id))
|
||||
.expect("real Inspector parent reference trigger")
|
||||
.1;
|
||||
let request = draft.reference_open(app.project(), "/parent", bounds);
|
||||
let task = app.update(Message::FieldReferenceOpen(request));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.reference_input_active());
|
||||
mounted
|
||||
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.reference_input_active());
|
||||
}
|
||||
"Dirty" => {
|
||||
let task = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unsaved camera boundary".into(),
|
||||
));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(app.draft_dirty());
|
||||
// Every retained packet is also rejected while the owner is dirty.
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert!(app.draft_dirty());
|
||||
let task = app.update(Message::RevertProperties);
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
"Picker" => {
|
||||
// Public transitions, with cancellation injected at the external
|
||||
// picker boundary. Never poll the native dialog future here.
|
||||
let picker = app.update(Message::Save(true));
|
||||
if cfg!(target_os = "linux") {
|
||||
assert!(app.file_prompt.is_some());
|
||||
mounted.perform(app, picker).await;
|
||||
let task = app.update(Message::CancelFile);
|
||||
mounted.perform(app, task).await;
|
||||
} else {
|
||||
assert!(app.busy, "Save As enters the real native-picker busy state");
|
||||
drop(picker);
|
||||
for packet in packets.iter().cloned() {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
let task = app.update(Message::SavePath(None));
|
||||
mounted.perform(app, task).await;
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_ne!(
|
||||
app.camera_epoch, lifetime,
|
||||
"{boundary} changes the presentation lifetime"
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"{boundary} cycle leaves semantic context unchanged"
|
||||
);
|
||||
let focus_before = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>();
|
||||
let current_lifetime = app.camera_epoch;
|
||||
for packet in packets {
|
||||
let task = app.update(packet);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
assert_eq!(
|
||||
app.camera_epoch, current_lifetime,
|
||||
"{boundary} retained intents cannot start a new lifetime"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.topology.camera_boundary() == process,
|
||||
"{boundary} retained toolbar packet changed process presentation"
|
||||
);
|
||||
assert!(
|
||||
app.behavior.scenario.camera_boundary() == scenario,
|
||||
"{boundary} retained toolbar packet changed scenario presentation"
|
||||
);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.collect::<Vec<_>>(),
|
||||
focus_before,
|
||||
"{boundary} retained toolbar packet stole field focus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
|
||||
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
|
||||
mounted
|
||||
.input(
|
||||
app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
|
||||
.expect("actual production toolbar packet")
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
use super::{Event as CameraEvent, Scope};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Operation, Tree, operation, tree},
|
||||
};
|
||||
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
|
||||
use iced::{keyboard, mouse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
scope: Option<Scope>,
|
||||
focused: bool,
|
||||
pointer: Option<Point>,
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pan = None;
|
||||
self.space = false;
|
||||
}
|
||||
}
|
||||
pub struct Viewport<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
blank_pan: bool,
|
||||
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
scope: Scope,
|
||||
scale: f32,
|
||||
keyboard: bool,
|
||||
publish: impl Fn(CameraEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
blank_pan: false,
|
||||
publish: Box::new(publish),
|
||||
}
|
||||
}
|
||||
pub fn pan_background(mut self, enabled: bool) -> Self {
|
||||
self.blank_pan = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Input>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Input {
|
||||
scope: Some(self.scope),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if state.scope.is_none_or(|s| {
|
||||
(s.document, s.owner, s.revision)
|
||||
!= (self.scope.document, self.scope.owner, self.scope.revision)
|
||||
}) {
|
||||
*state = Input::default();
|
||||
}
|
||||
if state
|
||||
.scope
|
||||
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|
||||
|| !self.keyboard
|
||||
{
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
state.scope = Some(self.scope);
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fill)
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if self.keyboard {
|
||||
let id = self.scope.control_id("viewport");
|
||||
operation.custom(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
&mut super::focus::Member(self.scope),
|
||||
);
|
||||
operation.focusable(
|
||||
Some(&id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Input>(),
|
||||
);
|
||||
}
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
|
||||
state.bounds = Some(layout.bounds());
|
||||
if changed_bounds || !self.keyboard {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
}
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
let pointer = match event {
|
||||
Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_)
|
||||
| mouse::Event::ButtonReleased(_)
|
||||
| mouse::Event::WheelScrolled { .. },
|
||||
) => state.pointer.or_else(|| cursor.position()),
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
_ => cursor.position().or(state.pointer),
|
||||
};
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
|
||||
&& cursor.position().is_some();
|
||||
if !self.keyboard {
|
||||
state.space = false;
|
||||
}
|
||||
let mut cancel = changed_bounds;
|
||||
let mut camera = None;
|
||||
let mut capture = false;
|
||||
match event {
|
||||
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focused = false;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pan = None;
|
||||
state.pointer = None;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
cancel = true;
|
||||
}
|
||||
state.focused = inside && self.keyboard;
|
||||
if inside
|
||||
&& self.keyboard
|
||||
&& (*button == mouse::Button::Middle
|
||||
|| (*button == mouse::Button::Left && state.space))
|
||||
{
|
||||
state.pan = pointer.map(|p| (*button, p));
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
if let Some((button, previous)) = state.pan {
|
||||
state.pan = Some((button, *position));
|
||||
camera = Some(CameraEvent::Pan(*position - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
if let Some((held, previous)) = state.pan
|
||||
&& held == *button
|
||||
{
|
||||
state.pan = None;
|
||||
camera = pointer
|
||||
.filter(|_| inside)
|
||||
.map(|p| CameraEvent::Pan(p - previous));
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
|
||||
state.pan = None;
|
||||
cancel = true;
|
||||
let y = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => *y,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
|
||||
};
|
||||
if y != 0. {
|
||||
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
|
||||
camera = Some(CameraEvent::Zoom {
|
||||
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
|
||||
at,
|
||||
base: self.scale,
|
||||
});
|
||||
capture = true;
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F6),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if self.keyboard && modifiers.is_empty() => {
|
||||
camera = Some(CameraEvent::ToggleFocus);
|
||||
capture = true;
|
||||
cancel = true;
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
|
||||
if state.focused && self.keyboard =>
|
||||
{
|
||||
use keyboard::{Key, key::Named};
|
||||
match key.as_ref() {
|
||||
Key::Named(Named::Space) => {
|
||||
state.space = true;
|
||||
cancel = true;
|
||||
camera = Some(CameraEvent::CancelGesture);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Tab) => {
|
||||
camera = Some(CameraEvent::Traverse(modifiers.shift()));
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Escape) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
camera = Some(CameraEvent::Leave);
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(key) if modifiers.is_empty() => {
|
||||
let delta = match key {
|
||||
Named::ArrowLeft => Some(Vector::new(60., 0.)),
|
||||
Named::ArrowRight => Some(Vector::new(-60., 0.)),
|
||||
Named::ArrowUp => Some(Vector::new(0., 60.)),
|
||||
Named::ArrowDown => Some(Vector::new(0., -60.)),
|
||||
_ => None,
|
||||
};
|
||||
camera = delta.map(CameraEvent::Pan);
|
||||
capture = camera.is_some();
|
||||
cancel = capture;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
|
||||
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
|
||||
{
|
||||
state.space = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cancel {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&Event::Window(iced::window::Event::Unfocused),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
if let Some(event) = camera {
|
||||
shell.publish((self.publish)(event));
|
||||
}
|
||||
if capture {
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
|
||||
return;
|
||||
}
|
||||
let child_cursor = if inside {
|
||||
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
child_cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// A semantic hit or armed creation tool gets first refusal. In Select,
|
||||
// an otherwise unused blank press starts the same camera gesture.
|
||||
if self.blank_pan
|
||||
&& self.keyboard
|
||||
&& inside
|
||||
&& !shell.is_event_captured()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.pan = pointer.map(|p| (mouse::Button::Left, p));
|
||||
shell.publish((self.publish)(CameraEvent::CancelGesture));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
renderer.with_layer(layout.bounds(), |renderer| {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
});
|
||||
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: crate::style::TEAL,
|
||||
width: 1.,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if tree.state.downcast_ref::<Input>().pan.is_some() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(viewport: Viewport<'a, Message, R>) -> Self {
|
||||
Element::new(viewport)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,761 @@
|
||||
//! Local behavior creation. A draft is not a model object: only its validated
|
||||
//! batch may cross Document::execute, and acknowledgement follows success.
|
||||
use iced::{
|
||||
Element as UiElement, Fill,
|
||||
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
|
||||
};
|
||||
use oa_core::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
pub const NAME_INPUT: &str = "behavior-creation-name";
|
||||
|
||||
// A focused text input consumes Escape. Preserve the application's modal
|
||||
// priority by emitting its ordinary Escape message from this narrow bridge.
|
||||
pub fn subscription(active: bool) -> iced::Subscription<()> {
|
||||
if active {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
iced::Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<()> {
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Family {
|
||||
Sequence,
|
||||
Scenario,
|
||||
Machine,
|
||||
}
|
||||
impl Family {
|
||||
fn kind(self, layer: Layer) -> Kind {
|
||||
match (self, layer) {
|
||||
(Self::Sequence, Layer::Oa) => Kind::Process,
|
||||
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
|
||||
(Self::Scenario, _) => Kind::Scenario,
|
||||
(Self::Machine, _) => Kind::StateMachine,
|
||||
}
|
||||
}
|
||||
fn title(self, layer: Layer) -> &'static str {
|
||||
match (self, layer) {
|
||||
(Self::Sequence, Layer::Oa) => "Operational process",
|
||||
(Self::Sequence, Layer::Sa) => "Functional chain",
|
||||
(Self::Scenario, _) => "Scenario",
|
||||
(Self::Machine, _) => "State machine",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Picker {
|
||||
Owner,
|
||||
Context,
|
||||
}
|
||||
impl Picker {
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Owner => 0,
|
||||
Self::Context => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
Owner(Id),
|
||||
Viewpoint(ScenarioViewpoint),
|
||||
Context(Option<Id>),
|
||||
ContextOptions(bool),
|
||||
FirstView(bool),
|
||||
Search(Picker, String),
|
||||
Page(Picker, usize),
|
||||
Cancel,
|
||||
Submit,
|
||||
}
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
query: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
|
||||
version: u32,
|
||||
document: Id,
|
||||
layer: Layer,
|
||||
family: Option<Family>,
|
||||
name: String,
|
||||
owner: Option<Id>,
|
||||
viewpoint: Option<ScenarioViewpoint>,
|
||||
context: Option<Id>,
|
||||
#[serde(default)]
|
||||
context_open: bool,
|
||||
first_view: bool,
|
||||
element: Id,
|
||||
diagram: Id,
|
||||
placement: Id,
|
||||
browsers: [Browser; 2],
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Created {
|
||||
pub behavior: Id,
|
||||
pub layer: Layer,
|
||||
pub diagram: Option<Id>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.draft.is_some()
|
||||
}
|
||||
pub fn pending_layer(&self) -> Option<Layer> {
|
||||
self.draft.as_ref().map(|d| d.layer)
|
||||
}
|
||||
/// Capture this before successful acknowledgement; never select these IDs
|
||||
/// on mere planning or a rejected source/model transaction.
|
||||
pub fn created(&self) -> Option<Created> {
|
||||
let d = self.draft.as_ref()?;
|
||||
d.family.map(|family| Created {
|
||||
behavior: d.element,
|
||||
layer: d.layer,
|
||||
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
|
||||
})
|
||||
}
|
||||
/// Match before execution, then apply this target only after success.
|
||||
/// Identity alone is insufficient: changed bodies or trailing operations
|
||||
/// must not inherit the prepared creation's selection/opening contract.
|
||||
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
|
||||
let target = self.created()?;
|
||||
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if draft.version != 1 {
|
||||
return Err("Unsupported behavior creation draft version.".into());
|
||||
}
|
||||
// Raw values and stale references remain editable; planning validates
|
||||
// against the current document rather than silently fixing the draft.
|
||||
Ok(Self { draft: Some(draft) })
|
||||
}
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose New to start a behavior.")?
|
||||
.command(p)
|
||||
}
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.family != Some(Family::Machine) {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.document != p.id {
|
||||
return Err("This behavior draft belongs to another document.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Machine { layer: d.layer },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.owner = Some(id);
|
||||
let preview = next
|
||||
.source
|
||||
.preview(p, next.owner)?
|
||||
.ok_or("New owner preview is missing.")?;
|
||||
if !eligible(&preview, next.layer, Picker::Owner, id) {
|
||||
return Err("This participant cannot own the machine.".into());
|
||||
}
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Cancel => {
|
||||
self.draft = None;
|
||||
return Ok(None);
|
||||
}
|
||||
Event::Open(layer) => {
|
||||
if self.has_pending() {
|
||||
return Err("Create or cancel the pending behavior first.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
version: 1,
|
||||
document: p.id,
|
||||
layer,
|
||||
family: None,
|
||||
name: String::new(),
|
||||
owner: None,
|
||||
viewpoint: None,
|
||||
context: None,
|
||||
context_open: false,
|
||||
first_view: false,
|
||||
element: Id::new_v4(),
|
||||
diagram: Id::new_v4(),
|
||||
placement: Id::new_v4(),
|
||||
browsers: Default::default(),
|
||||
});
|
||||
return Ok(None);
|
||||
}
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
_ => {}
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Choose New to start a behavior.")?;
|
||||
if d.document != p.id {
|
||||
return Err("This draft belongs to another document. Cancel it to start here.".into());
|
||||
}
|
||||
match event {
|
||||
Event::Choose(family) => {
|
||||
if d.family.is_some_and(|old| old != family) {
|
||||
return Err(
|
||||
"Cancel this draft before choosing a different behavior family.".into(),
|
||||
);
|
||||
}
|
||||
d.family = Some(family);
|
||||
}
|
||||
Event::Name(name) => d.name = name,
|
||||
Event::Owner(id) if d.family == Some(Family::Machine) => {
|
||||
if !eligible(p, d.layer, Picker::Owner, id) {
|
||||
return Err("Choose an existing participant in this layer.".into());
|
||||
}
|
||||
d.owner = Some(id);
|
||||
}
|
||||
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
|
||||
if !viewpoints(d.layer).contains(&viewpoint) {
|
||||
return Err("Choose a scenario viewpoint from this layer.".into());
|
||||
}
|
||||
d.viewpoint = Some(viewpoint);
|
||||
}
|
||||
Event::Context(id) if d.family == Some(Family::Scenario) => {
|
||||
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
|
||||
return Err("Choose an existing same-layer scenario context.".into());
|
||||
}
|
||||
d.context = id;
|
||||
}
|
||||
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
|
||||
d.context_open = open;
|
||||
}
|
||||
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
|
||||
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
|
||||
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
|
||||
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
|
||||
_ => return Err("That field does not apply to this behavior family.".into()),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
|
||||
match layer {
|
||||
Layer::Oa => [
|
||||
ScenarioViewpoint::OperationalActivity,
|
||||
ScenarioViewpoint::ParticipantInteraction,
|
||||
],
|
||||
Layer::Sa => [
|
||||
ScenarioViewpoint::Functional,
|
||||
ScenarioViewpoint::BoundaryInterface,
|
||||
],
|
||||
}
|
||||
}
|
||||
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
|
||||
match viewpoint {
|
||||
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
|
||||
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
|
||||
ScenarioViewpoint::Functional => "Functions · functional exchanges",
|
||||
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
|
||||
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
|
||||
}
|
||||
}
|
||||
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.layer() == layer
|
||||
&& match picker {
|
||||
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
|
||||
Picker::Context => {
|
||||
e.kind().is_sequence()
|
||||
|| e.kind().is_work()
|
||||
|| e.kind().is_performer()
|
||||
|| matches!(
|
||||
e.kind(),
|
||||
Kind::Capability
|
||||
| Kind::SystemCapability
|
||||
| Kind::OperationalMission
|
||||
| Kind::Mission
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut matches: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| eligible(p, layer, picker, e.id))
|
||||
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
|
||||
.collect();
|
||||
matches.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
matches
|
||||
}
|
||||
|
||||
fn reference_context(p: &Project, e: &Element) -> String {
|
||||
let mut names = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = parent {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
let Some(ancestor) = p.elements.get(&id) else {
|
||||
break;
|
||||
};
|
||||
names.push(ancestor.name.as_str());
|
||||
parent = ancestor.parent;
|
||||
}
|
||||
names.reverse();
|
||||
let location = if names.is_empty() {
|
||||
format!("{} root", e.layer())
|
||||
} else {
|
||||
format!("{} / {}", e.layer(), names.join(" / "))
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
.unwrap_or_else(|| "Missing element — choose a replacement".into())
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Machine { layer: self.layer },
|
||||
self.owner,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.owner)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.owner, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id {
|
||||
return Err("This draft belongs to another document.".into());
|
||||
}
|
||||
let family = self
|
||||
.family
|
||||
.ok_or("Choose the behavior you want to create.")?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("Enter the behavior's name before creating it.".into());
|
||||
}
|
||||
if [self.element, self.diagram, self.placement]
|
||||
.into_iter()
|
||||
.any(|id| p.contains(id))
|
||||
|| self.element == self.diagram
|
||||
|| self.element == self.placement
|
||||
|| self.diagram == self.placement
|
||||
{
|
||||
return Err(
|
||||
"A reserved creation identity is already in use. Cancel and start a fresh draft."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let kind = family.kind(self.layer);
|
||||
let parent = if family == Family::Machine {
|
||||
Some(
|
||||
self.owner
|
||||
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
|
||||
.ok_or("Choose the participant that owns this state machine.")?,
|
||||
)
|
||||
} else {
|
||||
if self.owner.is_some() {
|
||||
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
|
||||
}
|
||||
None
|
||||
};
|
||||
if family != Family::Machine && self.first_view {
|
||||
return Err("A state view can only accompany a state machine.".into());
|
||||
}
|
||||
let mut body = ElementBody::new(kind, self.layer)
|
||||
.ok_or("This behavior is unavailable in this layer.")?;
|
||||
if family == Family::Scenario {
|
||||
let viewpoint = self
|
||||
.viewpoint
|
||||
.filter(|v| viewpoints(self.layer).contains(v))
|
||||
.ok_or("Choose the scenario's viewpoint explicitly.")?;
|
||||
if self
|
||||
.context
|
||||
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
|
||||
{
|
||||
return Err(
|
||||
"The selected scenario context is missing or no longer compatible.".into(),
|
||||
);
|
||||
}
|
||||
body = ElementBody::shared_in(
|
||||
self.layer,
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context: self.context,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: Vec::new(),
|
||||
},
|
||||
);
|
||||
} else if self.viewpoint.is_some() || self.context.is_some() {
|
||||
return Err(
|
||||
"Scenario viewpoint and context cannot be applied to another behavior family."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let element = Element {
|
||||
id: self.element,
|
||||
alias: p.fresh_alias(&self.name, "behavior", self.element),
|
||||
name: self.name.clone(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
};
|
||||
let mut commands = vec![Command::CreateElement(element)];
|
||||
if self.first_view {
|
||||
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
|
||||
// Reuse the state editor's geometry contract. A new machine has one
|
||||
// root placement; reserve both presentation IDs across retries.
|
||||
let plan = super::state_edit::create_view(&working, self.element)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Command::Batch(mut view_commands) = plan.command else {
|
||||
return Err("Unexpected state-view plan.".into());
|
||||
};
|
||||
if view_commands.len() != 2 {
|
||||
return Err("Unexpected additional operation in the first state-view plan.".into());
|
||||
}
|
||||
let Some(Command::Layout(layout)) = view_commands.pop() else {
|
||||
return Err("State-view plan must finish with its layout operation.".into());
|
||||
};
|
||||
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
|
||||
return Err("State-view plan is missing its diagram.".into());
|
||||
};
|
||||
if diagram.id != layout || diagram.nodes.len() != 1 {
|
||||
return Err("A first state view must contain only its new machine.".into());
|
||||
}
|
||||
let mut node = diagram
|
||||
.nodes
|
||||
.into_values()
|
||||
.next()
|
||||
.ok_or("State-view root is missing.")?;
|
||||
if node.element != self.element {
|
||||
return Err("State-view root differs from the new machine.".into());
|
||||
}
|
||||
diagram.id = self.diagram;
|
||||
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
|
||||
node.id = self.placement;
|
||||
node.alias = working.fresh_alias(
|
||||
&format!("{} placement", self.name),
|
||||
"placement",
|
||||
self.placement,
|
||||
);
|
||||
diagram.nodes = [(node.id, node)].into_iter().collect();
|
||||
commands.push(Command::CreateDiagram(diagram));
|
||||
commands.push(Command::Layout(self.diagram));
|
||||
}
|
||||
let command = Command::Batch(commands);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Parent maps these local events into the existing guarded Behavior route.
|
||||
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
|
||||
self.view_with(p)
|
||||
}
|
||||
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
&'a self,
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.draft.as_ref()?;
|
||||
if let Some(view) = d.source.view(p) {
|
||||
return Some(
|
||||
column![
|
||||
text(format!(
|
||||
"State machine name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
view.map(Event::Source)
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut fields = column![].spacing(10);
|
||||
let title = if let Some(family) = d.family {
|
||||
fields = fields.push(
|
||||
text_input("Exact behavior name", &d.name)
|
||||
.id(NAME_INPUT)
|
||||
.on_input(Event::Name)
|
||||
.on_submit(Event::Submit)
|
||||
.padding(8),
|
||||
);
|
||||
match family {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
Family::Scenario => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Choose what the lifelines represent:",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
for viewpoint in viewpoints(d.layer) {
|
||||
fields = fields.push(
|
||||
button(text(viewpoint_title(viewpoint)).size(13))
|
||||
.style(if d.viewpoint == Some(viewpoint) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
})
|
||||
.on_press(Event::Viewpoint(viewpoint))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
let context_label = if d.context_open {
|
||||
"Hide optional context".into()
|
||||
} else if let Some(id) = d.context {
|
||||
format!("Context: {} · Change…", reference_label(p, id))
|
||||
} else {
|
||||
"Add optional context…".into()
|
||||
};
|
||||
fields = fields.push(
|
||||
button(text(context_label).size(12))
|
||||
.on_press(Event::ContextOptions(!d.context_open))
|
||||
.padding(6),
|
||||
);
|
||||
if d.context_open {
|
||||
fields = fields.push(picker_view(p, d, Picker::Context));
|
||||
}
|
||||
}
|
||||
Family::Machine => {
|
||||
if let Some(label) = d.source.label(d.owner) {
|
||||
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
|
||||
}
|
||||
fields = fields.push(picker_view(p, d, Picker::Owner));
|
||||
fields = fields.push(
|
||||
button("Create new owner…")
|
||||
.on_press(Event::Source(super::source_draft::Event::Open(
|
||||
d.invocation,
|
||||
)))
|
||||
.padding(7),
|
||||
);
|
||||
fields = fields.push(
|
||||
checkbox(d.first_view)
|
||||
.label("Create the first state view")
|
||||
.on_toggle(Event::FirstView),
|
||||
);
|
||||
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
|
||||
}
|
||||
}
|
||||
format!("New {}", family.title(d.layer))
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
|
||||
.spacing(3),
|
||||
)
|
||||
.on_press(Event::Choose(family))
|
||||
.padding(10)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
format!("New behavior · {}", d.layer)
|
||||
};
|
||||
let mut actions =
|
||||
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
|
||||
if d.family.is_some() {
|
||||
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
|
||||
}
|
||||
Some(
|
||||
container(
|
||||
column![
|
||||
text(title).size(20),
|
||||
scrollable(fields)
|
||||
.id("behavior-creation-fields")
|
||||
.height(Fill),
|
||||
container(actions).id("behavior-creation-actions")
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(18)
|
||||
.max_width(680)
|
||||
.height(Fill)
|
||||
.style(crate::style::card)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
p: &'a Project,
|
||||
d: &'a Draft,
|
||||
picker: Picker,
|
||||
) -> UiElement<'a, Event, iced::Theme, R> {
|
||||
let browser = &d.browsers[picker.index()];
|
||||
let options = choices(p, d.layer, picker, &browser.query);
|
||||
let page = browser
|
||||
.page
|
||||
.min(options.len().saturating_sub(1) / PAGE_SIZE);
|
||||
let selected = match picker {
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let title = match picker {
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
let mut out = column![
|
||||
text(title).size(13),
|
||||
text_input("Search names, aliases or parent context", &browser.query)
|
||||
.on_input(move |query| Event::Search(picker, query))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected {
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
let message = match picker {
|
||||
Picker::Owner => Event::Owner(e.id),
|
||||
Picker::Context => Event::Context(Some(e.id)),
|
||||
};
|
||||
out = out.push(
|
||||
container(
|
||||
button(
|
||||
column![
|
||||
text(&e.name).size(13),
|
||||
text(reference_context(p, e)).size(11)
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.style(if selected == Some(e.id) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
})
|
||||
.on_press(message)
|
||||
.padding(6)
|
||||
.width(Fill),
|
||||
)
|
||||
.id(format!("behavior-creation-candidate-{}", e.id)),
|
||||
);
|
||||
}
|
||||
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
|
||||
if page > 0 {
|
||||
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
|
||||
}
|
||||
if (page + 1) * PAGE_SIZE < options.len() {
|
||||
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
|
||||
}
|
||||
if picker == Picker::Context && selected.is_some() {
|
||||
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
|
||||
}
|
||||
out.push(navigation).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod app_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,340 @@
|
||||
//! App-boundary regressions for the coordinated parent wiring. No I/O task is
|
||||
//! polled: file dialogs, checkpoint writes and lesson loading remain inactive.
|
||||
use super::*;
|
||||
use crate::app::{App, Message, Page, Pending};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(crate::behavior::Event::Creation(event)));
|
||||
}
|
||||
fn raw_property(app: &App) -> serde_json::Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
|
||||
let _ = app.update(Message::Layer(layer));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
send(app, Event::Open(layer));
|
||||
send(app, Event::Choose(family));
|
||||
send(app, Event::Name(name.into()));
|
||||
}
|
||||
|
||||
fn focused_escape() -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
|
||||
.id(NAME_INPUT)
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
input.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert_eq!(
|
||||
captured_escape(
|
||||
event.clone(),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique()
|
||||
),
|
||||
None
|
||||
);
|
||||
captured_escape(
|
||||
event,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap();
|
||||
Message::Escape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() {
|
||||
for file in [false, true] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"Keep while overlay closes",
|
||||
);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
if file {
|
||||
app.file_prompt = Some(crate::app::FilePrompt {
|
||||
operation: crate::app::FileOperation::Save,
|
||||
path: "unsubmitted-name.arcadia".into(),
|
||||
overwrite: false,
|
||||
});
|
||||
} else {
|
||||
app.pending = Some(Pending::Walkthrough);
|
||||
}
|
||||
let _ = app.update(focused_escape());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(focused_escape());
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let old = p
|
||||
.add_element(Family::Sequence.kind(layer), layer, "Old behavior", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
let _ = app.update(Message::Select(old));
|
||||
begin(&mut app, layer, Family::Sequence, " New exact path 雨 ");
|
||||
let target = app.behavior.creation.created().unwrap();
|
||||
assert_ne!(target.behavior, old);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Submit);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.selected, target.behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(target.behavior));
|
||||
assert_eq!(crate::behavior::selected_owner(&app), Some(target.behavior));
|
||||
assert_eq!(
|
||||
app.project().elements[&target.behavior].name,
|
||||
" New exact path 雨 "
|
||||
);
|
||||
assert!(!app.behavior.has_pending());
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert_eq!(app.project().elements[&old], p.elements[&old]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction() {
|
||||
for first_view in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Flight system", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
begin(&mut app, Layer::Sa, Family::Machine, " Flight modes 雨 ");
|
||||
send(&mut app, Event::Owner(owner));
|
||||
send(&mut app, Event::FirstView(first_view));
|
||||
let target = app.behavior.creation.created().unwrap();
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Behavior(crate::behavior::Event::Submit));
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
assert_eq!(app.selected, target.behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(target.behavior));
|
||||
assert_eq!(app.behavior.diagram.diagram, target.diagram);
|
||||
assert_eq!(
|
||||
crate::behavior::state_view::diagram(&app, target.behavior),
|
||||
target.diagram
|
||||
);
|
||||
assert_eq!(app.project().elements[&target.behavior].parent, Some(owner));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.project(), &p);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_stale_layer_open_never_starts_or_replaces_a_draft() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
app.page = Page::Behavior;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
send(&mut app, Event::Open(Layer::Sa));
|
||||
send(&mut app, Event::Choose(Family::Sequence));
|
||||
send(&mut app, Event::Name("Retained system chain".into()));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts() {
|
||||
for dirty in [
|
||||
"property-valid",
|
||||
"property-invalid",
|
||||
"source-valid",
|
||||
"source-invalid",
|
||||
] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Behavior;
|
||||
match dirty {
|
||||
"property-valid" => app
|
||||
.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Raw property name".into()),
|
||||
"property-invalid" => app.draft.as_mut().unwrap().input("/id", "-".into()),
|
||||
"source-valid" => app.source_workspace.edit(
|
||||
format!("{}\n// Retained comment\n", app.source_workspace.text()),
|
||||
app.source_editor.cursor(),
|
||||
),
|
||||
_ => app
|
||||
.source_workspace
|
||||
.edit("unfinished source 雨".into(), app.source_editor.cursor()),
|
||||
}
|
||||
let accepted = app.document.snapshot();
|
||||
let property = raw_property(&app);
|
||||
let workspace = app.source_workspace.recovery_snapshot();
|
||||
send(&mut app, Event::Open(Layer::Oa));
|
||||
assert!(
|
||||
!app.behavior.creation.has_pending(),
|
||||
"{dirty}: New must refuse without applying another editor"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(raw_property(&app), property);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
|
||||
serde_json::to_value(workspace).unwrap()
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft() {
|
||||
let mut p = Project::empty();
|
||||
let old = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Old process", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
begin(&mut app, Layer::Oa, Family::Sequence, "Retain this process");
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
let _ = app.update(Message::BehaviorOwner(crate::app::Choice {
|
||||
id: old,
|
||||
label: "Old process".into(),
|
||||
}));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"Unfinished engineering path",
|
||||
);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let task = app.update(Message::Home(crate::home::Event::Walkthrough));
|
||||
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
begin(
|
||||
&mut app,
|
||||
Layer::Sa,
|
||||
Family::Scenario,
|
||||
" Recovered system scenario 雨 ",
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Viewpoint(ScenarioViewpoint::BoundaryInterface),
|
||||
);
|
||||
// Recovery can contain simultaneous drafts even though new input is guarded.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Retained property".into());
|
||||
app.source_workspace.edit(
|
||||
"unfinished source preserved".into(),
|
||||
app.source_editor.cursor(),
|
||||
);
|
||||
let snapshot = crate::recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap();
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let mut restored = App::with_project(Project::empty());
|
||||
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot))));
|
||||
assert_eq!(restored.page, Page::Behavior);
|
||||
assert_eq!(restored.layer, Layer::Sa);
|
||||
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(
|
||||
restored.behavior.creation.created(),
|
||||
app.behavior.creation.created()
|
||||
);
|
||||
assert_eq!(restored.project(), expected.document.project());
|
||||
assert_eq!(restored.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(
|
||||
restored.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
assert_eq!(raw_property(&restored), raw_property(&app));
|
||||
let before = restored.document.snapshot();
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(Arc::ptr_eq(&before, &restored.document.snapshot()));
|
||||
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(raw_property(&restored), raw_property(&app));
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn start(p: &Project, layer: Layer, family: Family, name: &str) -> State {
|
||||
let mut state = State::default();
|
||||
assert!(state.update(p, Event::Open(layer)).unwrap().is_none());
|
||||
state.update(p, Event::Choose(family)).unwrap();
|
||||
state.update(p, Event::Name(name.into())).unwrap();
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates() {
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
struct Geometry {
|
||||
candidates: Vec<widget::Id>,
|
||||
rendered: Vec<widget::Id>,
|
||||
actions: Option<Rectangle>,
|
||||
fields: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("behavior-creation-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
if let Some(id) = id.filter(|id| self.candidates.contains(id)) {
|
||||
self.rendered.push(id.clone());
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&widget::Id::new("behavior-creation-fields")) {
|
||||
self.fields = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
for index in 0..105 {
|
||||
p.add_element(
|
||||
Kind::Entity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"{index:03} Long engineering participant name {}",
|
||||
"雨 extended ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let candidates: Vec<_> = p
|
||||
.elements
|
||||
.keys()
|
||||
.map(|id| widget::Id::from(format!("behavior-creation-candidate-{id}")))
|
||||
.collect();
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let state = start(&p, Layer::Oa, family, "Very long exact behavior name 雨");
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(500., 320.),
|
||||
] {
|
||||
let mut view: UiElement<'_, Event, iced::Theme, ()> = state.view_with(&p).unwrap();
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry {
|
||||
candidates: candidates.clone(),
|
||||
rendered: Vec::new(),
|
||||
actions: None,
|
||||
fields: None,
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut geometry);
|
||||
let actions = geometry.actions.unwrap();
|
||||
let fields = geometry.fields.unwrap();
|
||||
assert!(actions.x >= 0. && actions.y >= 0.);
|
||||
assert!(
|
||||
actions.x + actions.width <= size.width
|
||||
&& actions.y + actions.height <= size.height
|
||||
);
|
||||
assert!(fields.height >= 100. && fields.y + fields.height <= actions.y);
|
||||
assert_eq!(
|
||||
geometry.rendered.len(),
|
||||
if family == Family::Machine { 6 } else { 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(p: Project, state: &mut State) -> Project {
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
command,
|
||||
state.prepare_pending(&p).unwrap(),
|
||||
"preparation must retain reserved IDs"
|
||||
);
|
||||
let api: Command = serde_json::from_value(serde_json::to_value(&command).unwrap()).unwrap();
|
||||
assert_eq!(api, command);
|
||||
let expected = execute_batch(&p, &[api]).unwrap();
|
||||
let target = state.created().unwrap();
|
||||
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before_source = document.bundle().clone();
|
||||
document.execute(command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert!(
|
||||
state.has_pending(),
|
||||
"only successful acknowledgement clears raw fields"
|
||||
);
|
||||
state.acknowledge_commit();
|
||||
assert!(!state.has_pending());
|
||||
assert!(document.project().elements.contains_key(&target.behavior));
|
||||
let after_source = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(document.bundle(), &before_source);
|
||||
assert!(!document.can_undo(), "one creation must be one Undo entry");
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &after_source);
|
||||
let reopened = Document::from_bundle(after_source, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let p = Project::empty();
|
||||
let name = " Supply \"water\" — режим ";
|
||||
let mut state = start(&p, layer, Family::Sequence, name);
|
||||
let target = state.created().unwrap();
|
||||
let result = roundtrip(p, &mut state);
|
||||
let element = &result.elements[&target.behavior];
|
||||
assert_eq!(element.name, name);
|
||||
assert_eq!(element.layer(), layer);
|
||||
assert_eq!(
|
||||
element.kind(),
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
}
|
||||
);
|
||||
assert_eq!(element.parent, None);
|
||||
assert!(element.steps().unwrap().is_empty());
|
||||
assert!(result.diagrams.is_empty());
|
||||
assert!(result.relations.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_viewpoints_are_explicit_and_survive_source_history_reopen() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for viewpoint in viewpoints(layer) {
|
||||
let mut p = Project::empty();
|
||||
let context = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Capability
|
||||
} else {
|
||||
Kind::SystemCapability
|
||||
},
|
||||
layer,
|
||||
"Water supply",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = start(&p, layer, Family::Scenario, "Nominal supply");
|
||||
assert!(
|
||||
state
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("explicitly")
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::MixedLegacy))
|
||||
.is_err()
|
||||
);
|
||||
let wrong = viewpoints(if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
})[0];
|
||||
assert!(state.update(&p, Event::Viewpoint(wrong)).is_err());
|
||||
state.update(&p, Event::Viewpoint(viewpoint)).unwrap();
|
||||
state.update(&p, Event::Context(Some(context))).unwrap();
|
||||
let id = state.created().unwrap().behavior;
|
||||
let result = roundtrip(p, &mut state);
|
||||
assert_eq!(
|
||||
result.elements[&id].parent, None,
|
||||
"context is not containment"
|
||||
);
|
||||
assert!(
|
||||
matches!(result.elements[&id].shared(), Some(SharedBody::Scenario { viewpoint: actual, context: Some(actual_context), steps, .. }) if *actual == viewpoint && *actual_context == context && steps.is_empty())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_requires_valid_owner_and_first_view_adds_no_region_or_state() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for first_view in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Pump",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wrong_kind = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
"Pump water",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let opposite = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let wrong_layer = p
|
||||
.add_element(
|
||||
if opposite == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
opposite,
|
||||
"Other layer pump",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = start(&p, layer, Family::Machine, "Pump modes");
|
||||
assert!(
|
||||
state
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("participant")
|
||||
);
|
||||
assert!(state.update(&p, Event::Owner(wrong_kind)).is_err());
|
||||
assert!(state.update(&p, Event::Owner(wrong_layer)).is_err());
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(first_view)).unwrap();
|
||||
let target = state.created().unwrap();
|
||||
let expected_count = p.elements.len() + 1;
|
||||
let result = roundtrip(p, &mut state);
|
||||
assert_eq!(result.elements.len(), expected_count);
|
||||
assert_eq!(result.elements[&target.behavior].parent, Some(owner));
|
||||
assert_eq!(result.elements[&target.behavior].kind(), Kind::StateMachine);
|
||||
assert_eq!(result.diagrams.len(), usize::from(first_view));
|
||||
if let Some(view) = target.diagram {
|
||||
let diagram = &result.diagrams[&view];
|
||||
assert_eq!(
|
||||
diagram.projection,
|
||||
ProjectionKind::StateMachine {
|
||||
machine: target.behavior
|
||||
}
|
||||
);
|
||||
assert_eq!(diagram.nodes.len(), 1);
|
||||
assert_eq!(
|
||||
diagram.nodes.values().next().unwrap().element,
|
||||
target.behavior
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_and_illegal_family_layer_switches_never_create_model_objects() {
|
||||
let p = Project::empty();
|
||||
let mut state = start(&p, Layer::Oa, Family::Scenario, "Raw name");
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Viewpoint(ScenarioViewpoint::ParticipantInteraction),
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot = state.recovery_snapshot().unwrap();
|
||||
assert!(state.update(&p, Event::Open(Layer::Sa)).is_err());
|
||||
assert!(state.update(&p, Event::Choose(Family::Sequence)).is_err());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), snapshot);
|
||||
assert!(state.update(&p, Event::Cancel).unwrap().is_none());
|
||||
assert!(!state.has_pending());
|
||||
assert!(state.created().is_none());
|
||||
assert!(state.prepare_pending(&p).is_err());
|
||||
assert!(p.elements.is_empty());
|
||||
assert!(p.diagrams.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Plant", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, " \nRaw rejected name\0 ");
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(true)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Owner, " Plant ".into()))
|
||||
.unwrap();
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
|
||||
assert_eq!(restored.created(), state.created());
|
||||
assert_eq!(restored.pending_layer(), Some(Layer::Oa));
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
restored
|
||||
.update(&p, Event::Name("Plant modes".into()))
|
||||
.unwrap();
|
||||
let command = restored.prepare_pending(&p).unwrap();
|
||||
let again = State::from_recovery(restored.recovery_snapshot().unwrap().unwrap()).unwrap();
|
||||
assert_eq!(again.prepare_pending(&p).unwrap(), command);
|
||||
p.elements.remove(&owner);
|
||||
let before = restored.recovery_snapshot().unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("participant")
|
||||
);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
let different = Project::empty();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&different)
|
||||
.unwrap_err()
|
||||
.contains("another document")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_source_transaction_keeps_draft_and_every_accepted_source_byte() {
|
||||
let p = Project::empty();
|
||||
let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let bytes: usize = initial.bundle().files.values().map(String::len).sum();
|
||||
let limits = ProjectLimits {
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: bytes + 32,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
},
|
||||
..ProjectLimits::default()
|
||||
};
|
||||
let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap();
|
||||
let mut state = start(
|
||||
&p,
|
||||
Layer::Oa,
|
||||
Family::Sequence,
|
||||
"A long exact name retained after the source budget refuses this new process",
|
||||
);
|
||||
let raw = state.recovery_snapshot().unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert!(doc.execute(command).is_err());
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), initial.bundle());
|
||||
assert!(!doc.can_undo());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), raw);
|
||||
state.update(&p, Event::Cancel).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog() {
|
||||
let mut p = Project::empty();
|
||||
for index in (0..105).rev() {
|
||||
p.add_element(Kind::Entity, Layer::Oa, &format!("Pump {index:03}"), None)
|
||||
.unwrap();
|
||||
}
|
||||
p.add_element(Kind::System, Layer::Sa, "Pump 999", None)
|
||||
.unwrap();
|
||||
p.add_element(Kind::Activity, Layer::Oa, "Pump work", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
|
||||
let all = choices(&p, Layer::Oa, Picker::Owner, "");
|
||||
assert_eq!(all.len(), 105);
|
||||
assert_eq!(all[0].name, "Pump 000");
|
||||
state
|
||||
.update(&p, Event::Page(Picker::Owner, usize::MAX))
|
||||
.unwrap();
|
||||
assert!(
|
||||
state.view(&p).is_some(),
|
||||
"stale pages are clamped before slicing"
|
||||
);
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Owner, "Pump 104".into()))
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
|
||||
let found = choices(&p, Layer::Oa, Picker::Owner, "Pump 104");
|
||||
assert_eq!(found.len(), 1);
|
||||
state.update(&p, Event::Owner(found[0].id)).unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().owner, Some(found[0].id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias() {
|
||||
let mut p = Project::empty();
|
||||
let north = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "North station", None)
|
||||
.unwrap();
|
||||
let south = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "South station", None)
|
||||
.unwrap();
|
||||
let first = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(north))
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(south))
|
||||
.unwrap();
|
||||
let label = reference_label(&p, second);
|
||||
assert_ne!(reference_label(&p, first), label);
|
||||
assert!(label.contains("South station") && label.contains("Pump"));
|
||||
assert!(label.contains(&p.elements[&second].alias));
|
||||
let found = choices(&p, Layer::Oa, Picker::Owner, "south station");
|
||||
assert!(found.iter().any(|e| e.id == second));
|
||||
assert!(!found.iter().any(|e| e.id == first));
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump states");
|
||||
state.update(&p, Event::Owner(second)).unwrap();
|
||||
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
|
||||
assert_eq!(
|
||||
result.elements[&state.created().unwrap().behavior].parent,
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values() {
|
||||
let p = Project::empty();
|
||||
let state = start(&p, Layer::Sa, Family::Sequence, "Supply");
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(99)),
|
||||
("unexpected", serde_json::json!(true)),
|
||||
] {
|
||||
let mut bad = raw.clone();
|
||||
bad[field] = value;
|
||||
assert!(State::from_recovery(bad).is_err());
|
||||
}
|
||||
let mut bad = raw;
|
||||
bad["element"] = serde_json::json!(p.id);
|
||||
let restored = State::from_recovery(bad.clone()).unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.prepare_pending(&p)
|
||||
.unwrap_err()
|
||||
.contains("identity")
|
||||
);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_commit_selection_is_correlated_with_exact_created_behavior_and_view() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Pump", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
|
||||
state.update(&p, Event::Owner(owner)).unwrap();
|
||||
state.update(&p, Event::FirstView(true)).unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert_eq!(state.created_by(&p, &command), state.created());
|
||||
assert_eq!(
|
||||
state.created_by(&p, &Command::Rename(owner, "Unrelated change".into())),
|
||||
None
|
||||
);
|
||||
for edit in 0..4 {
|
||||
let mut changed = command.clone();
|
||||
let Command::Batch(ref mut commands) = changed else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
if edit < 3 {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("new behavior");
|
||||
};
|
||||
match edit {
|
||||
0 => element.id = Id::new_v4(),
|
||||
1 => element.name = "A different authored draft".into(),
|
||||
_ => element.parent = None,
|
||||
}
|
||||
} else {
|
||||
let Command::CreateDiagram(diagram) = &mut commands[1] else {
|
||||
panic!("new view");
|
||||
};
|
||||
diagram.projection = ProjectionKind::StateMachine { machine: owner };
|
||||
}
|
||||
assert_eq!(state.created_by(&p, &changed), None);
|
||||
}
|
||||
let Command::Batch(commands) = command else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
assert_eq!(
|
||||
state.created_by(&p, &commands[0]),
|
||||
None,
|
||||
"an opt-in view must be part of the actual committed batch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_context_collapses_without_losing_authored_reference_or_raw_search() {
|
||||
let mut p = Project::empty();
|
||||
let context = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Water supply", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Oa, Family::Scenario, "Supply nominal");
|
||||
assert!(!state.draft.as_ref().unwrap().context_open);
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::OperationalActivity))
|
||||
.unwrap();
|
||||
state.update(&p, Event::ContextOptions(true)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Search(Picker::Context, " Water ".into()))
|
||||
.unwrap();
|
||||
state.update(&p, Event::Context(Some(context))).unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
state.update(&p, Event::ContextOptions(false)).unwrap();
|
||||
let restored = State::from_recovery(state.recovery_snapshot().unwrap().unwrap()).unwrap();
|
||||
assert!(!restored.draft.as_ref().unwrap().context_open);
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().browsers[1].query,
|
||||
" Water "
|
||||
);
|
||||
assert_eq!(restored.prepare_pending(&p).unwrap(), command);
|
||||
assert_eq!(restored.created_by(&p, &command), restored.created());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_selection_rejects_changed_scenario_body_and_post_creation_operations() {
|
||||
let mut p = Project::empty();
|
||||
let capability = p
|
||||
.add_element(Kind::SystemCapability, Layer::Sa, "Supply", None)
|
||||
.unwrap();
|
||||
let mut state = start(&p, Layer::Sa, Family::Scenario, "Nominal functions");
|
||||
state
|
||||
.update(&p, Event::Viewpoint(ScenarioViewpoint::Functional))
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap();
|
||||
assert_eq!(state.created_by(&p, &command), state.created());
|
||||
for edit in 0..4 {
|
||||
let mut altered = command.clone();
|
||||
let Command::Batch(commands) = &mut altered else {
|
||||
panic!("creation batch");
|
||||
};
|
||||
match edit {
|
||||
0 => {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("scenario");
|
||||
};
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared_mut() else {
|
||||
panic!("scenario body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::BoundaryInterface;
|
||||
}
|
||||
1 => {
|
||||
let Command::CreateElement(element) = &mut commands[0] else {
|
||||
panic!("scenario");
|
||||
};
|
||||
let Some(SharedBody::Scenario { context, .. }) = element.shared_mut() else {
|
||||
panic!("scenario body");
|
||||
};
|
||||
*context = Some(capability);
|
||||
}
|
||||
2 => commands.push(Command::Rename(
|
||||
state.created().unwrap().behavior,
|
||||
"Changed afterward".into(),
|
||||
)),
|
||||
_ => commands.push(Command::Delete(state.created().unwrap().behavior)),
|
||||
}
|
||||
assert!(
|
||||
execute_batch(&p, std::slice::from_ref(&altered)).is_ok(),
|
||||
"the alternate command is valid, but is not this authored creation"
|
||||
);
|
||||
assert_eq!(state.created_by(&p, &altered), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Keep behavior field state within one editing session and reset it on replacement.
|
||||
use iced::advanced::widget::{Operation, Tree, tree};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
|
||||
use oa_core::Id;
|
||||
|
||||
struct Identity(Id);
|
||||
|
||||
struct Session<'a, Message, R: renderer::Renderer> {
|
||||
identity: Id,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
}
|
||||
|
||||
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
identity: Id,
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Session {
|
||||
identity,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Identity>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Identity(self.identity))
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let retained = tree.state.downcast_mut::<Identity>();
|
||||
if retained.0 != self.identity {
|
||||
retained.0 = self.identity;
|
||||
// Iced's keyed column preserves same-length child replacements.
|
||||
// An editing-session replacement must instead get fresh field state.
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -94,10 +95,13 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
First(Context, Tool),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
FormShown(Context, Id),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
@@ -132,10 +136,13 @@ pub enum ScrollArea {
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(context, _, _)
|
||||
Self::First(context, _)
|
||||
| Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
@@ -162,6 +169,7 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -197,6 +205,8 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_focus_pending: bool,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
@@ -240,6 +250,13 @@ struct CallChoiceContext {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
@@ -272,6 +289,40 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_tool,
|
||||
self.presentation.focused,
|
||||
self.presentation.shelf,
|
||||
self.presentation.viewport,
|
||||
self.presentation.pan,
|
||||
self.presentation.zoom,
|
||||
self.presentation.fit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
@@ -286,9 +337,31 @@ impl State {
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
let p = &mut self.presentation;
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
if let Some(next) = zoom {
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
@@ -356,7 +429,7 @@ impl State {
|
||||
owner: frame.owner,
|
||||
token: Id::new_v4(),
|
||||
offsets: frame.offsets,
|
||||
pending: [true; 2],
|
||||
pending: [false, true],
|
||||
});
|
||||
}
|
||||
pub(super) fn scroll_restore(
|
||||
@@ -415,10 +488,25 @@ impl State {
|
||||
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
draft: Some(draft),
|
||||
form_session: Id::new_v4(),
|
||||
form_focus_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn form_session(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|_| self.form_session)
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, session: Id) -> bool {
|
||||
if self.form_session() == Some(session) && self.form_focus_pending {
|
||||
self.form_focus_pending = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
||||
return false;
|
||||
@@ -431,6 +519,7 @@ impl State {
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
// The former page scroll now belongs to a sibling outline shelf.
|
||||
self.scroll_offsets[0]
|
||||
@@ -452,8 +541,16 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
match command {
|
||||
Command::CreateElement(element) if element.parent == Some(parent) => {
|
||||
@@ -568,6 +665,61 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Lifeline {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: d.owner,
|
||||
viewpoint: viewpoint(p, d.owner),
|
||||
},
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -576,7 +728,12 @@ impl State {
|
||||
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.form_session = Id::new_v4();
|
||||
self.form_focus_pending = true;
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -940,7 +1097,14 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
||||
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
||||
}
|
||||
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let layer = p.elements[&d.owner].layer();
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1256,6 +1420,28 @@ impl Draft {
|
||||
e
|
||||
}
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: self.owner,
|
||||
viewpoint: viewpoint(p, self.owner),
|
||||
},
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
||||
}
|
||||
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
||||
return Err("Choose this scenario or one of its operands as destination.".into());
|
||||
}
|
||||
@@ -1266,6 +1452,8 @@ impl Draft {
|
||||
.unwrap_or(self.tool.label());
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
//! A prerequisite belongs to its pending consumer, never the accepted catalog.
|
||||
use iced::{
|
||||
Element as UiElement, Fill,
|
||||
widget::{button, column, container, row, scrollable, text, text_input},
|
||||
};
|
||||
use oa_core::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const NAME_INPUT: &str = "behavior-source-name";
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Scope {
|
||||
Lifeline {
|
||||
owner: Id,
|
||||
viewpoint: ScenarioViewpoint,
|
||||
},
|
||||
Work {
|
||||
owner: Id,
|
||||
},
|
||||
Machine {
|
||||
layer: Layer,
|
||||
},
|
||||
}
|
||||
impl Scope {
|
||||
fn owner(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Lifeline { owner, .. } | Self::Work { owner } => Some(owner),
|
||||
Self::Machine { .. } => None,
|
||||
}
|
||||
}
|
||||
fn layer(self, p: &Project) -> Result<Layer, String> {
|
||||
match self {
|
||||
Self::Machine { layer } => Ok(layer),
|
||||
_ => p
|
||||
.layer_of(self.owner().unwrap())
|
||||
.ok_or("The consumer owner no longer exists.".into()),
|
||||
}
|
||||
}
|
||||
fn kinds(self, layer: Layer) -> Vec<Kind> {
|
||||
match self {
|
||||
Self::Work { .. } => vec![if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}],
|
||||
Self::Lifeline {
|
||||
viewpoint: ScenarioViewpoint::OperationalActivity,
|
||||
..
|
||||
} => vec![Kind::Activity],
|
||||
Self::Lifeline {
|
||||
viewpoint: ScenarioViewpoint::Functional,
|
||||
..
|
||||
} => vec![Kind::Function],
|
||||
Self::Lifeline {
|
||||
viewpoint: ScenarioViewpoint::ParticipantInteraction,
|
||||
..
|
||||
} => vec![Kind::Entity, Kind::Actor, Kind::Role],
|
||||
Self::Lifeline {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
} => vec![Kind::System, Kind::SystemActor],
|
||||
Self::Machine { .. } if layer == Layer::Oa => vec![Kind::Entity, Kind::Actor],
|
||||
Self::Machine { .. } => vec![Kind::System, Kind::SystemActor],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Open(Id),
|
||||
Edit(Id, Edit),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Edit {
|
||||
Name(String),
|
||||
Kind(Kind),
|
||||
Parent(Option<Id>),
|
||||
Search(String),
|
||||
Page(usize),
|
||||
Back,
|
||||
Use,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
token: Id,
|
||||
document: Id,
|
||||
scope: Scope,
|
||||
layer: Layer,
|
||||
owner: Option<Element>,
|
||||
id: Id,
|
||||
kind: Option<Kind>,
|
||||
name: String,
|
||||
parent: Option<Element>,
|
||||
search: String,
|
||||
page: usize,
|
||||
identities: Vec<Id>,
|
||||
}
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct State {
|
||||
editing: Option<Draft>,
|
||||
staged: Option<Draft>,
|
||||
#[serde(default)]
|
||||
engaged: bool,
|
||||
}
|
||||
impl State {
|
||||
pub fn check_scope(&self, expected: Scope, selected: Option<Id>) -> Result<(), String> {
|
||||
if self
|
||||
.editing
|
||||
.as_ref()
|
||||
.or_else(|| self.staged.as_ref().filter(|d| Some(d.id) == selected))
|
||||
.is_some_and(|d| d.scope != expected)
|
||||
{
|
||||
return Err("The recovered source request belongs to a different consumer context. Go Back or cancel this draft.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn dismiss(&mut self, event: &Event) -> Result<bool, String> {
|
||||
if let Event::Edit(token, Edit::Back) = event {
|
||||
if self.editing.as_ref().is_none_or(|d| d.token != *token) {
|
||||
return Err("This Back action belongs to a previous source form.".into());
|
||||
}
|
||||
self.editing = None;
|
||||
self.engaged = true;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn engaged(&self) -> bool {
|
||||
self.engaged || self.editing.is_some() || self.staged.is_some()
|
||||
}
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.editing.is_some()
|
||||
}
|
||||
pub fn back(&mut self) -> bool {
|
||||
self.editing.take().is_some()
|
||||
}
|
||||
pub fn label(&self, selected: Option<Id>) -> Option<String> {
|
||||
self.staged
|
||||
.as_ref()
|
||||
.filter(|d| Some(d.id) == selected)
|
||||
.map(|d| {
|
||||
format!(
|
||||
"New · {} · {}",
|
||||
d.kind.map_or("Choose kind".into(), |k| k.to_string()),
|
||||
d.name
|
||||
)
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
scope: Scope,
|
||||
invocation: Id,
|
||||
event: Event,
|
||||
) -> Result<Option<Id>, String> {
|
||||
if self.dismiss(&event)? {
|
||||
return Ok(None);
|
||||
}
|
||||
match event {
|
||||
Event::Open(token) => {
|
||||
if token != invocation || self.is_open() {
|
||||
return Err("This source request is stale. Use the current form.".into());
|
||||
}
|
||||
let layer = scope.layer(p)?;
|
||||
let kinds = scope.kinds(layer);
|
||||
if kinds.is_empty() {
|
||||
return Err("Create new sources requires a strict scenario viewpoint; use an existing source here.".into());
|
||||
}
|
||||
self.engaged = true;
|
||||
self.editing = Some(Draft {
|
||||
token: Id::new_v4(),
|
||||
document: p.id,
|
||||
scope,
|
||||
layer,
|
||||
owner: scope.owner().and_then(|id| p.elements.get(&id).cloned()),
|
||||
id: Id::new_v4(),
|
||||
kind: (kinds.len() == 1).then_some(kinds[0]),
|
||||
name: String::new(),
|
||||
parent: None,
|
||||
search: String::new(),
|
||||
page: 0,
|
||||
identities: (0..match scope {
|
||||
Scope::Work { owner } => p.children(owner).count() + 4,
|
||||
Scope::Lifeline { .. } => 1,
|
||||
Scope::Machine { .. } => 0,
|
||||
})
|
||||
.map(|_| Id::new_v4())
|
||||
.collect(),
|
||||
});
|
||||
Ok(None)
|
||||
}
|
||||
Event::Edit(token, edit) => {
|
||||
let d = self
|
||||
.editing
|
||||
.as_mut()
|
||||
.filter(|d| d.token == token && d.scope == scope && d.document == p.id)
|
||||
.ok_or("This source response is stale; return to the current form.")?;
|
||||
match edit {
|
||||
Edit::Back => {
|
||||
self.editing = None;
|
||||
Ok(None)
|
||||
}
|
||||
Edit::Use => {
|
||||
d.command(p)?;
|
||||
let id = d.id;
|
||||
self.staged = self.editing.take();
|
||||
Ok(Some(id))
|
||||
}
|
||||
Edit::Name(name) => {
|
||||
d.name = name;
|
||||
Ok(None)
|
||||
}
|
||||
Edit::Kind(kind) => {
|
||||
if !scope.kinds(d.layer).contains(&kind) {
|
||||
return Err("Choose an explicitly supported source kind.".into());
|
||||
}
|
||||
d.kind = Some(kind);
|
||||
d.parent = None;
|
||||
d.page = 0;
|
||||
Ok(None)
|
||||
}
|
||||
Edit::Parent(parent) => {
|
||||
let kind = d.kind.ok_or("Choose the source kind first.")?;
|
||||
if !validation::can_parent(p, kind, d.layer, parent) {
|
||||
return Err("Choose a valid semantic parent.".into());
|
||||
}
|
||||
d.parent = parent.map(|id| p.elements[&id].clone());
|
||||
Ok(None)
|
||||
}
|
||||
Edit::Search(query) => {
|
||||
d.search = query;
|
||||
d.page = 0;
|
||||
Ok(None)
|
||||
}
|
||||
Edit::Page(page) => {
|
||||
d.page = page;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn preview(&self, p: &Project, selected: Option<Id>) -> Result<Option<Project>, String> {
|
||||
if self.is_open() {
|
||||
return Err("Use this new source or go Back before creating the consumer.".into());
|
||||
}
|
||||
self.staged
|
||||
.as_ref()
|
||||
.filter(|d| Some(d.id) == selected)
|
||||
.map(|d| execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string()))
|
||||
.transpose()
|
||||
}
|
||||
pub fn combine(
|
||||
&self,
|
||||
p: &Project,
|
||||
selected: Option<Id>,
|
||||
command: Command,
|
||||
) -> Result<Command, String> {
|
||||
let Some(d) = self.staged.as_ref().filter(|d| Some(d.id) == selected) else {
|
||||
return Ok(command);
|
||||
};
|
||||
let preview = execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string())?;
|
||||
let mut commands = match command {
|
||||
Command::Batch(commands) => commands,
|
||||
other => vec![other],
|
||||
};
|
||||
let fresh: Vec<_> = commands
|
||||
.iter()
|
||||
.filter(|_| !matches!(d.scope, Scope::Machine { .. }))
|
||||
.filter_map(|c| match c {
|
||||
Command::CreateElement(e) => Some(e.id),
|
||||
Command::CreateRelation(r) => Some(r.id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if fresh.len() > d.identities.len() {
|
||||
return Err(
|
||||
"The path grew beyond this draft. Return and create the source again.".into(),
|
||||
);
|
||||
}
|
||||
let replacements: BTreeMap<_, _> = fresh
|
||||
.into_iter()
|
||||
.zip(d.identities.iter().copied())
|
||||
.collect();
|
||||
if replacements.values().any(|id| preview.contains(*id)) {
|
||||
return Err(
|
||||
"A reserved consumer identity is already used. Start a fresh source draft.".into(),
|
||||
);
|
||||
}
|
||||
let remap = |id: &mut Id| {
|
||||
if let Some(new) = replacements.get(id) {
|
||||
*id = *new;
|
||||
}
|
||||
};
|
||||
for c in &mut commands {
|
||||
match c {
|
||||
Command::CreateElement(e) => {
|
||||
remap(&mut e.id);
|
||||
references::visit_element_refs(e, remap);
|
||||
e.alias = preview.fresh_alias(&e.name, "contribution", e.id);
|
||||
}
|
||||
Command::CreateRelation(r) => {
|
||||
remap(&mut r.id);
|
||||
references::visit_relation_refs(r, remap);
|
||||
r.alias = preview.fresh_alias(&r.name, "path", r.id);
|
||||
}
|
||||
Command::EditRelation(_, r) => references::visit_relation_refs(r, remap),
|
||||
Command::SetSteps(_, steps) => steps.iter_mut().for_each(remap),
|
||||
// Machine commands already carry their reserved identities.
|
||||
Command::CreateDiagram(_) | Command::Layout(_) => {}
|
||||
_ => return Err("Unsupported prerequisite consumer operation.".into()),
|
||||
}
|
||||
}
|
||||
commands.insert(0, d.command(p)?);
|
||||
let command = super::topology::unique_batch(p, commands);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub fn view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
&'a self,
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.editing.as_ref()?;
|
||||
let event = move |edit| Event::Edit(d.token, edit);
|
||||
let mut fields = column![
|
||||
text(format!("New source · {}", d.layer)).size(18),
|
||||
text(match d.scope {
|
||||
Scope::Lifeline { owner, viewpoint } =>
|
||||
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint),
|
||||
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
|
||||
Scope::Machine { .. } => "Owner for the pending state machine".into(),
|
||||
})
|
||||
.size(12),
|
||||
text("The parent form is retained. This source is created only with its consumer.")
|
||||
.size(12)
|
||||
]
|
||||
.spacing(8);
|
||||
for kind in d.scope.kinds(d.layer) {
|
||||
fields = fields.push(
|
||||
button(text(kind.to_string()))
|
||||
.on_press(event(Edit::Kind(kind)))
|
||||
.style(if d.kind == Some(kind) {
|
||||
button::primary
|
||||
} else {
|
||||
button::secondary
|
||||
}),
|
||||
);
|
||||
}
|
||||
fields = fields.push(
|
||||
text_input("Source name (independent of the consumer name)", &d.name)
|
||||
.id(NAME_INPUT)
|
||||
.on_input(move |v| event(Edit::Name(v)))
|
||||
.padding(8),
|
||||
);
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Semantic parent: {}",
|
||||
d.parent
|
||||
.as_ref()
|
||||
.map_or(format!("{} layer root", d.layer), |e| format!(
|
||||
"{} · {} · @{}",
|
||||
e.name,
|
||||
e.kind(),
|
||||
e.alias
|
||||
))
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
if let Some(kind) = d.kind {
|
||||
fields = fields.push(button("Use layer root").on_press_maybe(
|
||||
validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))),
|
||||
));
|
||||
fields = fields.push(
|
||||
text_input("Find a semantic parent (optional)", &d.search)
|
||||
.on_input(move |v| event(Edit::Search(v)))
|
||||
.padding(6),
|
||||
);
|
||||
let query = d.search.to_lowercase();
|
||||
let mut parents: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| validation::can_parent(p, kind, d.layer, Some(e.id)))
|
||||
.map(|e| (e.id, super::creation::reference_label(p, e.id)))
|
||||
.filter(|(_, label)| label.to_lowercase().contains(&query))
|
||||
.collect();
|
||||
parents.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
let page = d.page.min(parents.len().saturating_sub(1) / 6);
|
||||
for (id, label) in parents.iter().skip(page * 6).take(6) {
|
||||
fields = fields.push(
|
||||
button(text(label.clone()).size(12))
|
||||
.on_press(event(Edit::Parent(Some(*id))))
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if parents.len() > 6 {
|
||||
fields = fields.push(
|
||||
row![
|
||||
button("Previous").on_press_maybe(
|
||||
(page > 0).then(|| event(Edit::Page(page.saturating_sub(1))))
|
||||
),
|
||||
text(format!("{} / {}", page + 1, parents.len().div_ceil(6))),
|
||||
button("Next").on_press_maybe(
|
||||
((page + 1) * 6 < parents.len()).then(|| event(Edit::Page(page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
}
|
||||
let ready = d.command(p);
|
||||
if let Err(error) = &ready {
|
||||
fields = fields.push(text(error.clone()).size(12));
|
||||
}
|
||||
Some(
|
||||
container(
|
||||
column![
|
||||
scrollable(fields).id("behavior-source-fields").height(Fill),
|
||||
container(
|
||||
row![
|
||||
button("Back · Esc").on_press(event(Edit::Back)),
|
||||
button("Use this new source")
|
||||
.on_press_maybe(ready.is_ok().then(|| event(Edit::Use)))
|
||||
]
|
||||
.spacing(8)
|
||||
)
|
||||
.id("behavior-source-actions")
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(12)
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id
|
||||
|| self.scope.layer(p)? != self.layer
|
||||
|| self
|
||||
.owner
|
||||
.as_ref()
|
||||
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
|
||||
{
|
||||
return Err(
|
||||
"The source request's document or consumer changed. Go Back and reopen Create new."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let kind = self
|
||||
.kind
|
||||
.filter(|kind| self.scope.kinds(self.layer).contains(kind))
|
||||
.ok_or("Choose the source kind explicitly.")?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("Enter the source name.".into());
|
||||
}
|
||||
if p.contains(self.id) {
|
||||
return Err(
|
||||
"The reserved source identity is already used. Go Back and start again.".into(),
|
||||
);
|
||||
}
|
||||
let parent = self.parent.as_ref().map(|e| e.id);
|
||||
if self
|
||||
.parent
|
||||
.as_ref()
|
||||
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
|
||||
|| !validation::can_parent(p, kind, self.layer, parent)
|
||||
{
|
||||
return Err("The semantic parent changed. Choose its intended parent again.".into());
|
||||
}
|
||||
let e = Element {
|
||||
id: self.id,
|
||||
alias: p.fresh_alias(&self.name, "source", self.id),
|
||||
name: self.name.clone(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: ElementBody::new(kind, self.layer)
|
||||
.ok_or("This source kind is unavailable in this layer.")?,
|
||||
};
|
||||
let command = Command::CreateElement(e);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ impl Tool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool, Option<Id>),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -80,9 +81,10 @@ pub struct State {
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||||
return_to: Vec<direct::ReturnFrame>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
pub(crate) focused: bool,
|
||||
@@ -90,15 +92,22 @@ pub struct State {
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
#[serde(with = "recovery_insertion")]
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
@@ -117,6 +126,40 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.tool,
|
||||
self.focused,
|
||||
self.shelf,
|
||||
self.viewport,
|
||||
self.pan,
|
||||
self.zoom,
|
||||
self.fit,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
@@ -152,6 +195,58 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Work {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Work { owner: d.owner },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -184,6 +279,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -263,10 +361,12 @@ impl State {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn canvas_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
|
||||
let available = self.fit_area(fallback_width);
|
||||
self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height)
|
||||
@@ -508,6 +608,22 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Work { owner: self.owner },
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&self.owner)
|
||||
@@ -615,6 +731,8 @@ impl Draft {
|
||||
});
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default_name
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -867,6 +985,8 @@ struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
camera_pan: iced::Vector,
|
||||
pending_insertion: Option<Insertion>,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
@@ -1199,6 +1319,8 @@ impl<'a> Graph<'a> {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
@@ -1298,6 +1420,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.translate(self.camera_pan);
|
||||
f.scale(self.scale);
|
||||
for pass in PAINT_PASSES {
|
||||
match pass {
|
||||
@@ -1513,7 +1636,10 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.position_in(bounds).is_some_and(|p| {
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
let p = Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
@@ -1587,6 +1713,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: None,
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Event {
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
@@ -608,7 +608,11 @@ mod tests {
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(owner, reference);
|
||||
state.remember(
|
||||
owner,
|
||||
reference,
|
||||
super::super::direct::referenced(&p, owner, reference).unwrap(),
|
||||
);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
|
||||
@@ -60,6 +60,7 @@ pub enum CanvasEvent {
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
@@ -77,6 +78,7 @@ impl CanvasEvent {
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Camera(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
@@ -86,6 +88,19 @@ impl CanvasEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) struct ReturnFrame {
|
||||
identity: Option<(Id, Id)>,
|
||||
owner: Id,
|
||||
occurrence: Id,
|
||||
target: Id,
|
||||
address: Option<oa_core::topology_context::ContextAddress>,
|
||||
pan: iced::Vector,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
viewport: Option<Size>,
|
||||
expanded: BTreeSet<Id>,
|
||||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
||||
self.selected_context = Some(address);
|
||||
@@ -138,6 +153,7 @@ impl State {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.pan = iced::Vector::ZERO;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
@@ -162,11 +178,21 @@ impl State {
|
||||
// Choosing how to edit must retain the user's current view.
|
||||
self.tool = tool;
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool {
|
||||
super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
self.zoom = value.map(|v| v.clamp(0.05, 3.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
|| !size.height.is_finite()
|
||||
|| size.width <= 0.
|
||||
|| size.height <= 0.
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
@@ -188,26 +214,83 @@ impl State {
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) {
|
||||
if self.return_to.len() == 32 {
|
||||
self.return_to.remove(0);
|
||||
}
|
||||
self.return_to.push(ReturnFrame {
|
||||
identity: self.identity,
|
||||
owner,
|
||||
occurrence,
|
||||
target,
|
||||
address: self.selected_context.clone(),
|
||||
pan: self.pan,
|
||||
zoom: self.zoom,
|
||||
fit: self.fit,
|
||||
viewport: self.viewport,
|
||||
expanded: self.expanded.clone(),
|
||||
contextual_expanded: self.contextual_expanded.clone(),
|
||||
});
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence, address)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
self.selected_context = address.filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
while let Some(frame) = self.return_to.pop() {
|
||||
if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) {
|
||||
continue;
|
||||
}
|
||||
let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target)
|
||||
|| if let Some(address) = &frame.address {
|
||||
oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(frame.owner, &address.endpoint)
|
||||
.is_ok_and(|r| r.defining_sequence == frame.target)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if valid && is_contribution(p, frame.owner, frame.occurrence) {
|
||||
self.identity = frame.identity;
|
||||
self.selected_context = frame.address.filter(|a| {
|
||||
oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(frame.owner, &a.endpoint)
|
||||
.is_ok()
|
||||
});
|
||||
return Some((owner, occurrence));
|
||||
self.pan = frame.pan;
|
||||
self.zoom = frame.zoom;
|
||||
self.fit = frame.fit;
|
||||
self.viewport = frame.viewport;
|
||||
self.expanded = frame.expanded;
|
||||
self.contextual_expanded = frame.contextual_expanded;
|
||||
return Some((frame.owner, frame.occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
|
||||
let old = self.scale(graph.size, 500.);
|
||||
if let Some(next) = zoom {
|
||||
let size = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(size.width / 2., size.height / 2.),
|
||||
base: old,
|
||||
});
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.scale(graph.size, 500.);
|
||||
self.zoom(Some(fitted));
|
||||
self.pan = iced::Vector::ZERO;
|
||||
}
|
||||
}
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
@@ -567,9 +650,12 @@ impl Graph<'_> {
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let world = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
@@ -722,6 +808,22 @@ impl Graph<'_> {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if let Some(insertion) = self.pending_insertion {
|
||||
if let Some(edge) = insertion
|
||||
.link
|
||||
.and_then(|id| self.edges.iter().find(|e| e.id == id))
|
||||
{
|
||||
draw_arrow(f, &edge.points, style::TEAL, 3.);
|
||||
} else if let Some(node) = insertion
|
||||
.before
|
||||
.and_then(|id| self.nodes.iter().find(|n| n.id == id))
|
||||
{
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
@@ -730,7 +832,12 @@ impl Graph<'_> {
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
)
|
||||
});
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
@@ -845,12 +952,7 @@ impl Graph<'_> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
@@ -862,10 +964,25 @@ impl State {
|
||||
self.qualified_selection(p, owner)
|
||||
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label));
|
||||
let Some(rect) = rect else {
|
||||
return false;
|
||||
};
|
||||
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
let x = rect.x * scale + self.pan.x;
|
||||
let y = rect.y * scale + self.pan.y;
|
||||
let delta = iced::Vector::new(
|
||||
if x < 12. || x + rect.width * scale > viewport.width - 12. {
|
||||
12. - x
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
if y < 12. || y + rect.height * scale > viewport.height - 12. {
|
||||
12. - y
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
self.camera(super::super::canvas_workspace::Event::Pan(delta))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,7 +732,7 @@ fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
|
||||
state.zoom(None);
|
||||
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
|
||||
state.zoom(Some(0.1));
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1);
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
let mut all = Vec::new();
|
||||
@@ -1364,82 +1364,6 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() {
|
||||
use iced::advanced::widget::{Tree, tree};
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(index, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, index);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree {
|
||||
for &index in path {
|
||||
tree = &mut tree.children[index];
|
||||
}
|
||||
tree
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for size in [Size::new(1440., 900.), Size::new(1100., 700.)] {
|
||||
for tool in [Tool::Control, Tool::Branch, Tool::Connect] {
|
||||
let f = fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.nodes[1]));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = size;
|
||||
let original = app.document.snapshot();
|
||||
let graph: Element<'_, Message> =
|
||||
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||
let canvas_tag = Tree::new(graph.as_widget()).tag;
|
||||
drop(graph);
|
||||
let scroll: Element<'_, Message> = scrollable(text("")).into();
|
||||
let scroll_tag = Tree::new(scroll.as_widget()).tag;
|
||||
let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
let mut scroll_path = path_to(&tree, canvas_tag).unwrap();
|
||||
scroll_path.pop();
|
||||
assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag);
|
||||
// Scrollable::diff treats its local state as opaque. A retained marker
|
||||
// proves the real Iced diff preserves the state that owns the offsets.
|
||||
// No layout or input operation runs on this test-only marker state.
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||
Event::Start(f.owner, tool, Some(f.nodes[1])),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
assert_eq!(
|
||||
Rc::strong_count(&marker),
|
||||
2,
|
||||
"form open: {layer:?} {size:?} {tool:?}"
|
||||
);
|
||||
let mut current_path = path_to(&tree, canvas_tag).unwrap();
|
||||
current_path.pop();
|
||||
assert_eq!(current_path, scroll_path);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &scroll_path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker,
|
||||
));
|
||||
let _ = app.update(Message::Behavior(super::super::Event::Topology(
|
||||
Event::Cancel,
|
||||
)));
|
||||
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
|
||||
assert_eq!(
|
||||
Rc::strong_count(&marker),
|
||||
2,
|
||||
"form cancel: {layer:?} {size:?} {tool:?}"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,107 +66,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
let pending = state.draft.is_some();
|
||||
let ordered = !legacy_order(p, owner).is_empty();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
if !pending && ordered {
|
||||
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
|
||||
CanvasEvent::AppendExchange(context),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
controls = controls.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
}
|
||||
}
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -211,11 +111,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
);
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
controls = controls.push(button("Qualified link / assembly…").on_press(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
)));
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
@@ -256,27 +151,79 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
navigation = navigation
|
||||
.push(
|
||||
button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected,
|
||||
)))),
|
||||
)
|
||||
.push(
|
||||
button("Qualified link / assembly...").on_press_maybe(clean.then_some(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
))),
|
||||
);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
// Explicit graphs no longer have a scrolling page ancestor. A retained
|
||||
// offset from an ordered view must not distort their pointer coordinates.
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
let scale = graph.scale;
|
||||
let mut header = column![
|
||||
row![
|
||||
out.width(Fill),
|
||||
if ordered {
|
||||
navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some(
|
||||
Message::TopologyCanvas(CanvasEvent::AppendExchange(context)),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.camera_pan = state.pan;
|
||||
graph.pending_insertion = state
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner && d.fixed)
|
||||
.map(|d| d.insertion);
|
||||
graph.interaction = Some((context, state.tool, clean));
|
||||
let scale = graph.scale;
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas = container(
|
||||
iced::widget::sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(graph).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(state.tool == CanvasTool::Select),
|
||||
)
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("process-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
let header = column![
|
||||
row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
@@ -285,368 +232,352 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
.on_press(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::TopologyCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
scope,
|
||||
"Panels",
|
||||
keyboard,
|
||||
Some(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::TopologyCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(6);
|
||||
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
|
||||
}
|
||||
if graph.nodes.is_empty() {
|
||||
controls = controls.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let canvas = container(
|
||||
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
|
||||
.spacing(6),
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
))),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
};
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
("S", "Select contributions or paths", CanvasTool::Select),
|
||||
(
|
||||
"W",
|
||||
"Work: click a path or empty space",
|
||||
CanvasTool::Add(Tool::Work, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"E",
|
||||
"Exchange: click a path or empty space",
|
||||
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"ref",
|
||||
"Reuse process / chain",
|
||||
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"OR",
|
||||
"Choice",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Choice),
|
||||
),
|
||||
(
|
||||
"M",
|
||||
"Merge",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Merge),
|
||||
),
|
||||
(
|
||||
"AND",
|
||||
"Fork",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Fork),
|
||||
),
|
||||
(
|
||||
"J",
|
||||
"Join",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Join),
|
||||
),
|
||||
(
|
||||
"IT",
|
||||
"Loop",
|
||||
CanvasTool::Add(Tool::Control, ControlKind::Loop),
|
||||
),
|
||||
(
|
||||
"->",
|
||||
"Sequence link: click or drag between contributions",
|
||||
CanvasTool::Connect,
|
||||
),
|
||||
];
|
||||
let palette = super::super::canvas_workspace::palette(
|
||||
scope,
|
||||
keyboard,
|
||||
camera,
|
||||
entries.into_iter().map(|(glyph, label, choice)| {
|
||||
(
|
||||
glyph,
|
||||
label,
|
||||
choice == state.tool,
|
||||
clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
|
||||
.id("process-details").width(Fill).height(Fill).into();
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Work {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
||||
}
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(d.invocation, actions.into(), form.into());
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
/// A permanent three-slot tree: variable controls never remount the canvas.
|
||||
/// The shelf scrolls independently; the canvas receives actual remaining height.
|
||||
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, iced::Theme, R>,
|
||||
details: iced::Element<'a, Message, iced::Theme, R>,
|
||||
canvas: iced::Element<'a, Message, iced::Theme, R>,
|
||||
show_details: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
|
||||
// slot so its disclosure cannot move/remount the following canvas tree.
|
||||
Column::from_vec(vec![
|
||||
container(header).width(Fill).into(),
|
||||
scrollable(details)
|
||||
.id("process-details")
|
||||
.width(Fill)
|
||||
.height(if show_details {
|
||||
if pending { 64. } else { 112. }
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.into(),
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
/// workspace height. The editor must never be clipped by the page scrollable.
|
||||
fn panes<'a, R: iced::advanced::Renderer + 'a>(
|
||||
diagram: iced::Element<'a, Message, iced::Theme, R>,
|
||||
form: iced::Element<'a, Message, iced::Theme, R>,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
column![
|
||||
container(diagram).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
Fill
|
||||
}),
|
||||
container(form).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
iced::Length::Shrink
|
||||
}),
|
||||
]
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
state.shelf.map(|_| details)
|
||||
};
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
session: Id,
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
fields: iced::Element<'a, Message, iced::Theme, R>,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
container(
|
||||
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
container(
|
||||
column![
|
||||
container(actions).id("process-actions"),
|
||||
super::super::form_session::reset_on_change(
|
||||
session,
|
||||
scrollable(fields).id("process-form").height(Fill)
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
|
||||
@@ -18,6 +18,7 @@ mod context_fields;
|
||||
mod data_fields;
|
||||
mod history_fields;
|
||||
mod port_fields;
|
||||
pub(crate) mod reference_chooser;
|
||||
mod view_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -85,6 +86,8 @@ pub struct Draft {
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
history: history_fields::State,
|
||||
reference_session: Id,
|
||||
reference_generation: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -209,11 +212,14 @@ impl Draft {
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
history: Default::default(),
|
||||
reference_session: Id::new_v4(),
|
||||
reference_generation: Id::new_v4(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
self.reference_generation = Id::new_v4();
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
@@ -274,6 +280,9 @@ impl Draft {
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
if self.context != context {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
@@ -359,6 +368,9 @@ impl Draft {
|
||||
}
|
||||
pub fn input(&mut self, path: &str, value: String) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
if input.raw != value {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.editor = text_editor::Content::with_text(&value);
|
||||
input.raw = value;
|
||||
}
|
||||
@@ -366,7 +378,11 @@ impl Draft {
|
||||
pub fn edit_text(&mut self, path: &str, action: text_editor::Action) {
|
||||
if let Some(input) = self.inputs.get_mut(path) {
|
||||
input.editor.perform(action);
|
||||
input.raw = input.editor.text();
|
||||
let raw = input.editor.text();
|
||||
if input.raw != raw {
|
||||
self.reference_generation = Id::new_v4();
|
||||
}
|
||||
input.raw = raw;
|
||||
}
|
||||
}
|
||||
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
@@ -1795,7 +1811,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
));
|
||||
}
|
||||
}
|
||||
content.into()
|
||||
reference_chooser::trigger::scope(content, draft.reference_session)
|
||||
}
|
||||
|
||||
/// Render one closed property subtree for a workflow-owned typed draft.
|
||||
@@ -1937,41 +1953,12 @@ fn property<'a>(
|
||||
return out.into();
|
||||
}
|
||||
if reference(path) && (value.is_string() || value.is_null()) {
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| value.as_str());
|
||||
let current = raw.and_then(|s| s.parse::<Id>().ok()).unwrap_or(Id::nil());
|
||||
let choices = reference_choices(draft, project, path, current);
|
||||
let selected = choices.iter().find(|c| c.id == current).cloned();
|
||||
let owned_path = path.to_string();
|
||||
let optional = optional_reference(path);
|
||||
let mut content = column![
|
||||
return column![
|
||||
label_view(),
|
||||
pick_list(choices, selected, move |choice| Message::FieldShape(
|
||||
owned_path.clone(),
|
||||
if choice.id.is_nil() && optional {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(choice.id)
|
||||
}
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
reference_chooser::adapter::field(draft, project, path)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(raw) = raw
|
||||
&& raw.parse::<Id>().is_err()
|
||||
{
|
||||
content = content.push(
|
||||
text(format!("Invalid reference retained in the draft: {raw}"))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
return content.into();
|
||||
.spacing(5)
|
||||
.into();
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
|
||||
@@ -579,11 +579,11 @@ fn render<'a>(
|
||||
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
let item_path = format!("{path}/{index}");
|
||||
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
|
||||
if path == FACETS {
|
||||
if let Some(current) = current_observation(draft, project, &item_path) {
|
||||
card = card.push(button("Use current counterpart observations")
|
||||
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
|
||||
}
|
||||
if path == FACETS
|
||||
&& let Some(current) = current_observation(draft, project, &item_path)
|
||||
{
|
||||
card = card.push(button("Use current counterpart observations")
|
||||
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
|
||||
}
|
||||
card = card.push(row![
|
||||
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Isolated live-reference chooser experiment, declared only under cfg(test).
|
||||
//! The host supplies an eligible catalog and revalidates every
|
||||
//! Write intent against the current document before changing a staged scalar.
|
||||
//! No core command, draft serialization, or App message belongs in this module.
|
||||
use oa_core::Id;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub mod adapter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod trigger;
|
||||
pub mod widget;
|
||||
|
||||
pub const MAX_ROWS: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub draft: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub optional: bool,
|
||||
}
|
||||
impl Context {
|
||||
/// This is an additional historical-path guard, not live schema discovery.
|
||||
/// Call only after the parent form has identified a live reference field.
|
||||
pub fn live(
|
||||
document: Id,
|
||||
revision: u64,
|
||||
draft: Id,
|
||||
record: Id,
|
||||
path: impl Into<String>,
|
||||
optional: bool,
|
||||
) -> Result<Self, &'static str> {
|
||||
let path = path.into();
|
||||
if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") {
|
||||
return Err("The chooser requires a live reference property.");
|
||||
}
|
||||
Ok(Self {
|
||||
document,
|
||||
revision,
|
||||
draft,
|
||||
record,
|
||||
path,
|
||||
optional,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// For a diagram placement this is the placement ID, not its element ID.
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub alias: String,
|
||||
pub kind: String,
|
||||
pub scope: String,
|
||||
pub containment: String,
|
||||
}
|
||||
impl Candidate {
|
||||
pub fn context_label(&self) -> String {
|
||||
format!("{} · {} · {}", self.scope, self.kind, self.containment)
|
||||
}
|
||||
pub fn detail(&self) -> String {
|
||||
format!(
|
||||
"{} · @{} · {} · {}",
|
||||
self.name,
|
||||
self.alias,
|
||||
self.context_label(),
|
||||
self.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Current {
|
||||
Unassigned,
|
||||
Valid(Candidate),
|
||||
Incompatible(Candidate),
|
||||
Missing(Id),
|
||||
InvalidRaw(String),
|
||||
}
|
||||
impl Current {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Unassigned => "Not assigned".into(),
|
||||
Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias),
|
||||
Self::Incompatible(c) => {
|
||||
format!("Current choice needs review: {} · @{}", c.name, c.alias)
|
||||
}
|
||||
Self::Missing(id) => format!("Missing reference: {id}"),
|
||||
Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Searchable {
|
||||
candidate: Candidate,
|
||||
all: String,
|
||||
alias: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub invocation: Id,
|
||||
pub results: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Close {
|
||||
Escape,
|
||||
Tab { backwards: bool },
|
||||
Outside,
|
||||
WindowChanged,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The mounted text input numbers emitted edits. Multiple edits in one
|
||||
/// runtime batch may share the old result stamp; none may be dropped.
|
||||
Query {
|
||||
text: String,
|
||||
serial: u64,
|
||||
},
|
||||
Move {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Page {
|
||||
delta: i32,
|
||||
rows: usize,
|
||||
},
|
||||
Highlight(Id),
|
||||
Choose(Id),
|
||||
Clear,
|
||||
Close(Close),
|
||||
}
|
||||
impl Action {
|
||||
pub fn writes_staged_value(&self) -> bool {
|
||||
matches!(self, Self::Choose(_) | Self::Clear)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Event {
|
||||
pub stamp: Stamp,
|
||||
pub action: Action,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Ignored,
|
||||
Browsed,
|
||||
Closed(Close),
|
||||
/// The host must still check current eligibility and scoped draft guards.
|
||||
Write {
|
||||
context: Context,
|
||||
value: Option<Id>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
context: Context,
|
||||
invocation: Id,
|
||||
results: u64,
|
||||
serial: u64,
|
||||
open: bool,
|
||||
candidates: Vec<Searchable>,
|
||||
filtered: Vec<usize>,
|
||||
query: String,
|
||||
start: usize,
|
||||
highlight: Option<usize>,
|
||||
pub current: Current,
|
||||
pub scope_label: String,
|
||||
}
|
||||
impl State {
|
||||
pub fn open(
|
||||
context: Context,
|
||||
candidates: Vec<Candidate>,
|
||||
current: Current,
|
||||
scope_label: impl Into<String>,
|
||||
) -> Result<Self, &'static str> {
|
||||
// Context fields are public for host comparisons; check again in case
|
||||
// a caller constructed one without the live-path constructor.
|
||||
if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") {
|
||||
return Err("Historical values are not live reference candidates.");
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut indexed = Vec::with_capacity(candidates.len());
|
||||
for candidate in candidates {
|
||||
if candidate.id.is_nil() || !seen.insert(candidate.id) {
|
||||
return Err("Eligible references must have distinct, non-nil identities.");
|
||||
}
|
||||
indexed.push(Searchable {
|
||||
all: candidate.detail().to_lowercase(),
|
||||
alias: candidate.alias.to_lowercase(),
|
||||
name: candidate.name.to_lowercase(),
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
indexed.sort_by(|a, b| {
|
||||
(&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id))
|
||||
});
|
||||
let filtered: Vec<_> = (0..indexed.len()).collect();
|
||||
let highlight = (!filtered.is_empty()).then_some(0);
|
||||
Ok(Self {
|
||||
context,
|
||||
invocation: Id::new_v4(),
|
||||
results: 0,
|
||||
serial: 0,
|
||||
open: true,
|
||||
candidates: indexed,
|
||||
filtered,
|
||||
query: String::new(),
|
||||
start: 0,
|
||||
highlight,
|
||||
current,
|
||||
scope_label: scope_label.into(),
|
||||
})
|
||||
}
|
||||
pub fn stamp(&self) -> Stamp {
|
||||
Stamp {
|
||||
context: self.context.clone(),
|
||||
invocation: self.invocation,
|
||||
results: self.results,
|
||||
}
|
||||
}
|
||||
pub fn event(&self, action: Action) -> Event {
|
||||
Event {
|
||||
stamp: self.stamp(),
|
||||
action,
|
||||
}
|
||||
}
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
pub fn query_serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
pub fn count(&self) -> usize {
|
||||
self.filtered.len()
|
||||
}
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
pub fn optional(&self) -> bool {
|
||||
self.context.optional
|
||||
}
|
||||
pub fn highlighted(&self) -> Option<&Candidate> {
|
||||
self.highlight.and_then(|i| self.at(i))
|
||||
}
|
||||
fn at(&self, index: usize) -> Option<&Candidate> {
|
||||
self.filtered
|
||||
.get(index)
|
||||
.map(|i| &self.candidates[*i].candidate)
|
||||
}
|
||||
pub fn visible(&self, rows: usize) -> impl Iterator<Item = &Candidate> {
|
||||
self.filtered
|
||||
.iter()
|
||||
.skip(self.start)
|
||||
.take(rows.min(MAX_ROWS))
|
||||
.map(|index| &self.candidates[*index].candidate)
|
||||
}
|
||||
/// The host calls this before rendering on any path/document/facet/context
|
||||
/// replacement. Existing events remain tied to the old invocation.
|
||||
pub fn invalidate(&mut self) {
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event) -> Outcome {
|
||||
if !self.open
|
||||
|| event.stamp.context != self.context
|
||||
|| event.stamp.invocation != self.invocation
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
// Absolute row actions require exactly the presented result set.
|
||||
// Relative navigation and dismissal are processed in input order within
|
||||
// this session: typing must not make a following Escape stale.
|
||||
if matches!(event.action, Action::Choose(_) | Action::Highlight(_))
|
||||
&& event.stamp.results != self.results
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
match event.action {
|
||||
Action::Query { text, serial } => {
|
||||
if serial <= self.serial {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.serial = serial;
|
||||
self.query = text;
|
||||
let query = self.query.to_lowercase();
|
||||
let terms: Vec<_> = query.split_whitespace().collect();
|
||||
self.filtered = self
|
||||
.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
terms
|
||||
.iter()
|
||||
.all(|term| match term.strip_prefix('@') {
|
||||
Some(alias) => c.alias.contains(alias),
|
||||
None => c.all.contains(term),
|
||||
})
|
||||
.then_some(i)
|
||||
})
|
||||
.collect();
|
||||
self.start = 0;
|
||||
self.highlight = (!self.filtered.is_empty()).then_some(0);
|
||||
}
|
||||
Action::Page { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let relative = self
|
||||
.highlight
|
||||
.unwrap_or(self.start)
|
||||
.saturating_sub(self.start)
|
||||
.min(rows - 1);
|
||||
let last_page = (self.filtered.len() - 1) / rows * rows;
|
||||
self.start = (self.start as i64 + i64::from(delta) * rows as i64)
|
||||
.clamp(0, last_page as i64) as usize;
|
||||
self.highlight = Some((self.start + relative).min(self.filtered.len() - 1));
|
||||
}
|
||||
Action::Move { delta, rows } => {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
if self.filtered.is_empty() {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
let current = self.highlight.unwrap_or(self.start) as i64;
|
||||
let next =
|
||||
(current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize;
|
||||
self.highlight = Some(next);
|
||||
if next < self.start {
|
||||
self.start = next;
|
||||
}
|
||||
if next >= self.start + rows {
|
||||
self.start = next + 1 - rows;
|
||||
}
|
||||
}
|
||||
Action::Highlight(id) => {
|
||||
let Some(index) = self
|
||||
.filtered
|
||||
.iter()
|
||||
.position(|i| self.candidates[*i].candidate.id == id)
|
||||
else {
|
||||
return Outcome::Ignored;
|
||||
};
|
||||
if self.highlight == Some(index) {
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.highlight = Some(index);
|
||||
}
|
||||
Action::Choose(id) => {
|
||||
if !self
|
||||
.filtered
|
||||
.iter()
|
||||
.any(|i| self.candidates[*i].candidate.id == id)
|
||||
{
|
||||
return Outcome::Ignored;
|
||||
}
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: Some(id),
|
||||
};
|
||||
}
|
||||
Action::Clear if self.context.optional => {
|
||||
self.open = false;
|
||||
return Outcome::Write {
|
||||
context: self.context.clone(),
|
||||
value: None,
|
||||
};
|
||||
}
|
||||
Action::Clear => return Outcome::Ignored,
|
||||
Action::Close(reason) => {
|
||||
self.open = false;
|
||||
return Outcome::Closed(reason);
|
||||
}
|
||||
}
|
||||
self.results = self.results.wrapping_add(1);
|
||||
Outcome::Browsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Live form adapter. Compatibility stays in the existing parent adapter;
|
||||
//! browser metadata and scoped writes never reinterpret historical UUID text.
|
||||
use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids};
|
||||
use super::{Candidate, Context, Current, State, trigger};
|
||||
use crate::{app::Message, style};
|
||||
use iced::{
|
||||
Element, Fill, Rectangle,
|
||||
widget::{column, container, row, text, tooltip},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Open {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub generation: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub anchor: Rectangle,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Set {
|
||||
pub context: Context,
|
||||
pub generation: Id,
|
||||
pub value: Option<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub fn reference_session(&self) -> Id {
|
||||
self.reference_session
|
||||
}
|
||||
pub fn reference_generation(&self) -> Id {
|
||||
self.reference_generation
|
||||
}
|
||||
pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open {
|
||||
Open {
|
||||
document: project.id,
|
||||
session: self.reference_session,
|
||||
generation: self.reference_generation,
|
||||
record: self.original,
|
||||
path: path.into(),
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
pub fn reference_matches(&self, request: &Open) -> bool {
|
||||
self.reference_session == request.session
|
||||
&& self.reference_generation == request.generation
|
||||
&& self.original == request.record
|
||||
&& self.live_reference(&request.path)
|
||||
}
|
||||
fn live_reference(&self, path: &str) -> bool {
|
||||
!history_fields::is_path(path)
|
||||
&& reference(path)
|
||||
&& self
|
||||
.value
|
||||
.pointer(path)
|
||||
.is_some_and(|v| v.is_string() || v.is_null())
|
||||
}
|
||||
pub fn reference_catalog(
|
||||
&self,
|
||||
project: &Project,
|
||||
request: &Open,
|
||||
revision: u64,
|
||||
) -> Result<State, String> {
|
||||
if request.document != project.id || !self.reference_matches(request) {
|
||||
return Err("The reference field changed. Open its chooser again.".into());
|
||||
}
|
||||
let allowed: BTreeSet<_> = reference_ids(self, project, &request.path)
|
||||
.into_iter()
|
||||
.filter(|id| !id.is_nil())
|
||||
.collect();
|
||||
let candidates = allowed
|
||||
.iter()
|
||||
.filter_map(|id| candidate(project, *id))
|
||||
.collect();
|
||||
let raw = self
|
||||
.inputs
|
||||
.get(&request.path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| self.value.pointer(&request.path).and_then(Value::as_str));
|
||||
let current = match raw {
|
||||
None => Current::Unassigned,
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => Current::Unassigned,
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) if allowed.contains(&id) => Current::Valid(c),
|
||||
Some(c) => Current::Incompatible(c),
|
||||
None => Current::Missing(id),
|
||||
},
|
||||
Err(_) => Current::InvalidRaw(raw.into()),
|
||||
},
|
||||
};
|
||||
let context = Context::live(
|
||||
project.id,
|
||||
revision,
|
||||
self.reference_session,
|
||||
self.original,
|
||||
&request.path,
|
||||
optional_reference(&request.path),
|
||||
)
|
||||
.map_err(str::to_owned)?;
|
||||
State::open(
|
||||
context,
|
||||
candidates,
|
||||
current,
|
||||
format!(
|
||||
"{} · compatible references",
|
||||
friendly(request.path.rsplit('/').next().unwrap_or("Reference"))
|
||||
),
|
||||
)
|
||||
.map_err(str::to_owned)
|
||||
}
|
||||
pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> {
|
||||
let value = self.reference_value(project, intent)?;
|
||||
self.shape_scoped(&intent.context.path, value)
|
||||
}
|
||||
pub fn reference_value(&self, project: &Project, intent: &Set) -> Result<Value, String> {
|
||||
let context = &intent.context;
|
||||
if context.document != project.id
|
||||
|| context.draft != self.reference_session
|
||||
|| context.record != self.original
|
||||
|| intent.generation != self.reference_generation
|
||||
|| !self.live_reference(&context.path)
|
||||
|| context.optional != optional_reference(&context.path)
|
||||
{
|
||||
return Err("The originating reference field is no longer current.".into());
|
||||
}
|
||||
let value = match intent.value {
|
||||
Some(id)
|
||||
if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) =>
|
||||
{
|
||||
json!(id)
|
||||
}
|
||||
None if context.optional => Value::Null,
|
||||
_ => return Err("Choose a currently compatible reference.".into()),
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id) -> String {
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
format!("{} · @{}", e.name, e.alias)
|
||||
} else {
|
||||
project.named(id).into()
|
||||
}
|
||||
}
|
||||
fn ancestry(project: &Project, id: Id) -> String {
|
||||
let mut parts = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
parts.push("[containment cycle]".into());
|
||||
break;
|
||||
}
|
||||
parts.push(named(project, id));
|
||||
current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
parts.reverse();
|
||||
if parts.is_empty() {
|
||||
project.name.clone()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
pub(crate) fn candidate(project: &Project, id: Id) -> Option<Candidate> {
|
||||
let layer = |layer| format!("{layer:?}").to_uppercase();
|
||||
if id == project.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.name.clone(),
|
||||
alias: project.alias.clone(),
|
||||
kind: "Project".into(),
|
||||
scope: "Model".into(),
|
||||
containment: "Model root".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: e.name.clone(),
|
||||
alias: e.alias.clone(),
|
||||
kind: e.kind().to_string(),
|
||||
scope: layer(e.layer()),
|
||||
containment: ancestry(project, id),
|
||||
});
|
||||
}
|
||||
if let Some(r) = project.relations.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: r.name.clone(),
|
||||
alias: r.alias.clone(),
|
||||
kind: r.kind().to_string(),
|
||||
scope: project.layer_of(id).map_or_else(|| "Model".into(), layer),
|
||||
containment: format!(
|
||||
"{} → {}",
|
||||
named(project, r.source()),
|
||||
named(project, r.target())
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagram in project.diagrams.values() {
|
||||
if id == diagram.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: diagram.name.clone(),
|
||||
alias: diagram.alias.clone(),
|
||||
kind: format!("{:?} diagram", diagram.projection),
|
||||
scope: layer(diagram.layer),
|
||||
containment: project.name.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(node) = diagram.nodes.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.named(node.element).into(),
|
||||
alias: node.alias.clone(),
|
||||
kind: "Placement".into(),
|
||||
scope: layer(diagram.layer),
|
||||
containment: format!(
|
||||
"{} · @{} / {}",
|
||||
diagram.name,
|
||||
diagram.alias,
|
||||
named(project, node.element)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(link) = project.artifact_links.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: link.name.clone(),
|
||||
alias: link.alias.clone(),
|
||||
kind: "Artifact link".into(),
|
||||
scope: project
|
||||
.layer_of(link.owner)
|
||||
.map_or_else(|| "Model".into(), layer),
|
||||
containment: named(project, link.owner),
|
||||
});
|
||||
}
|
||||
project.traces.get(&id).map(|trace| Candidate {
|
||||
id,
|
||||
name: trace.name.clone(),
|
||||
alias: trace.alias.clone(),
|
||||
kind: "Trace".into(),
|
||||
scope: "OA ↔ SA".into(),
|
||||
containment: format!(
|
||||
"{} ↔ {}",
|
||||
named(project, trace.oa),
|
||||
named(project, trace.sa)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field<'a, R: iced::advanced::text::Renderer<Font = iced::Font> + 'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| draft.value.pointer(path).and_then(Value::as_str));
|
||||
let (label, detail) = match raw {
|
||||
None => (
|
||||
if optional_reference(path) {
|
||||
"Not assigned"
|
||||
} else {
|
||||
"Choose a reference…"
|
||||
}
|
||||
.into(),
|
||||
String::new(),
|
||||
),
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()),
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()),
|
||||
None => ("Missing reference".into(), raw.into()),
|
||||
},
|
||||
Err(_) => ("Invalid reference retained".into(), raw.into()),
|
||||
},
|
||||
};
|
||||
let label = crate::views::short(&label, 35);
|
||||
let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6))
|
||||
.padding(8)
|
||||
.width(Fill);
|
||||
let path = path.to_owned();
|
||||
let id = trigger::field_id(draft.reference_generation, &path);
|
||||
let field = trigger::Field::new(body, id, move |anchor| {
|
||||
Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor))
|
||||
});
|
||||
if detail.is_empty() {
|
||||
field.into()
|
||||
} else {
|
||||
tooltip(
|
||||
field,
|
||||
container(column![text(detail).size(11)])
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{Layout, Shell, layout, widget::Tree},
|
||||
keyboard, mouse,
|
||||
widget::{Space, mouse_area},
|
||||
};
|
||||
|
||||
fn context(optional: bool) -> Context {
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
7,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
"/body/SequenceUse/sequence",
|
||||
optional,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn candidate(index: u128) -> Candidate {
|
||||
Candidate {
|
||||
id: Id::from_u128(10_000 + index),
|
||||
name: "Control".into(),
|
||||
alias: format!("controller_{index:04}"),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: format!("System / Subsystem {} / Controller", index / 100),
|
||||
}
|
||||
}
|
||||
fn thousand(current: Current, optional: bool) -> State {
|
||||
// Reverse the fixture input to falsify accidental dependence on ingestion
|
||||
// order. Expected IDs and aliases below are literal fixture identities.
|
||||
State::open(
|
||||
context(optional),
|
||||
(0..1_000).rev().map(candidate).collect(),
|
||||
current,
|
||||
"SA · compatible functions",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Chooser(Event),
|
||||
UnderPress,
|
||||
UnderRelease,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard(Option<String>);
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
self.0.clone()
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
|
||||
self.0 = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
anchor: Rectangle,
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(1100., 700.),
|
||||
anchor: Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
clipboard: Clipboard::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn batch(
|
||||
&mut self,
|
||||
state: Option<&State>,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Option<layout::Node>) {
|
||||
let underneath: Element<'_, Message, Theme, ()> =
|
||||
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
|
||||
.on_press(Message::UnderPress)
|
||||
.on_release(Message::UnderRelease)
|
||||
.into();
|
||||
let mut view: Element<'_, Message, Theme, ()> =
|
||||
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (event, point) in events {
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
// Reconstruct the overlay between layout and every input, as
|
||||
// Iced does. Geometry must live in the measured layout tree.
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
event,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
overlay.as_overlay().draw(
|
||||
&mut (),
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: iced::Color::BLACK,
|
||||
},
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
|
||||
) {
|
||||
assert!(shell.is_event_captured(), "An open chooser owns input");
|
||||
}
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
(messages, overlay_node)
|
||||
}
|
||||
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &[(event, None)]);
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Popup leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &typing(text));
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Typing leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn geometry(&mut self, state: &State) -> widget::Geometry {
|
||||
let (_, node) = self.batch(Some(state), &[]);
|
||||
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
|
||||
}
|
||||
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
Some(state),
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
}
|
||||
|
||||
fn named(key: keyboard::key::Named) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
|
||||
// Whole-string typing is several key events; paste and IME commit are separate
|
||||
// input paths and are exercised below rather than faked as one physical key.
|
||||
fn typed(character: char) -> Input {
|
||||
let text = character.to_string();
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.clone().into()),
|
||||
modified_key: keyboard::Key::Character(text.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
|
||||
text.chars()
|
||||
.map(|character| (typed(character), None))
|
||||
.collect()
|
||||
}
|
||||
fn query(state: &mut State, value: &str) {
|
||||
let event = state.event(Action::Query {
|
||||
text: value.into(),
|
||||
serial: state.query_serial() + 1,
|
||||
});
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
assert_eq!(
|
||||
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
|
||||
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
|
||||
);
|
||||
query(&mut state, "control subsystem 6 @controller_0617");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
|
||||
query(&mut state, "OA");
|
||||
assert_eq!(state.count(), 0);
|
||||
query(&mut state, &Id::from_u128(10_617).to_string());
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
|
||||
|
||||
let placement = Candidate {
|
||||
id: Id::from_u128(20_001),
|
||||
name: "Control".into(),
|
||||
alias: "control_placement".into(),
|
||||
kind: "Placement".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Architecture / Main diagram / Control".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![placement],
|
||||
Current::Unassigned,
|
||||
"SA · placements",
|
||||
)
|
||||
.unwrap();
|
||||
query(&mut state, "placement main");
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
|
||||
let event = state.event(Action::Choose(Id::from_u128(20_000)));
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Ignored,
|
||||
"Semantic ID is not the placement identity"
|
||||
);
|
||||
assert!(
|
||||
State::open(
|
||||
context(false),
|
||||
vec![candidate(1), candidate(1)],
|
||||
Current::Unassigned,
|
||||
"SA"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
|
||||
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
|
||||
for size in [
|
||||
Size::new(1100., 700.),
|
||||
Size::new(1600., 1000.),
|
||||
Size::new(320., 320.),
|
||||
] {
|
||||
for y in [5., size.height / 2., size.height - 34.] {
|
||||
let mut mount = Mount {
|
||||
size,
|
||||
anchor: Rectangle {
|
||||
x: size.width - 300.,
|
||||
y,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (_, node) = mount.batch(Some(&state), &[]);
|
||||
let node = node.unwrap();
|
||||
let bounds = node.children()[0].bounds();
|
||||
let geometry = widget::Geometry::from_bounds(bounds);
|
||||
assert!(bounds.x >= 8. && bounds.y >= 8.);
|
||||
assert!(bounds.x + bounds.width <= size.width - 8.);
|
||||
assert!(bounds.y + bounds.height <= size.height - 8.);
|
||||
assert!(bounds.width <= 420. && bounds.height <= 360.);
|
||||
assert!(geometry.rows > 0 && geometry.rows <= 6);
|
||||
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
|
||||
let search = node.children()[1].bounds();
|
||||
assert!(
|
||||
bounds.contains(search.position())
|
||||
&& bounds.contains(Point::new(
|
||||
search.x + search.width,
|
||||
search.y + search.height
|
||||
))
|
||||
);
|
||||
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
|
||||
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"Actual focused input must publish each edit"
|
||||
);
|
||||
let mut queries = Vec::new();
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Unexpected parent event")
|
||||
};
|
||||
let Action::Query { text, .. } = &event.action else {
|
||||
panic!("Typing must not choose a reference")
|
||||
};
|
||||
queries.push(text.clone());
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(queries, ["a", "ab"]);
|
||||
assert_eq!(state.query(), "ab");
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Home))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
|
||||
assert_eq!(
|
||||
state.query(),
|
||||
"zab",
|
||||
"Rebuilding the controlled widget must not refocus at the end"
|
||||
);
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.start(), 6);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(event.action.writes_staged_value());
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_007))
|
||||
}
|
||||
);
|
||||
assert!(!state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
|
||||
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0731";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(state.count(), 1);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_731))
|
||||
}
|
||||
);
|
||||
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&state).row(4).center();
|
||||
let captured = mount.click(&state, point);
|
||||
let Message::Chooser(old_choice) = captured[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
query(&mut state, "@controller_0700");
|
||||
assert_eq!(state.update(old_choice), Outcome::Ignored);
|
||||
// The rejected old Choose had marked the widget closing; a changed result
|
||||
// context must unlock it so an explicit current click can complete.
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_700))
|
||||
}
|
||||
);
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
query(&mut state, "@controller_0319");
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
|
||||
let source = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&source).row(0).center();
|
||||
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
for changed in 0..6 {
|
||||
let mut destination = context(false);
|
||||
match changed {
|
||||
0 => destination.document = Id::from_u128(999),
|
||||
1 => destination.revision += 1,
|
||||
2 => destination.draft = Id::from_u128(999),
|
||||
3 => destination.record = Id::from_u128(999),
|
||||
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
|
||||
_ => {} // A new invocation even with every semantic ID unchanged.
|
||||
}
|
||||
let mut state = State::open(
|
||||
destination,
|
||||
vec![candidate(0)],
|
||||
Current::InvalidRaw("unfinished".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.update(event.clone()), Outcome::Ignored);
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
let mut invalidated = source.clone();
|
||||
invalidated.invalidate();
|
||||
assert_eq!(invalidated.update(event), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
|
||||
for current in [
|
||||
Current::Missing(Id::from_u128(999)),
|
||||
Current::InvalidRaw("@incomplete/".into()),
|
||||
Current::Incompatible(candidate(999)),
|
||||
] {
|
||||
let mut state = thousand(current.clone(), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "no match exists";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(
|
||||
state.count(),
|
||||
0,
|
||||
"Verify actual input reached the empty-result state before Enter"
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Enter))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
assert_eq!(state.current, current);
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Escape)),
|
||||
[Outcome::Closed(Close::Escape)]
|
||||
);
|
||||
assert_eq!(state.current, current);
|
||||
}
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Tab)),
|
||||
[Outcome::Closed(Close::Tab { backwards: false })]
|
||||
);
|
||||
let mut state = thousand(Current::InvalidRaw("?".into()), true);
|
||||
let point = mount.geometry(&state).clear.center();
|
||||
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(true),
|
||||
value: None
|
||||
}
|
||||
);
|
||||
let mut required = thousand(Current::Unassigned, false);
|
||||
let clear = required.event(Action::Clear);
|
||||
assert_eq!(required.update(clear), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let outside = Point::new(20., 20.);
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside),
|
||||
)],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Expected only popup dismissal")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
|
||||
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
assert!(
|
||||
mount
|
||||
.batch(None, &[(release.clone(), Some(outside))])
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
mount
|
||||
.batch(
|
||||
None,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside)
|
||||
),
|
||||
(release, Some(outside)),
|
||||
]
|
||||
)
|
||||
.0,
|
||||
[Message::UnderPress, Message::UnderRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
|
||||
for path in [
|
||||
"/baseline",
|
||||
"/baseline/oa/value",
|
||||
"/baseline/sa/value/TypeRef/Named",
|
||||
] {
|
||||
assert!(
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
0,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
path,
|
||||
true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut forged = context(true);
|
||||
forged.path = path.into();
|
||||
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
|
||||
}
|
||||
for action in [
|
||||
Action::Query {
|
||||
text: "Control".into(),
|
||||
serial: 1,
|
||||
},
|
||||
Action::Highlight(Id::from_u128(10_000)),
|
||||
Action::Move { delta: 1, rows: 6 },
|
||||
Action::Page { delta: 1, rows: 6 },
|
||||
Action::Close(Close::Escape),
|
||||
] {
|
||||
assert!(!action.writes_staged_value());
|
||||
}
|
||||
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
|
||||
assert!(Action::Clear.writes_staged_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old = state.event(Action::Query {
|
||||
text: "@controller_0001".into(),
|
||||
serial: 1,
|
||||
});
|
||||
let new = state.event(Action::Query {
|
||||
text: "@controller_0999".into(),
|
||||
serial: 2,
|
||||
});
|
||||
assert_eq!(state.update(new), Outcome::Browsed);
|
||||
assert_eq!(state.update(old), Outcome::Ignored);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
|
||||
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0720";
|
||||
let mut inputs = typing(query);
|
||||
inputs.push((named(keyboard::key::Named::Escape), None));
|
||||
let (messages, _) = mount.batch(Some(&state), &inputs);
|
||||
let outcomes: Vec<_> = messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
state.update(event)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = vec![Outcome::Browsed; query.chars().count()];
|
||||
expected.push(Outcome::Closed(Close::Escape));
|
||||
assert_eq!(outcomes, expected);
|
||||
assert_eq!(state.query(), "@controller_0720");
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::ArrowUp), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 3);
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
|
||||
assert_eq!(state.start(), 11);
|
||||
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
mount.clipboard.0 = Some("@controller_0472".into());
|
||||
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character("v".into()),
|
||||
modified_key: keyboard::Key::Character("v".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::COMMAND,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::COMMAND,
|
||||
)),
|
||||
None,
|
||||
),
|
||||
(paste, None),
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::empty(),
|
||||
)),
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Paste should emit exactly one whole-string query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), "@controller_0472");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
assert!(state.is_open());
|
||||
|
||||
let unicode_name = "Режим Δ負荷";
|
||||
let unicode = Candidate {
|
||||
id: Id::from_u128(30_111),
|
||||
name: unicode_name.into(),
|
||||
alias: "mode_unicode".into(),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Система / 控制".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![unicode],
|
||||
Current::InvalidRaw("retained".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
use iced::advanced::input_method::Event as Ime;
|
||||
let preedit = [
|
||||
(Input::InputMethod(Ime::Opened), None),
|
||||
(
|
||||
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
|
||||
None,
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
mount.batch(Some(&state), &preedit).0.is_empty(),
|
||||
"Composition is not a committed query"
|
||||
);
|
||||
assert_eq!(state.query(), "");
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
|
||||
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
|
||||
(Input::InputMethod(Ime::Closed), None),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("IME commit should emit one complete query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), unicode_name);
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
|
||||
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Keyboard entry and focus operations scoped to one property form. Focus
|
||||
//! targets include the draft generation so delayed operations cannot focus a
|
||||
//! replacement array row or a recovered document with the same semantic IDs.
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, operation, tree},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::container,
|
||||
};
|
||||
use oa_core::Id as ModelId;
|
||||
mod focus;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FormScope {
|
||||
pub session: ModelId,
|
||||
pub modal: bool,
|
||||
}
|
||||
pub(super) struct FormOwner(pub Option<FormScope>);
|
||||
const REVIEW_MODAL: &str = "reference-owning-review-modal";
|
||||
|
||||
/// Marks the entire Review dialog without changing its layout or event flow.
|
||||
pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Field {
|
||||
content: content.into(),
|
||||
id: Some(Id::new(REVIEW_MODAL)),
|
||||
on_open: None,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn field_id(generation: ModelId, path: &str) -> Id {
|
||||
Id::new(format!("reference:{generation}:{path}"))
|
||||
}
|
||||
pub fn scope_id(session: ModelId) -> Id {
|
||||
Id::new(format!("reference-form:{session}"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
identity: Option<Id>,
|
||||
anchor: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Interaction {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
}
|
||||
pub struct Field<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
id: Option<Id>,
|
||||
on_open: Option<Box<dyn Fn(Rectangle) -> Message + 'a>>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
id: Id,
|
||||
on_open: impl Fn(Rectangle) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
id: Some(id),
|
||||
on_open: Some(Box::new(on_open)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
session: ModelId,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
container(Field {
|
||||
content: content.into(),
|
||||
id: None,
|
||||
on_open: None,
|
||||
})
|
||||
.id(scope_id(session))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
mut operation: Box<dyn Operation>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
loop {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => {
|
||||
operation = next;
|
||||
changed = true;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn tab<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
scope: FormScope,
|
||||
backwards: bool,
|
||||
) -> bool {
|
||||
run(
|
||||
content,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
Box::new(focus::relative(scope.session, backwards, scope.modal)),
|
||||
)
|
||||
}
|
||||
pub(super) fn own_input<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
) {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut focus::exclusive());
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.identity != self.id {
|
||||
*state = Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if let Some(id) = &self.id
|
||||
&& self.on_open.is_some()
|
||||
{
|
||||
operation.focusable(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
operation.custom(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
}
|
||||
if self.on_open.is_none() {
|
||||
operation.container(self.id.as_ref(), layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.on_open.is_some() {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds));
|
||||
let activate = match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => {
|
||||
state.focused = true;
|
||||
state.pressed = true;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
false
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let activate = state.pressed && hovered;
|
||||
if state.pressed {
|
||||
shell.capture_event();
|
||||
}
|
||||
state.pressed = false;
|
||||
activate
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key:
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space),
|
||||
repeat: false,
|
||||
..
|
||||
}) if state.focused => true,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
state.focused = false;
|
||||
state.pressed = false;
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if activate && let Some(bounds) = state.anchor {
|
||||
shell.publish(self.on_open.as_ref().unwrap()(bounds));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.on_open.is_some() {
|
||||
let focus = tree.state.downcast_ref::<Interaction>().focused;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: if focus || cursor.is_over(layout.bounds()) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
},
|
||||
width: if focus { 2. } else { 1. },
|
||||
radius: 5.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.on_open.is_some() && cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable child updates use content coordinates. The root visits the
|
||||
/// retained tree before each input, so field anchors use current window
|
||||
/// coordinates even when a wheel event and activation share one runtime batch.
|
||||
pub(super) fn measure<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
viewport: Rectangle,
|
||||
) {
|
||||
content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
&mut Measure {
|
||||
translation: Vector::ZERO,
|
||||
viewport: Some(viewport),
|
||||
pending: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
struct Measure {
|
||||
translation: Vector,
|
||||
viewport: Option<Rectangle>,
|
||||
pending: Option<(Vector, Option<Rectangle>)>,
|
||||
}
|
||||
impl Operation for Measure {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.viewport);
|
||||
if let Some((translation, viewport)) = self.pending.take() {
|
||||
self.translation = translation;
|
||||
self.viewport = viewport;
|
||||
}
|
||||
visit(self);
|
||||
(self.translation, self.viewport) = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
self.pending = Some((
|
||||
self.translation + translation,
|
||||
self.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds)),
|
||||
));
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(state) = state.downcast_mut::<Interaction>() {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
state.anchor = self
|
||||
.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Field<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(field: Field<'a, Message, R>) -> Self {
|
||||
Element::new(field)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_operation(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> impl Operation + 'static {
|
||||
focus::exact(session, generation, path, step)
|
||||
}
|
||||
pub fn focus<Message>(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> Task<Message> {
|
||||
iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard()
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Focus movement and reveal share one root traversal. A form may be inside a
|
||||
//! scrollable; restricting traversal to its container would hide that ancestor.
|
||||
use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
enum Origin {
|
||||
Current,
|
||||
Exact(Id),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Field {
|
||||
id: Option<Id>,
|
||||
bounds: Rectangle,
|
||||
ancestors: Vec<Scroll>,
|
||||
}
|
||||
pub(super) struct Collect {
|
||||
scope: Id,
|
||||
origin: Origin,
|
||||
step: i32,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending_scroll: Option<Scroll>,
|
||||
scroll_index: usize,
|
||||
fields: Vec<Field>,
|
||||
focused: Option<usize>,
|
||||
outside_focused: bool,
|
||||
modal: bool,
|
||||
owner_valid: bool,
|
||||
}
|
||||
impl Collect {
|
||||
fn new(scope: Id, origin: Origin, step: i32) -> Self {
|
||||
let owner_valid = matches!(origin, Origin::Current);
|
||||
Self {
|
||||
scope,
|
||||
origin,
|
||||
step,
|
||||
inside: false,
|
||||
entering: None,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending_scroll: None,
|
||||
scroll_index: 0,
|
||||
fields: vec![],
|
||||
focused: None,
|
||||
outside_focused: false,
|
||||
modal: false,
|
||||
owner_valid,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect {
|
||||
Collect {
|
||||
modal,
|
||||
..Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Current,
|
||||
if backwards { -1 } else { 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect {
|
||||
Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Exact(field_id(generation, path)),
|
||||
step,
|
||||
)
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.modal = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.inside, self.translation, self.scrolls.len());
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
if let Some(scroll) = self.pending_scroll.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous.0;
|
||||
self.translation = previous.1;
|
||||
self.scrolls.truncate(previous.2);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending_scroll = Some(Scroll {
|
||||
index: self.scroll_index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if !self.inside {
|
||||
self.outside_focused |= state.is_focused();
|
||||
return;
|
||||
}
|
||||
if state.is_focused() {
|
||||
self.focused = Some(self.fields.len());
|
||||
}
|
||||
self.fields.push(Field {
|
||||
id: id.cloned(),
|
||||
bounds: translated(bounds, self.translation),
|
||||
ancestors: self.scrolls.clone(),
|
||||
});
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.owner_valid || self.fields.is_empty() {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let origin = match &self.origin {
|
||||
Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
Origin::Current => self
|
||||
.focused
|
||||
.map(|index| index as i64)
|
||||
.unwrap_or(if self.step < 0 {
|
||||
self.fields.len() as i64
|
||||
} else {
|
||||
-1
|
||||
}),
|
||||
Origin::Exact(id) => {
|
||||
let Some(index) = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id.as_ref() == Some(id))
|
||||
else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
index as i64
|
||||
}
|
||||
};
|
||||
// A form keeps a reachable focus at both boundaries. Initial Tab enters
|
||||
// the first field; initial Shift+Tab enters the last field.
|
||||
let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize;
|
||||
let field = &self.fields[target];
|
||||
let mut bounds = field.bounds;
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in field.ancestors.iter().rev() {
|
||||
let dx = reveal_axis(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal_axis(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
let delta = Vector::new(
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x,
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y,
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope.clone(),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts,
|
||||
owner_valid: matches!(self.origin, Origin::Current),
|
||||
clear_outside: self.modal,
|
||||
preserve_inside: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 {
|
||||
if start < viewport_start || length > viewport_length {
|
||||
start - viewport_start
|
||||
} else {
|
||||
(start + length - viewport_start - viewport_length).max(0.)
|
||||
}
|
||||
}
|
||||
pub(super) struct Apply {
|
||||
scope: Id,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
target: usize,
|
||||
field_index: usize,
|
||||
scroll_index: usize,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
owner_valid: bool,
|
||||
clear_outside: bool,
|
||||
preserve_inside: bool,
|
||||
}
|
||||
pub(super) fn exclusive() -> Apply {
|
||||
Apply {
|
||||
scope: Id::new(super::REVIEW_MODAL),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target: 0,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts: BTreeMap::new(),
|
||||
owner_valid: true,
|
||||
clear_outside: true,
|
||||
preserve_inside: true,
|
||||
}
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.clear_outside = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = self.inside;
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.owner_valid
|
||||
&& let Some(delta) = self.shifts.get(&self.scroll_index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.owner_valid && self.inside && !self.preserve_inside {
|
||||
if self.field_index == self.target {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
self.field_index += 1;
|
||||
} else if self.owner_valid && !self.inside && self.clear_outside {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
|
||||
//! from a dismissal gesture cannot reach the underlying content. Parent modal
|
||||
//! ownership is supplied by the host (pass no chooser while a modal is active).
|
||||
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
text::{self, Paragraph as _},
|
||||
widget::{Operation, Tree, tree},
|
||||
},
|
||||
alignment, keyboard, mouse, touch,
|
||||
widget::text_input,
|
||||
window,
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
const ROW: f32 = 34.;
|
||||
const MARGIN: f32 = 8.;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Geometry {
|
||||
pub popup: Rectangle,
|
||||
pub search: Rectangle,
|
||||
pub current: Rectangle,
|
||||
pub detail: Rectangle,
|
||||
pub close: Rectangle,
|
||||
pub previous: Rectangle,
|
||||
pub next: Rectangle,
|
||||
pub clear: Rectangle,
|
||||
pub rows: usize,
|
||||
}
|
||||
impl Geometry {
|
||||
fn new(anchor: Rectangle, viewport: Size) -> Self {
|
||||
let width = anchor
|
||||
.width
|
||||
.clamp(278., 420.)
|
||||
.min((viewport.width - 2. * MARGIN).max(0.));
|
||||
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
|
||||
let x = anchor.x.clamp(
|
||||
MARGIN.min(viewport.width / 2.),
|
||||
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
|
||||
);
|
||||
let below = anchor.y + anchor.height + 4.;
|
||||
let preferred = if below + height + MARGIN <= viewport.height {
|
||||
below
|
||||
} else {
|
||||
anchor.y - height - 4.
|
||||
};
|
||||
let y = preferred.clamp(
|
||||
MARGIN.min(viewport.height / 2.),
|
||||
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
|
||||
);
|
||||
Self::from_bounds(Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub(super) fn from_bounds(popup: Rectangle) -> Self {
|
||||
let inner = (popup.width - 16.).max(0.);
|
||||
let rect = |left, top, width, height| Rectangle {
|
||||
x: popup.x + left,
|
||||
y: popup.y + top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let footer = (popup.height - 32.).max(0.);
|
||||
let detail_height = if popup.height >= 180. { 32. } else { 0. };
|
||||
let detail_top = (footer - detail_height - 4.).max(84.);
|
||||
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
|
||||
Self {
|
||||
popup,
|
||||
search: rect(8., 28., inner, 30.),
|
||||
current: rect(8., 62., inner, 18.),
|
||||
detail: rect(8., detail_top, inner, detail_height),
|
||||
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
|
||||
previous: rect(8., footer, 28., 24.),
|
||||
next: rect(40., footer, 28., 24.),
|
||||
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
pub fn row(&self, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: self.popup.x + 8.,
|
||||
y: self.popup.y + 84. + index as f32 * ROW,
|
||||
width: (self.popup.width - 16.).max(0.),
|
||||
height: ROW,
|
||||
}
|
||||
}
|
||||
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
|
||||
if self.close.contains(point) {
|
||||
return Some(Action::Close(Close::Escape));
|
||||
}
|
||||
if self.previous.contains(point) && chooser.start() > 0 {
|
||||
return Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
|
||||
return Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.clear.contains(point) && chooser.optional() {
|
||||
return Some(Action::Clear);
|
||||
}
|
||||
chooser
|
||||
.visible(self.rows)
|
||||
.enumerate()
|
||||
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
stamp: Option<Stamp>,
|
||||
serial: u64,
|
||||
pressed: Option<(ChoiceEvent, Pointer)>,
|
||||
closing: bool,
|
||||
swallow_mouse: Option<mouse::Button>,
|
||||
swallow_touch: Option<touch::Finger>,
|
||||
held_mouse: Option<mouse::Button>,
|
||||
held_touch: Option<touch::Finger>,
|
||||
detail_scroll: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Interaction {
|
||||
fn swallow(&mut self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.swallow_mouse == Some(*button) =>
|
||||
{
|
||||
self.swallow_mouse = None;
|
||||
true
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.swallow_touch == Some(*id) => {
|
||||
self.swallow_touch = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: text::Renderer<Font = Font>,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
input: Element<'a, String, Theme, R>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
|
||||
focus_scope: Option<super::trigger::FormScope>,
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
let chooser = chooser.filter(|state| state.is_open());
|
||||
let input = text_input(
|
||||
"Name, @alias, kind or containment",
|
||||
chooser.map_or("", Chooser::query),
|
||||
)
|
||||
.on_input(std::convert::identity)
|
||||
.size(12)
|
||||
.padding(6)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
Self {
|
||||
content: content.into(),
|
||||
input,
|
||||
chooser,
|
||||
anchor,
|
||||
on_event: Rc::new(on_event),
|
||||
focus_scope: None,
|
||||
}
|
||||
}
|
||||
pub fn focus_scope(mut self, scope: Option<super::trigger::FormScope>) -> Self {
|
||||
self.focus_scope = scope;
|
||||
self
|
||||
}
|
||||
fn reconcile(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let stamp = self.chooser.map(Chooser::stamp);
|
||||
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
|
||||
let input = tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<R::Paragraph>>();
|
||||
if stamp.is_some() {
|
||||
input.focus();
|
||||
} else {
|
||||
input.unfocus();
|
||||
}
|
||||
state.closing = false;
|
||||
state.serial = self.chooser.map_or(0, Chooser::query_serial);
|
||||
}
|
||||
if state.stamp != stamp {
|
||||
state.pressed = None;
|
||||
// A queued Choose can be rejected after a preceding query changed
|
||||
// the result generation. Reconciliation must unlock this view.
|
||||
state.closing = false;
|
||||
state.detail_scroll = 0.;
|
||||
}
|
||||
state.stamp = stamp;
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
|
||||
for Popup<'_, Message, R>
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![
|
||||
Tree::new(self.content.as_widget()),
|
||||
Tree::new(self.input.as_widget()),
|
||||
]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.children[0].diff(self.content.as_widget());
|
||||
tree.children[1].diff(self.input.as_widget());
|
||||
self.reconcile(tree);
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.reconcile(tree);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let mut owner =
|
||||
super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none()));
|
||||
operation.custom(None, layout.bounds(), &mut owner);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) {
|
||||
super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer);
|
||||
}
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
&& let Some(scope) = self.focus_scope
|
||||
&& super::trigger::tab(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
scope,
|
||||
modifiers.shift(),
|
||||
)
|
||||
{
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
super::trigger::measure(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
*viewport,
|
||||
);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
if self.chooser.is_some() {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
cursor
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.chooser.is_some() {
|
||||
mouse::Interaction::None
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
if let Some(chooser) = self.chooser {
|
||||
Some(overlay::Element::new(Box::new(ChooserOverlay {
|
||||
chooser,
|
||||
anchor: self.anchor,
|
||||
state: tree.state.downcast_mut(),
|
||||
input: &mut self.input,
|
||||
input_tree: &mut tree.children[1],
|
||||
on_event: &*self.on_event,
|
||||
})))
|
||||
} else {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(popup: Popup<'a, Message, R>) -> Self {
|
||||
Element::new(popup)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
|
||||
chooser: &'a Chooser,
|
||||
anchor: Rectangle,
|
||||
state: &'b mut Interaction,
|
||||
input: &'b mut Element<'a, String, Theme, R>,
|
||||
input_tree: &'b mut Tree,
|
||||
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
|
||||
}
|
||||
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
|
||||
fn geometry(&self, layout: Layout<'_>) -> Geometry {
|
||||
Geometry::from_bounds(
|
||||
layout
|
||||
.children()
|
||||
.next()
|
||||
.expect("measured chooser bounds")
|
||||
.bounds(),
|
||||
)
|
||||
}
|
||||
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
|
||||
if self.state.closing {
|
||||
return;
|
||||
}
|
||||
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
|
||||
self.state.closing = true;
|
||||
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
|
||||
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
|
||||
}
|
||||
shell.publish((self.on_event)(self.chooser.event(action)));
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.publish(Action::Close(Close::Outside), shell);
|
||||
} else {
|
||||
self.state.pressed = geometry
|
||||
.hit(self.chooser, point)
|
||||
.map(|action| (self.chooser.event(action), pointer));
|
||||
}
|
||||
}
|
||||
fn release(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if let Some((pressed, origin)) = self.state.pressed.take()
|
||||
&& origin == pointer
|
||||
&& pressed.stamp == self.chooser.stamp()
|
||||
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
|
||||
{
|
||||
self.publish(pressed.action, shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
|
||||
for ChooserOverlay<'_, '_, Message, R>
|
||||
{
|
||||
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
|
||||
let geometry = Geometry::new(self.anchor, bounds);
|
||||
let input = self
|
||||
.input
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
&layout::Limits::new(Size::ZERO, geometry.search.size()),
|
||||
)
|
||||
.move_to(geometry.search.position());
|
||||
layout::Node::with_children(
|
||||
bounds,
|
||||
vec![
|
||||
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
|
||||
self.input.as_widget_mut().operate(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
self.state.held_mouse = Some(*button)
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.state.held_mouse == Some(*button) =>
|
||||
{
|
||||
self.state.held_mouse = None
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
|
||||
self.state.held_touch = Some(*id)
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
|
||||
_ => {}
|
||||
}
|
||||
if self.state.swallow(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.state.closing {
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut handled = false;
|
||||
match event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
|
||||
use keyboard::key::Named;
|
||||
let action = match key {
|
||||
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
|
||||
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
|
||||
backwards: modifiers.shift(),
|
||||
})),
|
||||
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::Enter) => {
|
||||
handled = true;
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
|
||||
.map(|c| Action::Choose(c.id))
|
||||
}
|
||||
keyboard::Key::Character(c)
|
||||
if modifiers.control()
|
||||
&& modifiers.shift()
|
||||
&& c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
clipboard.write(
|
||||
iced::advanced::clipboard::Kind::Standard,
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
|
||||
);
|
||||
handled = true;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
self.publish(action, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
|
||||
self.publish(Action::Close(Close::WindowChanged), shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if let Some(point) = cursor.position() {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.state.swallow_mouse = Some(*button);
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
if let Some(point) = cursor.position() {
|
||||
self.release(point, Pointer::Mouse, geometry, shell);
|
||||
} else {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position })
|
||||
if self.state.pressed.is_none() =>
|
||||
{
|
||||
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
|
||||
&& self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_none_or(|candidate| candidate.id != id)
|
||||
{
|
||||
self.publish(Action::Highlight(id), shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
||||
let (x, y) = match delta {
|
||||
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
|
||||
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
|
||||
};
|
||||
self.state.pressed = None;
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.detail.contains(p))
|
||||
{
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
let max = (measure::<R>(&detail, 11., renderer.default_font())
|
||||
- geometry.detail.width)
|
||||
.max(0.);
|
||||
self.state.detail_scroll =
|
||||
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
|
||||
shell.request_redraw();
|
||||
} else if y != 0. {
|
||||
self.publish(
|
||||
Action::Page {
|
||||
delta: if y < 0. { 1 } else { -1 },
|
||||
rows: geometry.rows,
|
||||
},
|
||||
shell,
|
||||
);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, position })
|
||||
if !geometry.search.contains(*position) =>
|
||||
{
|
||||
if !geometry.popup.contains(*position) {
|
||||
self.state.swallow_touch = Some(*id);
|
||||
}
|
||||
self.press(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLifted { id, position })
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
self.release(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !handled {
|
||||
let mut messages = Vec::new();
|
||||
let mut input_shell = Shell::new(&mut messages);
|
||||
self.input.as_widget_mut().update(
|
||||
self.input_tree,
|
||||
event,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut input_shell,
|
||||
&geometry.popup,
|
||||
);
|
||||
let serial = Cell::new(self.state.serial);
|
||||
shell.merge(input_shell, |text| {
|
||||
serial.set(serial.get().saturating_add(1));
|
||||
(self.on_event)(self.chooser.event(Action::Query {
|
||||
text,
|
||||
serial: serial.get(),
|
||||
}))
|
||||
});
|
||||
self.state.serial = serial.get();
|
||||
}
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: geometry.popup,
|
||||
border: Border {
|
||||
color: style::LINE,
|
||||
width: 1.,
|
||||
radius: 8.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
renderer.with_layer(geometry.popup, |renderer| {
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.scope_label,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.popup.x + 8.,
|
||||
y: geometry.popup.y + 4.,
|
||||
width: (geometry.popup.width - 78.).max(0.),
|
||||
height: 22.,
|
||||
},
|
||||
);
|
||||
label(renderer, "Close", 11., style::TEAL, geometry.close);
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.current.label(),
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.current,
|
||||
);
|
||||
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
|
||||
let row = geometry.row(i);
|
||||
if self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_some_and(|c| c.id == candidate.id)
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: row,
|
||||
..Default::default()
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
}
|
||||
let alias_width = (row.width * 0.42).min(
|
||||
measure::<R>(
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
renderer.default_font(),
|
||||
) + 4.,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.name,
|
||||
12.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
width: (row.width - alias_width - 6.).max(0.),
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
x: row.x + row.width - alias_width,
|
||||
width: alias_width,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.context_label(),
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: row.y + 17.,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.chooser.count() == 0 {
|
||||
label(
|
||||
renderer,
|
||||
"No compatible matches. Clear the query.",
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.row(0),
|
||||
);
|
||||
}
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
renderer.with_layer(geometry.detail, |renderer| {
|
||||
raw_label(
|
||||
renderer,
|
||||
detail,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.detail.x - self.state.detail_scroll,
|
||||
width: geometry.detail.width + self.state.detail_scroll,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"Scroll details · Ctrl+Shift+C copies",
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: geometry.detail.y + 16.,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
});
|
||||
label(
|
||||
renderer,
|
||||
"‹",
|
||||
15.,
|
||||
if self.chooser.start() > 0 {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.previous,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"›",
|
||||
15.,
|
||||
if self.chooser.start() + geometry.rows < self.chooser.count() {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.next,
|
||||
);
|
||||
let count = if self.chooser.count() == 0 {
|
||||
"0 results".into()
|
||||
} else {
|
||||
format!(
|
||||
"{}–{} / {}",
|
||||
self.chooser.start() + 1,
|
||||
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
|
||||
self.chooser.count()
|
||||
)
|
||||
};
|
||||
label(
|
||||
renderer,
|
||||
&count,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.next.x + 30.,
|
||||
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
|
||||
..geometry.next
|
||||
},
|
||||
);
|
||||
if self.chooser.optional() {
|
||||
label(
|
||||
renderer,
|
||||
"Clear reference",
|
||||
10.,
|
||||
style::TEAL,
|
||||
geometry.clear,
|
||||
);
|
||||
}
|
||||
self.input.as_widget().draw(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
let geometry = self.geometry(layout);
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_input(event: &Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
|
||||
)
|
||||
}
|
||||
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
|
||||
R::Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font,
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
})
|
||||
.min_width()
|
||||
}
|
||||
fn label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: &str,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let font = renderer.default_font();
|
||||
let mut display = content.to_owned();
|
||||
if measure::<R>(content, size, font) > bounds.width {
|
||||
let ends: Vec<_> = content
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(content.len()))
|
||||
.collect();
|
||||
let (mut low, mut high) = (0, ends.len());
|
||||
while low + 1 < high {
|
||||
let middle = (low + high) / 2;
|
||||
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
|
||||
{
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
display = format!("{}…", &content[..ends[low]]);
|
||||
}
|
||||
raw_label(renderer, display, size, color, bounds);
|
||||
}
|
||||
fn raw_label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: String,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content,
|
||||
bounds: bounds.size(),
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
},
|
||||
Point::new(bounds.x, bounds.center_y()),
|
||||
color,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
@@ -445,9 +445,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
|
||||
"Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
@@ -489,8 +489,8 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Create a scenario",
|
||||
&[
|
||||
"Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.",
|
||||
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
|
||||
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
|
||||
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
|
||||
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
|
||||
@@ -543,7 +543,9 @@ fn article(topic: Topic) -> Article {
|
||||
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.",
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
|
||||
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram 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.",
|
||||
@@ -898,6 +900,10 @@ fn article(topic: Topic) -> Article {
|
||||
"A property draft is not yet the model",
|
||||
"Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.",
|
||||
),
|
||||
explain(
|
||||
"A new behavior is still a draft",
|
||||
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
),
|
||||
explain(
|
||||
"Aliases and references",
|
||||
"Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.",
|
||||
|
||||
@@ -87,6 +87,9 @@ pub enum RowEvent {
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
ReferenceOpen(crate::forms::reference_chooser::adapter::Open),
|
||||
ReferenceEvent(crate::forms::reference_chooser::Event),
|
||||
ReferenceSet(crate::forms::reference_chooser::adapter::Set),
|
||||
OperationOmission(bool),
|
||||
OperationRationale(String),
|
||||
Tab(Tab),
|
||||
|
||||
@@ -296,6 +296,51 @@ impl State {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> {
|
||||
let facet = self.active.selected.as_ref()?;
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active.tab != Tab::Facts
|
||||
|| self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.candidate_draft(facet)
|
||||
}
|
||||
pub(crate) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.frozen(project).is_err()
|
||||
|| self.active_address != address
|
||||
|| self.active.tab != Tab::Facts
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.active.reference_draft(project, facet, path)
|
||||
}
|
||||
pub(crate) fn set_reference(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
address: Option<RowAddress>,
|
||||
facet: &FacetSelector,
|
||||
intent: crate::forms::reference_chooser::adapter::Set,
|
||||
) -> Result<(), String> {
|
||||
self.reference_draft(project, address, facet, &intent.context.path)
|
||||
.ok_or("The originating review row or property is no longer active.")?
|
||||
.reference_value(project, &intent)?;
|
||||
self.update(
|
||||
project,
|
||||
Event::Row {
|
||||
address,
|
||||
facet: Some(facet.clone()),
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
||||
let event = event.into();
|
||||
#[cfg(test)]
|
||||
@@ -303,6 +348,30 @@ impl State {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
match &event {
|
||||
Event::Row {
|
||||
event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_),
|
||||
..
|
||||
}
|
||||
| Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return,
|
||||
Event::Row {
|
||||
address,
|
||||
facet,
|
||||
event: RowEvent::ReferenceSet(intent),
|
||||
} => {
|
||||
let valid = facet
|
||||
.as_ref()
|
||||
.and_then(|facet| {
|
||||
self.reference_draft(project, *address, facet, &intent.context.path)
|
||||
})
|
||||
.is_some_and(|draft| draft.reference_value(project, intent).is_ok());
|
||||
if !valid {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Editor(RowEvent::ReferenceSet(_)) => return,
|
||||
_ => {}
|
||||
}
|
||||
if let Err(error) = self.frozen(project) {
|
||||
// Withdrawing a pending reset is authored intent only. Retain it while
|
||||
// keeping the original accepted base and disabling every stale plan.
|
||||
@@ -464,7 +533,8 @@ impl State {
|
||||
);
|
||||
let scoped_edit = matches!(
|
||||
event,
|
||||
RowEvent::Input(_, _)
|
||||
RowEvent::ReferenceSet(_)
|
||||
| RowEvent::Input(_, _)
|
||||
| RowEvent::Text(_, _)
|
||||
| RowEvent::Shape(_, _)
|
||||
| RowEvent::Variant(_, _)
|
||||
@@ -747,13 +817,15 @@ impl State {
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
self.active
|
||||
let view = self
|
||||
.active
|
||||
.view(&self.base, header.into(), self.can_apply())
|
||||
.map(move |message| match message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
});
|
||||
crate::forms::reference_chooser::trigger::modal(view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@ impl ReviewRow {
|
||||
pub fn update(&mut self, project: &Project, event: RowEvent) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
// Browser messages belong to the app overlay and must never replan.
|
||||
RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_) => return,
|
||||
RowEvent::ReferenceSet(intent) => {
|
||||
let path = intent.context.path.clone();
|
||||
self.edit(project, &path, |draft| {
|
||||
draft.reference_set(project, &intent)
|
||||
})
|
||||
}
|
||||
RowEvent::OperationOmission(enabled) => {
|
||||
self.operation_omission =
|
||||
enabled.then(|| self.operation_omission.clone().unwrap_or_default());
|
||||
@@ -518,6 +526,25 @@ impl ReviewRow {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reference_draft(
|
||||
&self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
path: &str,
|
||||
) -> Option<&Draft> {
|
||||
if self.selected.as_ref() != Some(facet)
|
||||
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
|| matches!(path, "/id" | "/alias" | "/body" | "/layer")
|
||||
|| !self
|
||||
.candidate_roots(project, facet)?
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.candidate_draft(facet)
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
@@ -895,6 +922,8 @@ pub fn form_event(message: Message) -> Message {
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
RowEvent::ReferenceBrowse(path, query, page)
|
||||
}
|
||||
Message::FieldReferenceOpen(request) => RowEvent::ReferenceOpen(request),
|
||||
Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event),
|
||||
_ => RowEvent::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
|
||||
@@ -867,7 +867,13 @@ impl ReviewRow {
|
||||
) {
|
||||
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
||||
}
|
||||
detail.into()
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||
{
|
||||
crate::forms::reference_chooser::trigger::scope(detail, draft.reference_session())
|
||||
} else {
|
||||
detail.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
|
||||
+71
-20
@@ -752,7 +752,7 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_scenario</span>, <span class="docutils literal">sa_scenario</span></p></td>
|
||||
<td><p><span class="docutils literal">viewpoint</span>: ScenarioViewpoint; <span class="docutils literal">context</span>: ref?; <span class="docutils literal">precondition</span>, <span class="docutils literal">postcondition</span>: strings; <span class="docutils literal">steps</span>: occurrence refs</p></td>
|
||||
<td><p>MixedLegacy for omitted schema-2 source; none; <span class="docutils literal">""</span>; <span class="docutils literal">""</span>; <span class="docutils literal">[]</span></p></td>
|
||||
<td><p>MixedLegacy when viewpoint is omitted, including newly authored source 7 (see section 4.3); none; <span class="docutils literal">""</span>; <span class="docutils literal">""</span>; <span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_lifeline</span>, <span class="docutils literal">sa_lifeline</span></p></td>
|
||||
<td><p><span class="docutils literal">represented</span>: participant, work, or role ref?</p></td>
|
||||
@@ -792,49 +792,71 @@ An Enumeration contains unique string literals. For example:</p>
|
||||
<pre class="code text literal-block"><code>definition { Enumeration { literals ["Clear", "Fog"]; }; };
|
||||
type_ref { Named @weather_classification; };</code></pre>
|
||||
<p>These are typed constructors with named fields, not embedded JSON documents.
|
||||
Constructor field names and enum spellings are case-sensitive. When replacing
|
||||
a constructor, all of its fields MUST be supplied; the containing declaration
|
||||
defaults do not silently fill a different constructor's missing fields.
|
||||
During schema-2 compatibility, the new Exchange fields <span class="docutils literal">message_kind</span>,
|
||||
<span class="docutils literal">reply_to</span> and <span class="docutils literal">values</span> are exceptions: omitted values hydrate as
|
||||
Asynchronous, none and [] respectively. Canonical output writes them explicitly.
|
||||
This preserves older schema-2 source/JSON without inventing call correlation or
|
||||
value annotations. It is not a rule for an unspecified future schema.</p>
|
||||
Constructor field names and enum spellings are case-sensitive. A selected
|
||||
constructor uses its own field contract; it does not inherit the payload of a
|
||||
different constructor from the declaration default. Required fields MUST be
|
||||
supplied. Nullability and omission are separate properties: <span class="docutils literal">?</span> denotes a
|
||||
value that can be <span class="docutils literal">none</span>; a constructor's omission contract determines whether
|
||||
the field may be absent. There is no general all-constructor-fields-required
|
||||
rule. Other typed constructors use their contracts in the corresponding
|
||||
sections; the occurrence rules below do not establish defaults for them.</p>
|
||||
<p>The following table records the source-7 OccurrenceBody contracts. Optional
|
||||
references may be omitted and then mean <span class="docutils literal">none</span>. Exchange also admits omitted
|
||||
<span class="docutils literal">message_kind</span> and <span class="docutils literal">values</span>, meaning Asynchronous and <span class="docutils literal">[]</span> respectively.
|
||||
These omissions are accepted in newly authored source 7, not only in schema-2
|
||||
compatibility input. Earlier schema admission of constructors and fields is
|
||||
still governed by its version rules.</p>
|
||||
<p>Canonical formatting writes the hydrated occurrence references and default
|
||||
<span class="docutils literal">message_kind</span>, <span class="docutils literal">reply_to</span> and <span class="docutils literal">values</span> explicitly. An absent
|
||||
<span class="docutils literal">operation</span> remains omitted in canonical output. Lossless no-op updates and
|
||||
unrelated name edits retain authored omissions; canonical formatting is a
|
||||
different operation. An admitted incomplete occurrence can still produce model
|
||||
checks requesting its missing work, exchange, lifeline or referenced scenario.</p>
|
||||
<table>
|
||||
<caption>Occurrence body constructors</caption>
|
||||
<colgroup>
|
||||
<col style="width: 22.0%" />
|
||||
<col style="width: 78.0%" />
|
||||
<col style="width: 18.0%" />
|
||||
<col style="width: 36.0%" />
|
||||
<col style="width: 46.0%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Constructor</p></th>
|
||||
<th class="head"><p>Required fields</p></th>
|
||||
<th class="head"><p>Fields that may be omitted, with their resulting values</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Exchange</p></td>
|
||||
<td><p><span class="docutils literal">exchange</span>: exchange relation ref?; <span class="docutils literal">sender</span>, <span class="docutils literal">receiver</span>: lifeline refs?; <span class="docutils literal">message_kind</span>: MessageKind; <span class="docutils literal">reply_to</span>: occurrence ref?; <span class="docutils literal">values</span>: MessageValue records</p></td>
|
||||
<td><p>None</p></td>
|
||||
<td><p><span class="docutils literal">exchange</span>: exchange relation ref?, <span class="docutils literal">sender</span>, <span class="docutils literal">receiver</span>: lifeline refs?, <span class="docutils literal">reply_to</span>: occurrence ref?, <span class="docutils literal">operation</span>: OperationUse? → <span class="docutils literal">none</span>; <span class="docutils literal">message_kind</span>: MessageKind → Asynchronous; <span class="docutils literal">values</span>: MessageValue records → <span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p>Action</p></td>
|
||||
<td><p><span class="docutils literal">action</span>: activity/function ref?; <span class="docutils literal">lifeline</span>: ref?</p></td>
|
||||
<td><p>None</p></td>
|
||||
<td><p><span class="docutils literal">action</span>: activity/function ref?, <span class="docutils literal">lifeline</span>: ref? → <span class="docutils literal">none</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p>Control</p></td>
|
||||
<td><p><span class="docutils literal">kind</span>: Choice, Merge, Fork, Join, Loop</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p>SequenceReference</p></td>
|
||||
<td><p><span class="docutils literal">sequence</span>: process/functional-chain ref?</p></td>
|
||||
<td><p>None</p></td>
|
||||
<td><p><span class="docutils literal">sequence</span>: process/functional-chain ref? → <span class="docutils literal">none</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p>ScenarioUse</p></td>
|
||||
<td><p><span class="docutils literal">scenario</span>: scenario ref?; <span class="docutils literal">bindings</span>: LifelineBinding records</p></td>
|
||||
<td><p><span class="docutils literal">bindings</span>: LifelineBinding records</p></td>
|
||||
<td><p><span class="docutils literal">scenario</span>: scenario ref? → <span class="docutils literal">none</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p>State</p></td>
|
||||
<td><p><span class="docutils literal">state</span>: state/mode ref?; <span class="docutils literal">lifeline</span>: ref?</p></td>
|
||||
<td><p>None</p></td>
|
||||
<td><p><span class="docutils literal">state</span>: state/mode ref?, <span class="docutils literal">lifeline</span>: ref? → <span class="docutils literal">none</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p>Fragment</p></td>
|
||||
<td><p><span class="docutils literal">operator</span>: Alternative, Parallel, Loop, Optional; <span class="docutils literal">branches</span>: branch occurrence refs</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Branch</p></td>
|
||||
<td><p><span class="docutils literal">guard</span>: string; <span class="docutils literal">occurrences</span>: occurrence refs</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1274,9 +1296,21 @@ reference checks.</p>
|
||||
<p>Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON.
|
||||
This preserves pre-extension mixed work/participant scenes without discarding
|
||||
their lifelines or silently assigning a stricter meaning. MixedLegacy emits a
|
||||
review warning. New graphical factories explicitly select OperationalActivity
|
||||
or Functional by layer. Authors can select a strict viewpoint after reviewing
|
||||
the legacy scene; canonical source then records that choice.</p>
|
||||
review warning. Source parsing also uses MixedLegacy when <span class="docutils literal">viewpoint</span> is
|
||||
omitted from a newly authored source-7 scenario; it does not infer a strict
|
||||
viewpoint from the layer or lifelines. Write the intended viewpoint explicitly
|
||||
when starting a scenario. For example, this new OA source project starts an
|
||||
activity-based scenario whose lifelines and occurrences can be added later:</p>
|
||||
<pre class="literal-block">arcadia 7;
|
||||
project response "Distress response" {
|
||||
oa_scenario assessment "Assess the distress report" {
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
}</pre>
|
||||
<p>An empty scenario can produce completeness warnings while remaining editable.
|
||||
The desktop's Behavior <strong>New</strong> flow asks authors to choose one of the layer's
|
||||
two strict viewpoints explicitly. Authors can also select a strict viewpoint
|
||||
after reviewing a legacy scene; canonical source then records that choice.</p>
|
||||
<p>MessageKind is Asynchronous, Call, Reply, Create, Delete, or
|
||||
<span class="docutils literal">{ Timer { expression "after 5 s"; }; }</span>. A process/chain exchange
|
||||
contribution MUST remain Asynchronous with no reply correlation. Call/reply,
|
||||
@@ -2217,6 +2251,23 @@ strings use JSON string escaping, including <span class="docutils literal">\n</s
|
||||
and Unicode escapes. Literal unescaped newlines inside strings are invalid.
|
||||
Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers,
|
||||
leading plus signs, and leading zeroes are invalid.</p>
|
||||
<p>A small model may begin with engineering declarations alone. This complete
|
||||
source describes two participants, their work, responsibility and an exchange:</p>
|
||||
<pre class="literal-block">arcadia 7;
|
||||
project distress "Distress response" {
|
||||
oa_actor vessel "Vessel in distress" {}
|
||||
oa_entity rescue "Rescue coordination" {}
|
||||
oa_activity report "Report distress" {}
|
||||
oa_activity assess "Assess incident" {}
|
||||
allocation vessel_reports "Crew reports" { source @vessel; target @report; }
|
||||
allocation rescue_assesses "Team assesses" { source @rescue; target @assess; }
|
||||
interaction distress_report "Distress report" { source @report; target @assess; }
|
||||
}</pre>
|
||||
<p>No diagram, placement, empty description or explicit root parent is required.
|
||||
The accepted source adds identities; keep those clauses during later edits.
|
||||
Changing <span class="docutils literal">"Assess incident"</span> changes the display name while <span class="docutils literal">@assess</span> still
|
||||
resolves to the same activity. Behavior-coverage warnings permit this intermediate
|
||||
model to be saved. Allocation remains explicit and distinct from decomposition.</p>
|
||||
<p>Aliases match <span class="docutils literal"><span class="pre">[A-Za-z_][A-Za-z0-9_]*</span></span>. The following aliases are reserved:
|
||||
arcadia, project, id, oa, sa, element, relation, diagram, node, artifact,
|
||||
trace, body, alias, name, description, parent, from, to, entity, actor,
|
||||
@@ -2227,7 +2278,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" "5" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "7" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
|
||||
+76
-19
@@ -120,7 +120,7 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
|
||||
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
|
||||
* - ``oa_scenario``, ``sa_scenario``
|
||||
- ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs
|
||||
- MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]``
|
||||
- MixedLegacy when viewpoint is omitted, including newly authored source 7 (see section 4.3); none; ``""``; ``""``; ``[]``
|
||||
* - ``oa_lifeline``, ``sa_lifeline``
|
||||
- ``represented``: participant, work, or role ref?
|
||||
- none
|
||||
@@ -155,37 +155,60 @@ An Enumeration contains unique string literals. For example:
|
||||
type_ref { Named @weather_classification; };
|
||||
|
||||
These are typed constructors with named fields, not embedded JSON documents.
|
||||
Constructor field names and enum spellings are case-sensitive. When replacing
|
||||
a constructor, all of its fields MUST be supplied; the containing declaration
|
||||
defaults do not silently fill a different constructor's missing fields.
|
||||
During schema-2 compatibility, the new Exchange fields ``message_kind``,
|
||||
``reply_to`` and ``values`` are exceptions: omitted values hydrate as
|
||||
Asynchronous, none and [] respectively. Canonical output writes them explicitly.
|
||||
This preserves older schema-2 source/JSON without inventing call correlation or
|
||||
value annotations. It is not a rule for an unspecified future schema.
|
||||
Constructor field names and enum spellings are case-sensitive. A selected
|
||||
constructor uses its own field contract; it does not inherit the payload of a
|
||||
different constructor from the declaration default. Required fields MUST be
|
||||
supplied. Nullability and omission are separate properties: ``?`` denotes a
|
||||
value that can be ``none``; a constructor's omission contract determines whether
|
||||
the field may be absent. There is no general all-constructor-fields-required
|
||||
rule. Other typed constructors use their contracts in the corresponding
|
||||
sections; the occurrence rules below do not establish defaults for them.
|
||||
|
||||
The following table records the source-7 OccurrenceBody contracts. Optional
|
||||
references may be omitted and then mean ``none``. Exchange also admits omitted
|
||||
``message_kind`` and ``values``, meaning Asynchronous and ``[]`` respectively.
|
||||
These omissions are accepted in newly authored source 7, not only in schema-2
|
||||
compatibility input. Earlier schema admission of constructors and fields is
|
||||
still governed by its version rules.
|
||||
|
||||
Canonical formatting writes the hydrated occurrence references and default
|
||||
``message_kind``, ``reply_to`` and ``values`` explicitly. An absent
|
||||
``operation`` remains omitted in canonical output. Lossless no-op updates and
|
||||
unrelated name edits retain authored omissions; canonical formatting is a
|
||||
different operation. An admitted incomplete occurrence can still produce model
|
||||
checks requesting its missing work, exchange, lifeline or referenced scenario.
|
||||
|
||||
.. list-table:: Occurrence body constructors
|
||||
:header-rows: 1
|
||||
:widths: 22 78
|
||||
:widths: 18 36 46
|
||||
|
||||
* - Constructor
|
||||
- Required fields
|
||||
- Fields that may be omitted, with their resulting values
|
||||
* - Exchange
|
||||
- ``exchange``: exchange relation ref?; ``sender``, ``receiver``: lifeline refs?; ``message_kind``: MessageKind; ``reply_to``: occurrence ref?; ``values``: MessageValue records
|
||||
- None
|
||||
- ``exchange``: exchange relation ref?, ``sender``, ``receiver``: lifeline refs?, ``reply_to``: occurrence ref?, ``operation``: OperationUse? → ``none``; ``message_kind``: MessageKind → Asynchronous; ``values``: MessageValue records → ``[]``
|
||||
* - Action
|
||||
- ``action``: activity/function ref?; ``lifeline``: ref?
|
||||
- None
|
||||
- ``action``: activity/function ref?, ``lifeline``: ref? → ``none``
|
||||
* - Control
|
||||
- ``kind``: Choice, Merge, Fork, Join, Loop
|
||||
- None
|
||||
* - SequenceReference
|
||||
- ``sequence``: process/functional-chain ref?
|
||||
- None
|
||||
- ``sequence``: process/functional-chain ref? → ``none``
|
||||
* - ScenarioUse
|
||||
- ``scenario``: scenario ref?; ``bindings``: LifelineBinding records
|
||||
- ``bindings``: LifelineBinding records
|
||||
- ``scenario``: scenario ref? → ``none``
|
||||
* - State
|
||||
- ``state``: state/mode ref?; ``lifeline``: ref?
|
||||
- None
|
||||
- ``state``: state/mode ref?, ``lifeline``: ref? → ``none``
|
||||
* - Fragment
|
||||
- ``operator``: Alternative, Parallel, Loop, Optional; ``branches``: branch occurrence refs
|
||||
- None
|
||||
* - Branch
|
||||
- ``guard``: string; ``occurrences``: occurrence refs
|
||||
- None
|
||||
|
||||
For example, ``body { Action { action @assess; lifeline @operator_line; }; };``
|
||||
selects an Action occurrence. Processes and functional chains contain Exchange,
|
||||
@@ -631,9 +654,23 @@ reference checks.
|
||||
Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON.
|
||||
This preserves pre-extension mixed work/participant scenes without discarding
|
||||
their lifelines or silently assigning a stricter meaning. MixedLegacy emits a
|
||||
review warning. New graphical factories explicitly select OperationalActivity
|
||||
or Functional by layer. Authors can select a strict viewpoint after reviewing
|
||||
the legacy scene; canonical source then records that choice.
|
||||
review warning. Source parsing also uses MixedLegacy when ``viewpoint`` is
|
||||
omitted from a newly authored source-7 scenario; it does not infer a strict
|
||||
viewpoint from the layer or lifelines. Write the intended viewpoint explicitly
|
||||
when starting a scenario. For example, this new OA source project starts an
|
||||
activity-based scenario whose lifelines and occurrences can be added later::
|
||||
|
||||
arcadia 7;
|
||||
project response "Distress response" {
|
||||
oa_scenario assessment "Assess the distress report" {
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
}
|
||||
|
||||
An empty scenario can produce completeness warnings while remaining editable.
|
||||
The desktop's Behavior **New** flow asks authors to choose one of the layer's
|
||||
two strict viewpoints explicitly. Authors can also select a strict viewpoint
|
||||
after reviewing a legacy scene; canonical source then records that choice.
|
||||
|
||||
MessageKind is Asynchronous, Call, Reply, Create, Delete, or
|
||||
``{ Timer { expression "after 5 s"; }; }``. A process/chain exchange
|
||||
@@ -1582,6 +1619,26 @@ and Unicode escapes. Literal unescaped newlines inside strings are invalid.
|
||||
Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers,
|
||||
leading plus signs, and leading zeroes are invalid.
|
||||
|
||||
A small model may begin with engineering declarations alone. This complete
|
||||
source describes two participants, their work, responsibility and an exchange::
|
||||
|
||||
arcadia 7;
|
||||
project distress "Distress response" {
|
||||
oa_actor vessel "Vessel in distress" {}
|
||||
oa_entity rescue "Rescue coordination" {}
|
||||
oa_activity report "Report distress" {}
|
||||
oa_activity assess "Assess incident" {}
|
||||
allocation vessel_reports "Crew reports" { source @vessel; target @report; }
|
||||
allocation rescue_assesses "Team assesses" { source @rescue; target @assess; }
|
||||
interaction distress_report "Distress report" { source @report; target @assess; }
|
||||
}
|
||||
|
||||
No diagram, placement, empty description or explicit root parent is required.
|
||||
The accepted source adds identities; keep those clauses during later edits.
|
||||
Changing ``"Assess incident"`` changes the display name while ``@assess`` still
|
||||
resolves to the same activity. Behavior-coverage warnings permit this intermediate
|
||||
model to be saved. Allocation remains explicit and distinct from decomposition.
|
||||
|
||||
Aliases match ``[A-Za-z_][A-Za-z0-9_]*``. The following aliases are reserved:
|
||||
arcadia, project, id, oa, sa, element, relation, diagram, node, artifact,
|
||||
trace, body, alias, name, description, parent, from, to, entity, actor,
|
||||
@@ -1596,7 +1653,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
document = "arcadia" "5" ";" project
|
||||
document = "arcadia" "7" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Source CLI
|
||||
|
||||
Use `arcadia` to check or update local `.arcadia` projects without opening Studio
|
||||
or starting a server. Pass the entry file explicitly; its relative include paths
|
||||
resolve from their containing source files.
|
||||
|
||||
Build with `cargo build --locked -p oa-cli --bin arcadia`. The resulting command
|
||||
is `target/debug/arcadia.exe` on Windows or `target/debug/arcadia` on Linux unless
|
||||
your Cargo configuration chooses another target directory. A workspace release
|
||||
build also includes it. Use the platform's usual executable path prefix when
|
||||
running these examples.
|
||||
|
||||
```sh
|
||||
arcadia check project.arcadia
|
||||
arcadia check project.arcadia --json
|
||||
arcadia stabilize project.arcadia
|
||||
arcadia stabilize project.arcadia --write
|
||||
arcadia rename-alias project.arcadia old_alias new_alias
|
||||
arcadia rename-alias project.arcadia old_alias new_alias --write --json
|
||||
```
|
||||
|
||||
`lint` is an alias for `check`. Checks accept incomplete engineering models with
|
||||
warnings; syntax errors and structural violations fail. Located diagnostics
|
||||
refer to the named file and coordinate space. Line and column numbers are
|
||||
one-based and columns count Unicode scalar values; byte ranges are half-open
|
||||
UTF-8 offsets.
|
||||
|
||||
`stabilize` previews missing persistent identities and any supported source
|
||||
migration. `rename-alias` changes one existing global alias and its typed
|
||||
references while preserving comments, strings and unrelated source. Pass alias
|
||||
names without `@`. The command does not rename an element's display name.
|
||||
|
||||
Both editing commands preview by default. Human output writes unified file
|
||||
diffs to stdout and diagnostics to stderr. `--json` writes one versioned report
|
||||
with diagnostics, migration information, exact before/after text for every
|
||||
changed file, diffs and the write result. Unchanged files are not included in
|
||||
that report. They are still checked for conflicts before a write.
|
||||
|
||||
Add `--write` to save the candidate prepared by that invocation. Even an empty
|
||||
diff checks the captured files and pending-save state before returning. A failed
|
||||
write reports `written: false` and retains its proposed diff. This does not
|
||||
guarantee rollback; the reported conflict or recovery outcome determines the
|
||||
next step. A pending save
|
||||
requires explicit recovery through the existing project recovery workflow;
|
||||
the CLI does not recover it automatically.
|
||||
|
||||
Missing IDs in a separate earlier preview remain provisional. A later command
|
||||
may allocate different IDs until you save them. This CLI does not persist a
|
||||
review plan for later application. It does not canonicalize the whole project
|
||||
or delete detached files. Source versions 3–7 use the core's supported migration
|
||||
rules; invalid source is not rewritten.
|
||||
|
||||
| Exit code | Meaning |
|
||||
| --- | --- |
|
||||
| 0 | Valid check or successful preview/write; warnings may remain |
|
||||
| 1 | Invalid source or refused semantic alias change |
|
||||
| 2 | Invalid command arguments |
|
||||
| 3 | File/path, conflict, pending-save or output failure |
|
||||
|
||||
Admission bounds match the desktop: 256 files, include depth 32, 1024 include
|
||||
edges, 64 MiB total UTF-8, 100,000 graph objects, 250,000 records and 500,000
|
||||
ordered references. `arcadia --help` lists the commands and bounds. These limits
|
||||
are not a claim of interactive performance at the maximum project size.
|
||||
|
||||
For the notation itself, start with [the text-first examples](../examples/text-first/README.md)
|
||||
and the [language contract](language.rst).
|
||||
@@ -65,12 +65,12 @@ The integration oracle executes this exact semantic review and source update. It
|
||||
| `oa/`, `sa/` | Participants, work, data, capabilities and reusable behavior |
|
||||
| `views/` | Five saved views; filters and placements are presentation state |
|
||||
| `traceability.arcadia` | OA↔SA correspondence and review history |
|
||||
| `model.json` | Current schema-6 interchange of the final accepted model |
|
||||
| `model.json` | Current schema-7 interchange of the final accepted model |
|
||||
| `transition-before.json`, `transition-request.json` | Explicit first-transition replay inputs |
|
||||
| `evidence/repair-check.md` | Local teaching evidence linked from the SA conversation |
|
||||
| `model.rs` | Deterministic lesson builder shared by generator and tests |
|
||||
|
||||
The REST routes are documented in [the API reference](../../docs/api.rst). Import `model.json` with `POST /api/v1/projects/import`; query the returned resource's `/model` and `/validation`. Expect schema 6 and the one warning above. JSON import provides canonical source; to retain the exact modular source layout, use `/api/v1/projects/import-source-project` with a SourceBundle containing entry `project.arcadia` and only the 30 reachable `.arcadia` file strings, keyed by slash-separated relative paths. Neither route reads the artifact target from your local filesystem.
|
||||
The REST routes are documented in [the API reference](../../docs/api.rst). Import `model.json` with `POST /api/v1/projects/import`; query the returned resource's `/model` and `/validation`. Expect schema 7 and the one warning above. JSON import provides canonical source; to retain the exact modular source layout, use `/api/v1/projects/import-source-project` with a SourceBundle containing entry `project.arcadia` and only the 30 reachable `.arcadia` file strings, keyed by slash-separated relative paths. Neither route reads the artifact target from your local filesystem.
|
||||
|
||||
For transition replay, import `transition-before.json` instead. Submit `transition-request.json` to that baseline resource's `/transitions/preview` with its current ETag in `If-Match`. Review the returned plan, then follow the documented `/transitions/apply` protocol. Use the baseline resource for this exercise: the final model already contains the mapped records and subsequent refinements. This tutorial's automated evidence is core/source validation; it does not claim a separate native or HTTP acceptance run of this particular example.
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Start a model in text
|
||||
|
||||
Open `project.arcadia`, then choose **Textual model**. The model describes who
|
||||
reports distress, who assesses it, and the information passing between those
|
||||
activities. It needs no diagram or handwritten UUIDs.
|
||||
|
||||
1. Read each declaration as **kind, alias, display name**. References such as
|
||||
`@assess` use the alias. The two allocations state responsibility; the
|
||||
interaction states the direction of the exchange.
|
||||
2. Change `"Assess incident"` to a name suited to your project. Use **Apply all**,
|
||||
then **Save all**. Keep the identity clauses added to the accepted source:
|
||||
they preserve the same model objects in later edits.
|
||||
3. Add work and exchanges as your understanding grows. Behavior coverage checks
|
||||
identify activities and exchanges that still need process or scenario context;
|
||||
these advisory checks allow you to save the intermediate model.
|
||||
|
||||
Changing a display name keeps references intact. Changing an alias requires
|
||||
updating every `@alias` use. Nesting expresses decomposition; an allocation
|
||||
expresses who performs work. Neither implies the other.
|
||||
|
||||
For a larger example with behavior, ports, views and OA/SA correspondence, use
|
||||
[the water-pressure walkthrough](../release1/README.md). The
|
||||
[language reference](../../docs/language.rst) defines all declarations and the
|
||||
rules for splitting a project into included files.
|
||||
@@ -0,0 +1,10 @@
|
||||
arcadia 7;
|
||||
project distress "Distress response" {
|
||||
oa_actor vessel "Vessel in distress" {}
|
||||
oa_entity rescue "Rescue coordination" {}
|
||||
oa_activity report "Report distress" {}
|
||||
oa_activity assess "Assess incident" {}
|
||||
allocation vessel_reports "Crew reports" { source @vessel; target @report; }
|
||||
allocation rescue_assesses "Team assesses" { source @rescue; target @assess; }
|
||||
interaction distress_report "Distress report" { source @report; target @assess; }
|
||||
}
|
||||
@@ -319,3 +319,98 @@ Revisit if complete text/native tasks expose unacceptable hidden propagation,
|
||||
wrong-context edits or inability to preserve reproducible independent studies.
|
||||
The comparison establishes semantic/text maintenance evidence, not human task
|
||||
timings or full quantity/library integration acceptance.
|
||||
|
||||
## Analysis and library contract convergence — 2026-09-11
|
||||
|
||||
Root Codex-956atf read the complete [baseline refinement](release-analysis-baseline-contract-20260911.md),
|
||||
[library refinement](release-library-compatibility-contract-20260911.md), their
|
||||
complete fixtures and [independent review](release-refined-contracts-review-20260911.md).
|
||||
Select the following architecture for production implementation:
|
||||
|
||||
- Required property-owned Binding values, explicit ModelBaseline/Case selection,
|
||||
one level of complete-case override maps and shared root checks. Clear removes
|
||||
an override; Unbound masks the base. Inherited formulas evaluate dependencies
|
||||
in the requested context. Pure calculations see only explicit parameters.
|
||||
- Same-layer property dependencies and case targets, with explicit analysis
|
||||
selection for transitions and exports. Check/case subjects may be absent;
|
||||
architectural containment must not infer their selection. Validate every
|
||||
effective dependency graph, including statically referenced unused branches.
|
||||
- The refined R6 closed algebra, exact rational R7 literal/AST preservation,
|
||||
affine point/difference distinction and declared-output kind anchoring.
|
||||
Strict expression operands gather actual missing/failure causes; Boolean
|
||||
control flow evaluates only the selected branch. Supported domain failures
|
||||
are distinct from violated predicates and missing values.
|
||||
- Whole accepted PortableDocument freeze with exact SourceBundle, local
|
||||
definitions, all imported package contents/pins and validated provenance.
|
||||
Replay resolves only that immutable private capture. A result report, shallow
|
||||
case copy or current cache is not a reproducible freeze.
|
||||
- Exact archive/reference/history identity alongside verified same-origin
|
||||
typed-contract equivalence. Different nominal origins remain distinct.
|
||||
Compatibility supplies a scoped expected/actual field-path and operation
|
||||
correspondence; it never advances a pin or rewrites outside record identities.
|
||||
Keep qualified neutral definitions, immutable mapped layered definitions and
|
||||
ordinary editable pattern instances for the first coherent implementation.
|
||||
- Explicit source preparation/acceptance with complete version-controlled
|
||||
package/base/map/lineage state, recomputed admission and stale-input rejection.
|
||||
Exact three-way facets retain independent local edits. Mounted OA/SA transfer
|
||||
requires a verified exact target-layer export or an explicitly independent
|
||||
editable local derivative; no relabeling or silent mutation of a mount.
|
||||
|
||||
These are architecture decisions, not claims that the proposed schema8 examples
|
||||
are admitted by current source7. Before real consumer integration, enumerate and
|
||||
implement the class-specific canonical contract over all interpretation-bearing
|
||||
fields and use sites, exact transitive archive closure and full source/history
|
||||
boundaries. Retain the frozen120-assertion prototype with its documented C2
|
||||
threshold-unit coverage gap; production typing must check every operand.
|
||||
|
||||
Close independent C1 before evaluator expansion: a request-local, profile-versioned
|
||||
logical work meter must bound dynamic expression visits, calls, list traversal
|
||||
and exact-number work. Static acyclicity/depth/node limits do not bound expanded
|
||||
execution. Cache warmth must not change outcomes; terminal exhaustion stops
|
||||
further traversal and marks collected causes as incomplete. Numeric ceilings and
|
||||
the precise charging table require measured foundation implementation before
|
||||
publishing the profile. The333-node/31-level counterexample and a nearby successful
|
||||
case are mandatory tests across direct/API/frozen replay.
|
||||
|
||||
The exact numeric foundation is the next bounded core implementation. Ordinary
|
||||
engineering approximations, a complete low-noise text workflow, two-project
|
||||
library maintenance and native/API/source parity remain required release work.
|
||||
An exact-only slice cannot complete the user's first release.
|
||||
|
||||
## Analysis production staging — 2026-09-11
|
||||
|
||||
Root accepts the concrete [production integration sequence](release-analysis-production-integration-plan-20260911.md).
|
||||
Allocate schema8 to the next coherent production analysis integration. Start with
|
||||
the actual Project's neutral definition catalog, three analysis element kinds,
|
||||
typed expressions and a closed Local definition-reference environment. Imported
|
||||
references will be admitted only with the real package environment; no placeholder
|
||||
resolver, guessed pin, implicit type compatibility or competing package format.
|
||||
This stages implementation and does not defer the required maintained libraries.
|
||||
|
||||
The first useful gate is the complete persisted/source-authored response budget,
|
||||
shared two-parameter calculation and check: baseline7/4 seconds versus degraded9/4,
|
||||
with explicit request context and the same core result through CLI and REST.
|
||||
Required property fields cannot come from generic factory guesses. Existing
|
||||
descriptive expressions/unit labels retain their exact old meaning and bytes.
|
||||
Freeze older top-level wire DTOs before making definitions required in schema8;
|
||||
reject new fields/constructors in older versions instead of silently dropping them.
|
||||
Global, scoped parameter, imported and historical identities remain distinct.
|
||||
|
||||
Implement pure static numeric type helpers once and reuse them for admission and
|
||||
runtime. Validate every effective case graph and unused branch before accepting a
|
||||
candidate; deleting dependencies requires explicit same-batch repair. Numeric
|
||||
domain failure is not a static type failure. Full evaluator traversal and actual
|
||||
C1 input, provenance, terminal incomplete causes and immutable replay are required
|
||||
before claiming evaluated engineering behavior. Until explicit analysis review/
|
||||
transition facets exist, those entry points must reject unsupported analysis with
|
||||
a clear capability error, including manual-trace paths that could otherwise appear
|
||||
to reconcile headers alone.
|
||||
|
||||
Logical expression depth128 must be reconciled with bounded actual JSON/source
|
||||
decoding; do not simply disable recursion limits. Raw byte, decoded string and
|
||||
aggregate literal/graph/case-admission limits are separate from evaluation fuel.
|
||||
Concrete decoder/admission ceilings remain subject to bounded boundary and timing
|
||||
experiments; the implementer must surface those choices before locking the profile.
|
||||
The current local numeric gate runs against frozen root source. New core contract
|
||||
work proceeds in an isolated checkout, preserving root's Cargo/native leases and
|
||||
the other hosts' CLI, staged-source and B4 ownership.
|
||||
|
||||
+29
-20
@@ -27,11 +27,11 @@ broad downstream changes. Do not silently promote a design proposal to a decisio
|
||||
|
||||
| Milestone | Work and acceptance | Dependency/current limit |
|
||||
| --- | --- | --- |
|
||||
| 1. Reliable direct authoring | Exercise actual minimum/wide native creation, allocation, editing, rejection, cancellation and history without implicit draft application or placement drift. Integrate compact named behavior creation after its coherent external gate. | Full local desktop suite passes 642/642 at80cf197; GUI build/native and strict lint remain open. Workspace/B3/palette/recovery/packages are imported. Root owns this host's Cargo/native lease. |
|
||||
| 2. Coherent process/scenario work | Interactive ordered/explicit process editing; clicked root/nested scenario insertion; exact reuse bindings and caller return; measured canvas/zoom/scroll; visible scoped Create/Delete lifetimes. Validate model/source/identity and no unintended edit during navigation, including stale/deleted/retargeted references and recovery. | B1/B2/B3 regressions pass in the full suite. B4 is cfg(test) projection only; real lifetime extents, branch-local activation clipping, incomplete lanes and self-arrow endpoints require integration. Use the independently self-checked oracle for native checkpoints. |
|
||||
| 3. Precise engineering analysis | Compare owned properties, analysis contexts and existing-data/external alternatives; settle quantity kind/unit/value, numerical policy, calculation/binding and Boolean/error/unknown semantics through falsifying examples. Implement mixed-unit budgets and reusable calculation/constraint definitions with full UI/source/REST parity and exact history/recovery. | New requirement audit confirms absence. Maximum-reasoning proposal tracks precede root synthesis and independent criticism; no semantic contract is ratified yet. Strings are not evaluation. |
|
||||
| 4. Maintained reusable libraries | Compare package/definition/instance alternatives. Establish pinned identity/version/dependency closure, local edits, import bindings, detach and explicit three-way updates. Demonstrate two projects, two independent instances, offline reopen, conflicting updates and stable unaffected identities. | Coordinate with milestone 3 definition references and migration. Same-project includes/reuse do not satisfy maintained cross-project libraries. No package contract implemented yet. |
|
||||
| 5. Understandable complete engineering tasks | Close teaching, OA↔SA transition/reconciliation, typed state/data/interface, artifact, saved-view and hosted workflows. Establish complete text-only creation/refactoring/modular maintenance with stable identity. Replace concrete long homogeneous selectors with contextual/searchable bounded choices; verify large models, keyboard access, minimum sizes and failure/recovery. Keep offline guide/examples aligned. | A complete warning-free text-only model probe passes source/identity/rename/reparent/file-move checks; measure usability separately. Anchored chooser is an undeclared widget experiment. Reassess current goal rather than copying historical scope exclusions. |
|
||||
| 1. Reliable direct authoring | Exercise actual minimum/wide native creation, allocation, editing, rejection, cancellation and history without implicit draft application or placement drift. Integrate compact named behavior creation after its coherent external gate. | Named creation4bb4aaf is imported; combined desktop676/676, format and strict Clippy pass at2cc4d11. Native80cf197 passes all four OA creations/history/reopen; new workspace fixes and production chooser await compilation. Workspace/B3/palette/recovery/packages are imported. Root owns this host's Cargo/native lease. |
|
||||
| 2. Coherent process/scenario work | Interactive ordered/explicit process editing; clicked root/nested scenario insertion; exact reuse bindings and caller return; measured canvas/zoom/scroll; visible scoped Create/Delete lifetimes. Validate model/source/identity and no unintended edit during navigation, including stale/deleted/retargeted references and recovery. | B1/B2/B3 regressions pass in the full suite. Local B4 remains cfg(test); Codex-ytr83r owns production Scene integration and its independent native gate. Use the independently self-checked oracle for native checkpoints. |
|
||||
| 3. Precise engineering analysis | Compare owned properties, analysis contexts and existing-data/external alternatives; settle quantity kind/unit/value, numerical policy, calculation/binding and Boolean/error/unknown semantics through falsifying examples. Implement mixed-unit budgets and reusable calculation/constraint definitions with full UI/source/REST parity and exact history/recovery. | B/shared-check/full-capture and R6/R7 architecture ratified after complete fixtures and criticism. Exact numeric/work-meter foundation is undeclared and under independent review. Approximate profile and full evaluator parity remain required. |
|
||||
| 4. Maintained reusable libraries | Compare package/definition/instance alternatives. Establish pinned identity/version/dependency closure, local edits, import bindings, detach and explicit three-way updates. Demonstrate two projects, two independent instances, offline reopen, conflicting updates and stable unaffected identities. | Exact pins plus verified same-origin typed compatibility, explicit source preparation/acceptance and three-way editable updates are ratified. Finite120-assertion experiment is not production contract/archive/consumer implementation. |
|
||||
| 5. Understandable complete engineering tasks | Close teaching, OA↔SA transition/reconciliation, typed state/data/interface, artifact, saved-view and hosted workflows. Establish complete text-only creation/refactoring/modular maintenance with stable identity. Replace concrete long homogeneous selectors with contextual/searchable bounded choices; verify large models, keyboard access, minimum sizes and failure/recovery. Keep offline guide/examples aligned. | A complete warning-free text-only model probe passes source/identity/rename/reparent/file-move checks; measure usability separately. Anchored chooser passes13 isolated mounted tests under cfg(test); production inspector/review integration is active; its compile/native gates remain open. Separate-host CLI is coherent at01b76a1 and awaits import here. Reassess current goal rather than copying historical scope exclusions. |
|
||||
| 6. Release confidence and delivery | Fresh current Windows/Linux suites, strict Clippy/format, normative reST/OpenAPI/migration/examples; actual GUI/service transport, optimized builds, runtime/notices/archive/provenance/checksums and clean-environment launch. Fresh independent requirements/domain/parity/architecture/UX/runtime review; resolve material findings, then final handoff. | Integrates all preceding capabilities. Linux runtime remains open; use an approved supported environment, preserving historical failed/denied experiments. Historical previews are not final distributions. |
|
||||
|
||||
Milestones are capability gates, not a rigid serial schedule. Root can continue
|
||||
@@ -42,28 +42,37 @@ cross-representation evaluators guide implementation boundaries.
|
||||
|
||||
## Immediate execution sequence
|
||||
|
||||
1. Finish the current GUI build and format/strict Clippy checks, then run native
|
||||
engineering tasks. The current 642-test desktop suite has passed; rerun for new
|
||||
code or unresolved failures, not as a substitute for runtime evaluation.
|
||||
2. Exchange exact verified commits with the separate host, preserving its workspace
|
||||
ownership. Do not merge the bundle-transfer branch or assume remote publication.
|
||||
3. Use the self-checked [scenario oracle](scenario-authoring-contract.rs), follow
|
||||
1. Complete the coherent workspace/production chooser build and focused gates,
|
||||
then stage a new GUI for real engineering tasks. Full676/format/Clippy passed
|
||||
for2cc4d11; working changes require their own verification.
|
||||
2. Implement the user-required common canvas interactions for Process and Scenario
|
||||
in issue2 after the bounded shared-viewport/domain-constraint design decision.
|
||||
Treat this as direct-manipulation acceptance, not a tool-shelf restyle.
|
||||
3. Exchange exact verified commits with the separate host, preserving its workspace
|
||||
ownership. Direct authenticated publication works. CLI01b and exact evidence2ec
|
||||
are imported; next contextual-source and B4 handoffs retain their owners.
|
||||
4. Use the self-checked [scenario oracle](scenario-authoring-contract.rs), follow
|
||||
its [native procedure](fixtures/scenario-authoring/README.md), and compare exact
|
||||
whole snapshots for Undo/Redo/reopen at minimum/wide sizes.
|
||||
4. Resolve the paired semantic criticism with concrete text-only package normalization,
|
||||
cosmetic revision/type compatibility, subjectless analysis closure, OA/SA mounted
|
||||
data transitions, baseline-versus-case maintenance and closed numeric algebra
|
||||
experiments. Select coherent contracts before production metamodel expansion.
|
||||
5. Update current ledgers and durable decisions when evidence changes the plan.
|
||||
5. Exact numeric/unit/work-meter foundation26/26 unit tests pass at7d14bd0; complete
|
||||
its strict gate while actual schema8 core work proceeds in an isolated worktree.
|
||||
Extend actual model/source/API/GUI under the ratified
|
||||
B/R6/R7/full-capture and exact-pin/typed-compatibility contracts. Preserve explicit
|
||||
approximate numeric, complete consumer/archive and two-project release gates.
|
||||
6. Update current ledgers and durable decisions when evidence changes the plan.
|
||||
Reassess unresolved assumptions before expanding downstream implementation.
|
||||
|
||||
## Coordination and evidence rules
|
||||
|
||||
Use Gitea issue #1 and Wiki for current ownership/handoffs; read before resuming.
|
||||
Codex-956atf and Codex-9e8r6t are distinct hosts. Cargo/native leases are host-local;
|
||||
shared source ownership is coordinated. Root owns local integration and final
|
||||
responsibility. Both hosts currently lack direct push authentication; authenticated
|
||||
MCP and anonymous fetch support verified bundle exchange without shared-ref overwrite.
|
||||
Codex-956atf, Codex-9e8r6t and Codex-ytr83r are distinct hosts. Cargo/native leases
|
||||
are host-local; shared source ownership is coordinated.9e8r6t owns the next runnable
|
||||
Windows named-authoring prerelease and contextual-source/CLI; ytr83r owns B4 Scene
|
||||
and its platform gates. Root owns local chooser/numeric/workspace integration.
|
||||
Updated AGENTS requires runnable milestone prereleases. User#178 authorizes existing
|
||||
MCP credential reuse for Git/REST; secrets must never be printed or committed.
|
||||
Direct branch publication replaces bundle work when available; old exact bundles
|
||||
remain valid evidence and transfer branches must not be merged.
|
||||
|
||||
Record exact source/binary, command exit, test scope and native model/source oracle.
|
||||
Do not add focused reruns to full-suite totals or transfer old binary acceptance.
|
||||
|
||||
+119
-76
@@ -1,6 +1,6 @@
|
||||
# Progress Ledger — first release
|
||||
|
||||
Updated 2026-09-11, coordination through Gitea #116. This ledger describes the
|
||||
Updated 2026-09-11, coordination through Gitea #245 and issue2. This ledger describes the
|
||||
Codex-956atf checkout; external-host evidence is labeled separately. The entire
|
||||
previous ledger is preserved [byte-for-byte](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md).
|
||||
Historical dates, binaries, process IDs and stopped-session instructions in that
|
||||
@@ -15,7 +15,15 @@ acceptance. Direct behavior/scenario authoring and reduced cognitive load remain
|
||||
immediate priorities. Complete small and large models must be practical to develop
|
||||
through uncluttered text alone. The release is incomplete.
|
||||
|
||||
Local branch: `codex/behavior-authoring-20260910`; implementation checkpoint `80cf197be6f1d400bf0b9c84bf8557f88c895aa6`.
|
||||
Local branch: `codex/behavior-authoring-20260910`. Last compiled source checkpoint
|
||||
is `2cc4d115d0ff28423c7ed87fd5f4065efb011f2d`, including external named creation
|
||||
`4bb4aaf` as `1516949`, the isolated chooser and final baseline/library contracts.
|
||||
Full desktop676/676, formatting and strict Clippy pass at that source. The active
|
||||
native GUI remains immutable80cf197. New workspace and production chooser changes
|
||||
are outside those artifacts and await a coherent build. Current committed source
|
||||
adds exact numeric foundation7d14bd0, imported CLIbc311bc and evidence/native
|
||||
dc0f2e1. Numeric26/26 unit tests and strict core all-target Clippy pass. Shared
|
||||
instruction ancestrycab01c3 is merged asc7ed894, retaining the exact uploaded goal.
|
||||
The authoritative goal was copied byte-identically from attachment
|
||||
`a49d1780-25d5-4778-ad68-6140c60e4207/goal-objective.md`; SHA256
|
||||
`DD58F75C93D55FE16ED141A23D834898FB6EBC625AE7E7AE379DA552A8312435`.
|
||||
@@ -24,106 +32,139 @@ for capability acceptance. Older goal paths are superseded.
|
||||
|
||||
## Completed work and evidence
|
||||
|
||||
The creation bundle was fetched anonymously and verified at105022 bytes,
|
||||
SHA256 `0DEFAE91A56430076C432758F923BC0675911DA9FA7588C9961FF15F47EAEBF6`.
|
||||
Its coherent commit adds named Process/Chain/Scenario/Machine drafts, bounded
|
||||
owner search, recovery/selection guards, offline guide and measured textual
|
||||
examples. The duplicate Sensor correction was not reapplied. Both hosts' report
|
||||
additions and exact experiment bytes are retained; this host's goal and ledgers
|
||||
remain authoritative locally. External653-test/native evidence is attributed in
|
||||
[its integrated report](release-integrated-gates-20260910.md), not claimed as a
|
||||
local run. The separate isolated chooser at `c4e1580` passed13/13 mounted tests
|
||||
under cfg(test); it is not a production selector yet.
|
||||
|
||||
| Scope | Current evidence and limits |
|
||||
| --- | --- |
|
||||
| Reproducible local toolchain | Ignored `.local/toolchain/env.ps1` selects Rust/Cargo 1.98.1 GNU/LLVM and verified LLVM-MinGW 20260908 (clang 23.1.1), with one Cargo job. First cold desktop test build completed in 58m03s; one unused import warning was corrected separately. |
|
||||
| B1 process authoring | `01bb52a` includes interactive empty/ordered/explicit topology and presentation-only inferred paths; first conversion preserves ordered adjacency atomically. All topology regressions pass within the current full desktop suite. Native acceptance is pending. |
|
||||
| B2 scenario authoring | Clicked root/operand/slot and optional lifeline feed Action/State/Reuse/Fragment adapters. Pointer-to-App, cancellation, stale-event, recovery and history tests pass within the current full desktop suite. |
|
||||
| B3 caller navigation and workspace | External `3978bbe` imported as `865cd55` fixes visible-owner restoration, pending-draft guards and remaining-height workspace. Root `80cf197` keys the viewport Sensor by document/scenario; a real mounted regression failed before the fix and now passes. Current full desktop suite is green; native acceptance remains pending. |
|
||||
| B3 caller navigation and workspace | External `3978bbe` imported as `865cd55` fixes visible-owner restoration, pending-draft guards and remaining-height workspace. Root `80cf197` keys the viewport Sensor by document/scenario; a real mounted regression failed before the fix and now passes. The full suite is green; actual OA caller return and source-neutral navigation pass. Remaining native scope is explicit below. |
|
||||
| Architecture palette | External palette commit `7d1df4f` was cherry-picked as `aed40d7`, retaining local invariant tests. Captured Escape, stale Submit/raw retention and bounded card fixes are source-integrated. External native results do not transfer to this checkout. |
|
||||
| Typed stored-history F1 | `b6b76ef` fixes nullable constructor cache discrimination; `4ecad78` fixes NotApplicable seeding by checking actual enum alternatives. Thirteen focused tests passed and are included in the current full suite; core validation remains authoritative. |
|
||||
| Lifeline lifecycle B4 | `eb5161b` adds pure local/scope-aware lifecycle projection with ten tests, declared under `cfg(test)` only. It is not production Scene wiring or native acceptance. [Design](release-scenario-lifecycle-design-20260910.md). |
|
||||
| Native scenario oracle | [Independent checker](scenario-authoring-contract.rs) compiled/self-checked and verified the generated62-element/two-source baseline. Native OA root Action and exact Save/Undo/Redo pass at minimum size; native caller identity/scroll return and source-neutral navigation pass. Remaining prescribed checkpoints are open. [Native scope](release-scenario-native-20260911.md), [oracle inputs](fixtures/scenario-authoring/README.md). |
|
||||
| Native scenario oracle | [Independent checker](scenario-authoring-contract.rs) compiled/self-checked and verified the generated62-element/two-source baseline. Native OA Action/State/Reuse/ALT-fragment, each exact Save/Undo/Redo, and final native reopen pass at minimum size; caller identity/scroll return and source-neutral navigation pass. Remaining prescribed checkpoints are open. [Native scope](release-scenario-native-20260911.md), [oracle inputs](fixtures/scenario-authoring/README.md). |
|
||||
| Normative documentation | Strict Docutils 0.22.2 language HTML regeneration passed. `semantic_schema.py --check --validate` passed 72 source7 fixtures, 204 schemas, 14 families/90 scalar leaves. These are documentation/schema gates, not a Rust build or runtime pass. |
|
||||
| Comparable-product study | Actual isolated Capella 7.1 scenario/activity/lifeline/LOOP creation was saved and XML inspected, then closed normally. [Report](release-capella-scenario-benchmark-20260910.md). Current Studio native evidence is recorded separately. |
|
||||
| New requirement audit | [Independent delta report](release-requirements-delta-20260910.md) establishes that current expressions/unit labels are descriptive and source includes are not maintained cross-project libraries. Separate foundational design proposals remain under investigation, not ratified or implemented. |
|
||||
| New requirement audit | [Independent delta report](release-requirements-delta-20260910.md) establishes that current expressions/unit labels are descriptive and source includes are not maintained cross-project libraries. Refined B/R6/R7 and exact-pin/same-origin compatibility contracts are ratified; current source7 does not implement them. |
|
||||
| Packaging integration | External `b98f135` imported as `6d2b961`; all 25 checked-in exact notices match the manifest. `scripts/test-package-artifacts.py` passes 11/11 and `scripts/test-windows-notices.ps1` passes synthetic package/runtime/source/font checks. Actual optimized distributions and clean-environment launches remain open. |
|
||||
|
||||
## Active work, ownership and unresolved findings
|
||||
|
||||
Root Codex-956atf owns this host's integration, serialized Cargo and native UI.
|
||||
The integrated command `cargo test --locked -p oa-desktop --bin arcadia-studio -- --test-threads=1`
|
||||
completed with exit 0: **642 passed, zero failed/ignored**, tests 141.25 seconds,
|
||||
warning-free build 36.31 seconds. Log `.local/logs/desktop-integrated-20260911.log`.
|
||||
Implementation inputs match `80cf197`. The executable is
|
||||
`target/debug/deps/arcadia_studio-58e30cee1fd51a8b.exe`, SHA256
|
||||
`DF122CAF7CFB63A3A9D0D2F2098944CF88F53C4ADA2C43B576B2836F6FD42BE8`.
|
||||
The GUI build passed in3m28s; executable SHA256
|
||||
The source2cc4d11 gate completed with exit0: **676 passed, zero failed/ignored**,
|
||||
tests611.24 seconds after warning-free compilation10m47s. Command:
|
||||
`cargo test --locked -p oa-desktop --bin arcadia-studio -- --test-threads=1`.
|
||||
Log `.local/logs/desktop-creation-chooser-20260911.log`. Immutable executable
|
||||
`.local/creation-chooser-20260911/tests-2cc4d11/arcadia-studio-tests.exe`, SHA256
|
||||
`8D7FB35071F155B3BD07CE1885A1136B2D2EF71C4FF8C704665202499C86A721`.
|
||||
Its manifest pins256 tracked input hashes. Format passed; strict all-target desktop
|
||||
Clippy passed7m18s after the isolated manual-clamp correction. Final logs are copied
|
||||
beside the immutable binary. These gates supersede the historical642 total, not the
|
||||
current uncompiled working changes. Focused counts are included, never additive.
|
||||
|
||||
Actual native Studio remains `.local/native-qa/studio-80cf197/`, GUI SHA256
|
||||
`A5ED6DF334B1BFB620425AFDF3E863F0B522990E6FB47C6C401A71CF4443954A`.
|
||||
Studio is running from immutable `.local/native-qa/studio-80cf197/`. Actual caller
|
||||
identity/scroll return, source-neutral navigation and minimum-size OA root Action
|
||||
creation with exact Save/Undo/Redo comparisons pass. [Native evidence and remaining
|
||||
gates](release-scenario-native-20260911.md). Formatting passed. Strict Clippy found
|
||||
two collapsible-if cases; root corrected them. The isolated chooser is now declared
|
||||
under cfg(test). Its warning-free first compile completed;9/12 focused tests pass.
|
||||
Three failures trace to test inputs that sent whole strings as single physical key
|
||||
events; the worker is correcting realistic typing and adding paste/IME coverage.
|
||||
No production chooser integration or current passing whole-suite claim follows.
|
||||
That GUI build passed3m28s. At1100x700, all four OA creation checkpoints and final
|
||||
reopen now pass complete Project/two-source prediction and exact UUID restoration.
|
||||
Preserved OA snapshots end in `oa-reopened/`; separate SA Action Save/Undo/Redo
|
||||
also passes complete oracle/history comparisons, ending in `sa-action-redo/`,
|
||||
63objects. The GUI is saved at SA A caller, no active form. SA State/Reuse/Fragment
|
||||
and final reopen remain. No new-fix acceptance is inferred.
|
||||
[Native report](release-scenario-native-20260911.md) records details and two real
|
||||
workspace findings: new forms retain downward scroll, and opening a form can hide
|
||||
its clicked marker below the reduced canvas. Root has written a fresh form-session
|
||||
key, guarded Name focus, one-time marker reveal and three meaningful mounted/App
|
||||
regressions. They are not yet compiled. The isolated chooser's corrected realistic
|
||||
typing/paste/IME gate passes13/13; production inspector/review integration is now
|
||||
source is now frozen, with exact row/facet/group/attachment guards. Independent
|
||||
review exposed and closed three focus defects at source level, including the
|
||||
legitimate Review rationale input. Fourteen new App/mounted tests await execution;
|
||||
the earlier isolated13 is separately scoped. [Review](release-reference-chooser-production-review-20260911.md).
|
||||
|
||||
Historical first-binary runs with `--test-threads=1` passed history 13/13, pure lifecycle
|
||||
10/10, topology 45/45, architecture authoring 18/18 and mounted scroll 5/5. Scenario
|
||||
direct tests passed 13/14 and reproduced the known caller-return assertion failure
|
||||
at `scenario.rs:2473`; do not count that filter as passing. Logs are
|
||||
`.local/logs/history-fields-focused.log` and `.local/logs/focused-*.log`. The current
|
||||
642/642 suite supersedes that known failure after its correction; do not add filter
|
||||
counts to the full total. Gitea #97's exact integration bundle was anonymously fetched,
|
||||
decoded, SHA256-verified and admitted with `git bundle verify`; selected source and
|
||||
packaging commits are integrated. Source the local toolchain before commands and keep
|
||||
one Cargo writer. The independent checker used completed rlibs without another
|
||||
Cargo writer; its evidence is separately scoped above.
|
||||
User [issue2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2)
|
||||
requires Process and Scenario to behave as actual canvases like architecture/state
|
||||
diagrams. Current scrollable diagrams/tool shelves do not close that requirement.
|
||||
behavior_audit owns a bounded shared-canvas design; root re-observed the saved
|
||||
native scenario and its restricted viewport, without new model input.
|
||||
|
||||
Codex-9e8r6t is a separate host/team with a warm cache. It owns scenario workspace
|
||||
layout/B3 correction, local named behavior creation, palette and platform/packaging.
|
||||
Its coherent workspace and packaging checkpoint is imported. New local named
|
||||
behavior creation passed its 20 focused tests (#115), with broader checks/review
|
||||
and a verified transfer still pending. Its current native basic-editing checkpoint
|
||||
passed its independent model/four-source oracle; that is external evidence, not
|
||||
this host's acceptance. It owns docs/language.rst source7/default corrections,
|
||||
examples/release1 README schema correction and local-creation README guidance (#116).
|
||||
The [baseline contract](release-analysis-baseline-contract-20260911.md) supplies a
|
||||
complete17-UUID proposed model,11 exact fixtures, explicit ModelBaseline/Case,
|
||||
property bases and shared checks. Its transport fixtures are not parser/evaluator
|
||||
acceptance. The [library contract](release-library-compatibility-contract-20260911.md)
|
||||
has120 executed finite-prototype assertions, separate-process reopen and11 complete
|
||||
expected/reopened file comparisons. Final81-file manifest was independently
|
||||
verified; preliminary output is retained and labeled superseded. All166 experiment
|
||||
files retain exact Git bytes. Root ratified B/R6/R7/full private capture, nominal
|
||||
typed compatibility alongside exact pins, explicit source normalization and OA/SA
|
||||
dispositions in [DECISIONS](DECISIONS.md).
|
||||
|
||||
B3 diagnosis (#79): command execution may preselect an occurrence while another
|
||||
scenario remains visible; `select(same_id)` then returns without restoring the
|
||||
caller owner. The imported correction now establishes owner and selection together.
|
||||
Root's additional Sensor regression proves same-size owner/document changes must
|
||||
remeasure Fit; see `.local/logs/scenario-sensor-before-fix.log` for the red result.
|
||||
Exact source/history and mounted viewport checks pass in the integrated suite.
|
||||
The [independent refined review](release-refined-contracts-review-20260911.md)
|
||||
passes292 fixture/identity checks but finds C1:333 stored AST nodes can expand to
|
||||
over2billion calls. Request-local dynamic/numeric work accounting is mandatory;
|
||||
static depth/node limits alone fail. C2 is a finite-prototype threshold-unit gap,
|
||||
not a passing production type-checker. Exact arithmetic, unit/dimension operations
|
||||
and terminal work meter are declared/committed at7d14bd0; their26/26 unit gate has
|
||||
[exact source/artifact evidence](release-analysis-numeric-gate-20260911.md).
|
||||
requirements_delta now owns actual schema8 definitions, expressions, commands,
|
||||
references, migration and static admission in the isolated
|
||||
`.local/analysis-core-worktree-20260911/`, based at7d14bd0. It remains uncompiled.
|
||||
semantics_critic reviews the bounded decoder strategy separately; existing default
|
||||
recursion rejection stays until a full allocation/lifecycle gate is justified.
|
||||
Root owns compilation. Numeric/runtime and static-admission ceilings remain
|
||||
explicitly provisional pending measured gates. Approximate engineering calculations,
|
||||
full evaluator/model/source/REST/GUI parity and actual library maintenance are open.
|
||||
|
||||
Analysis/quantity and library proposals plus paired independent criticism are ready
|
||||
for synthesis. Remaining material questions: text-only instance base/map creation,
|
||||
cosmetic package updates versus quantity/type compatibility, subjectless case export
|
||||
closure, mounted data OA/SA transition policy, repeated case assumptions and closure
|
||||
of anonymous dimension operations. No production contract is ratified. Isolated
|
||||
text-only source experiments, baseline-versus-complete-case experiments and an
|
||||
undeclared anchored reference chooser widget are active; their passing claims must
|
||||
remain separate from the 642-test implementation. Root owns their integration.
|
||||
Codex-9e8r6t and Codex-ytr83r are separate hosts/teams. The former owns contextual
|
||||
prerequisite creation and source CLI. CLI01b76a1 is imported asbc311bc; exact
|
||||
corrected evidence2ec72d4 is imported atdc0f2e1, preserving root's separate native
|
||||
report. External675 staged-source and CLI12/183-assertion results remain externally
|
||||
attributed. Its runnable Studio/server preview release1 and standalone CLI preview
|
||||
release2 are published; see their release notes for exact source/runtime limits.
|
||||
Strict language/API HTML regeneration and the72-fixture source7 schema gate pass
|
||||
locally after that evidence import. Codex-ytr83r owns B4
|
||||
production Scene wiring, independent native oracle and local Linux route. Its
|
||||
rendering source has independent criticism but no passing build/native gate yet.
|
||||
Do not duplicate these changes. Preserve root's distinct scenario-scroll/focus hook
|
||||
when integrating other hosts' small App/behavior bridges.
|
||||
|
||||
B4 independent Scene review (#114) identifies real integration boundaries: insertion
|
||||
sequence bounds are not lifetime extents; activation tails must not continue past
|
||||
branch-local Delete; incomplete represented=None lifelines still need geometry;
|
||||
self Create/Delete arrives at row+24. The pure projection has not yet been wired.
|
||||
|
||||
Both hosts report direct Git push authentication unavailable. Anonymous fetch and
|
||||
authenticated Gitea MCP remain usable. Exact verified bundles are exchanged on
|
||||
`codex/behavior-transfer-20260910`; do not merge that transfer branch. Existing
|
||||
bundles cover `01bb52a`, `b6b76ef`, `eb5161b` and incremental `4ecad78`; exact hashes,
|
||||
prerequisites and readback are preserved in the [archived ledger](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md)
|
||||
and Gitea #65/#76. Latest verified outgoing transfer is `harness/transfers/behavior-aa40345.bundle.b64` on that branch (Gitea #92): 24,926 decoded bytes, prerequisite `4ecad78`, SHA256 `21B36D5016600953D95F064AE56569B6C8E7D3EA2B3B316C97222AD122FECA4F`. Later native oracle, proposals, ledgers and Sensor correction still need publication. Root owns subsequent transfer and integration.
|
||||
Updated AGENTS4b8465c8 adds concise shared maintenance to milestone releases. Gitea
|
||||
user#178 explicitly authorizes programmatic reuse of existing MCP credentials for
|
||||
Git/REST and relevant commits/tags/prereleases, without printing or committing them.
|
||||
Direct authenticated push/readback passed on this host, most recently publishing
|
||||
dc0f2e1. The subsequent owner's instruction commitcab01c3 has been fetched and
|
||||
merged locally; no force push or additional credentials are required.
|
||||
Historical verified bundle exchange remains reproducible: c6d296a bundle on
|
||||
`codex/behavior-transfer-20260910`,334830 decoded bytes, SHA256
|
||||
`526A80E2D32ED0E57A7B2372B5F02D4652E8751DFEBA81953C4F045225D6AACB`,
|
||||
prerequisite1797516. Both other hosts received it. Never merge transfer branches.
|
||||
Older bundles/logs retain historical scope in Git and the archived ledger.
|
||||
|
||||
## Remaining gates and highest-value next actions
|
||||
|
||||
1. Finish the active GUI build and current format/strict Clippy gates. The full
|
||||
desktop suite has passed; repeat only for new source changes or unresolved risk.
|
||||
Record exact implementation/binary and run the real application.
|
||||
2. Integrate the externally verified workspace/B3/local creation/palette/package
|
||||
checkpoints without overwriting ownership or treating their earlier tests as new
|
||||
local acceptance. Wire B4 only after coordinating actual Scene geometry.
|
||||
1. Freeze and compile the new workspace/chooser integration, resolve focused
|
||||
failures, then build an immutable GUI and verify both native workspace fixes.
|
||||
The676-test gate is complete for2cc4d11; new changes require their own evidence.
|
||||
2. Publish coherent checkpoints directly; integrate9e8r6t's next staged-source
|
||||
work, then ytr83r B4 only after its coherent handoff. CLI/evidence import is done.
|
||||
Preserve ownership and distinguish every external versus local gate.
|
||||
3. Use the self-checked independent oracle to exercise current Studio at
|
||||
minimum/wide sizes: named creation, direct process/scenario edits, nested reuse,
|
||||
caller return, lifeline lifecycle, invalid/cancel/recovery, Save/Undo/Redo/reopen.
|
||||
Compare complete model/source and exact identities, not screenshots alone.
|
||||
4. Resolve analysis/quantity/library alternatives against real engineering budgets,
|
||||
unit/kind failures and two-project reuse/update examples. Then implement precise
|
||||
shared-core/source/API/graphical contracts and large-model selection workflows.
|
||||
4. Compile/review/measure the numeric foundation, then implement the ratified
|
||||
analysis/library contracts with shared-core/source/API/graphical parity. Use real
|
||||
budgets, unit/kind failures, complete text-only and two-project update workflows.
|
||||
Integrate the bounded chooser and run its actual keyboard/minimum-size gates.
|
||||
5. Complete current teaching, transitions, semantic review, state/data, saved-view,
|
||||
artifact, hosted transport and migration acceptance. Historical scoped passes
|
||||
remain useful references, never blanket current acceptance.
|
||||
@@ -137,8 +178,10 @@ and Gitea #65/#76. Latest verified outgoing transfer is `harness/transfers/behav
|
||||
## Reassessment
|
||||
|
||||
Meaningful progress continues: concrete interaction defects have been found and
|
||||
corrected, all 642 desktop tests pass, exact native evaluation is prepared,
|
||||
and the revised semantic requirements are no longer hidden behind metadata.
|
||||
corrected,676 desktop tests pass and all four OA native creation/history/reopen
|
||||
checks pass. Contracts have converged through independent falsification; numeric
|
||||
implementation now addresses the exposed dynamic-work risk. The revised semantic
|
||||
requirements are no longer hidden behind metadata.
|
||||
Passing older suites or accumulating prose would not close the user problem.
|
||||
Current highest information gain comes from actual build/native results and
|
||||
independently criticized foundational examples, while preserving existing identity,
|
||||
|
||||
+57
-33
@@ -7,6 +7,16 @@ Its old goal references, aggregate test claims and stopped-session directives re
|
||||
historical scope. New semantic decisions are recorded explicitly in DECISIONS.md;
|
||||
prototype results retain their separate scope.
|
||||
|
||||
Current compiled checkpoint2cc4d11 includes external creation4bb4aaf as1516949,
|
||||
isolated chooser13, and final baseline/library contracts. Local full676/format/
|
||||
strict Clippy pass; native80cf197 now passes four OA creations, exact history and
|
||||
reopen. Separate native SA Action/history also passes. Exact numeric foundation
|
||||
is declared at7d14bd0 and passes26/26 unit tests plus strict core all-target Clippy.
|
||||
Working workspace/production chooser changes remain outside compiled artifacts;
|
||||
independent chooser source review closed three focus findings,14 new host tests
|
||||
are prepared. Schema8 core implementation is isolated and uncompiled. External
|
||||
results retain their host/source attribution.
|
||||
|
||||
## Goal and success criteria
|
||||
|
||||
Deliver the usable, feature-complete OA/SA first release specified in
|
||||
@@ -43,7 +53,14 @@ cannot establish completion.
|
||||
at effort appropriate to the question. Root owns synthesis/integration.
|
||||
- Project instructions require Gitea coordination, ownership preservation and
|
||||
durable handoffs. Current root reserves one Cargo/native operator per host.
|
||||
Do not infer restart, usage reset or credential disclosure authorization.
|
||||
Updated AGENTS4b8465c8 requires coordinated runnable milestone prereleases and
|
||||
concise current shared maintenance with unique historical evidence preserved;
|
||||
user Gitea#178 authorizes programmatic existing-MCP-credential reuse for relevant
|
||||
Git/REST publication without printing/committing secrets. Restart and usage reset
|
||||
remain unauthorized.
|
||||
- Gitea issue2 explicitly requires actual Process/Scenario canvases with the same
|
||||
interaction approach as architecture/state diagrams. Existing pointer edits
|
||||
inside scrollable projections do not by themselves satisfy that requirement.
|
||||
|
||||
## Verified facts
|
||||
|
||||
@@ -53,7 +70,7 @@ cannot establish completion.
|
||||
are evaluator evidence, not native Studio acceptance. Exact inputs/hashes are in
|
||||
[the fixture instructions](fixtures/scenario-authoring/README.md).
|
||||
|
||||
- Current local implementation is `80cf197` on `codex/behavior-authoring-20260910`, following
|
||||
- The historical native implementation is `80cf197` on `codex/behavior-authoring-20260910`, following
|
||||
baseline `1797516`. Local commits cover B1/B2/B3 authoring, nullable history cache
|
||||
(`b6b76ef`), external palette integration (`aed40d7`), cfg(test) lifecycle projection
|
||||
(`eb5161b`), history seed correction (`4ecad78`) and exact-identity native oracle.
|
||||
@@ -61,9 +78,10 @@ cannot establish completion.
|
||||
owner restoration. Root80cf197 fixes document/scenario viewport Sensor identity
|
||||
with a real mounted red-before/green-after regression.
|
||||
- This fresh host has task-local Rust/Cargo 1.98.1 GNU/LLVM and verified LLVM-MinGW.
|
||||
The integrated full desktop suite passes642/642, zero failed/ignored, with a
|
||||
warning-free build. History and pure lifecycle tests are included. Current GUI
|
||||
build/native and strict lint remain open. [Exact hashes/logs](STATE.md).
|
||||
The integrated2cc4d11 desktop suite passes676/676, zero failed/ignored, with a
|
||||
warning-free build; formatting and strict Clippy pass. History, pure lifecycle,
|
||||
creation and isolated chooser are included. New working code still needs its
|
||||
own gate. Native80cf197 evidence is separate. [Exact hashes/logs](STATE.md).
|
||||
- Strict language HTML regeneration and the source7 OpenAPI checker passed locally:
|
||||
72 fixtures, 204 schemas and 90 scalar leaves. Actual isolated Capella scenario,
|
||||
activity/lifeline and LOOP creation was saved/inspected; this is reference study,
|
||||
@@ -84,11 +102,12 @@ cannot establish completion.
|
||||
scenario checkpoint; owner restoration and pending-draft guards pass current
|
||||
tests. B4 independent review #114 adds Scene integration requirements for actual
|
||||
lifetime extents, branch-local activation clipping, incomplete lanes and self arrows.
|
||||
- Codex-9e8r6t is a separate host with independent Cargo/native leases. It owns
|
||||
scenario workspace/B3/local behavior creation, palette and platform/packaging;
|
||||
coherent workspace/package integration is imported, while newer named behavior
|
||||
creation and docs changes await transfer. Both hosts report direct push auth
|
||||
unavailable. MCP/anonymous fetch and verified bundle exchange remain usable.
|
||||
- Codex-9e8r6t and Codex-ytr83r have independent host-local Cargo/native leases.
|
||||
Named creation/workspace/palette/package integration is imported here.9e8r6t's
|
||||
coherent CLI/evidence checkpoint01b76a1 awaits import; it owns staged-source and
|
||||
the next runnable Windows prerelease. ytr83r owns production B4 and its native
|
||||
oracle.9e8r6t successfully reused existing credentials for direct push; this
|
||||
host is setting up the same authorized publication path.
|
||||
- Current Linux acceptance is open; this host has no installed WSL distribution.
|
||||
Historical Linux core/server/cross-build results have exact older snapshot scope.
|
||||
The earlier denied guest workaround did not run and must not be bypassed.
|
||||
@@ -97,25 +116,27 @@ cannot establish completion.
|
||||
|
||||
- Compact contextual tools and an adequately sized scenario workspace can reduce
|
||||
friction; only actual engineering tasks at minimum/wide sizes can validate this.
|
||||
- A bounded pure analysis model and pinned reusable packages may satisfy the new
|
||||
requirements without full language/runtime adoption. This is a candidate, not a
|
||||
selected architecture or a permission to treat metadata as semantics.
|
||||
- The ratified bounded pure analysis and pinned-library architecture can satisfy
|
||||
real engineering work without full language/runtime adoption. This remains a
|
||||
product adequacy assumption until actual budgets, approximate calculations and
|
||||
two-project/source/API/native workflows pass; metadata is insufficient.
|
||||
- Existing shared core/source/history boundaries remain suitable for additions;
|
||||
foundational examples and independent criticism must test that assumption.
|
||||
|
||||
## Open questions
|
||||
|
||||
- What precise owned-value, calculation, constraint-result, quantity-kind, unit and
|
||||
numerical contracts meet mass/power/latency and affine/dimensional edge cases?
|
||||
- Which library definition/instance/pinning/binding/update design preserves stable
|
||||
identities, local engineering edits and offline reproducibility across projects?
|
||||
- Do the ratified owned-value/shared-check/R6/R7 contracts meet real engineering
|
||||
budgets once implemented, including approximate numeric requirements? What
|
||||
measured ceilings keep work bounded without rejecting ordinary studies?
|
||||
- Does actual package closure/canonical consumer typing/source preparation and
|
||||
three-way update preserve every stable identity and local engineering edit?
|
||||
- What migration and OA↔SA treatment follows from those contracts without guessing
|
||||
executable meaning from old strings or silently changing old models?
|
||||
- How can text-only creation establish library instance bases/maps, preserve
|
||||
compatibility across cosmetic package revisions, export subjectless cases, and
|
||||
transition mounted OA/SA data without hidden GUI normalization or silent forks?
|
||||
- Do explicit baseline values with one-level case overrides or complete per-case
|
||||
bindings better support repeated study maintenance and reproducible evaluation?
|
||||
- Will full evaluator and private-capture replay preserve effective-case and
|
||||
deterministic work-limit behavior across direct, REST and cache-warm execution?
|
||||
- Which source-preserving refactoring/navigation and grammar changes reduce actual
|
||||
engineering effort while retaining stable identity and cross-file ownership?
|
||||
- Do current real gestures, exact caller return, nested scopes and lifecycle markers
|
||||
@@ -140,11 +161,14 @@ versus analysis contexts; calculation definitions/applications; numeric/unit rul
|
||||
and copy, linked-definition, live-instance or pinned-definition/editable-instance
|
||||
library architectures. Require concrete falsifiers, source/API migration effects,
|
||||
identity/history/recovery behavior and independent critique before convergence.
|
||||
The completed analysis/library proposals and paired review remain unratified.
|
||||
An isolated executable text-only fixture admits without warnings and passes exact
|
||||
source/identity/rename/reparent/file-move/default checks. Separate baseline-versus-
|
||||
complete-case and anchored chooser experiments are active, with no production
|
||||
metamodel or selector integration implied.
|
||||
These alternatives converged after concrete experiments and independent review:
|
||||
B property bases/shared checks/full capture, R6/R7, exact pins with same-origin
|
||||
typed compatibility and explicit portable normalization are now ratified. Complete
|
||||
11-file proposed baseline and120-assertion finite library prototype remain distinct
|
||||
from production admission. Independent292-check review exposed C1 dynamic-work
|
||||
expansion and a C2 prototype threshold-unit gap; implementation must close both.
|
||||
The text-only40-identity fixture and13 mounted chooser tests pass. Production
|
||||
numeric foundation and inspector/review chooser are active, with gates pending.
|
||||
|
||||
The subsequent context experiment passed218 assertions: shared property baselines
|
||||
and one-level overrides reduce repeated input maintenance while retaining explicit
|
||||
@@ -154,8 +178,8 @@ quantity/library/source details remain open. [Decision](DECISIONS.md),
|
||||
|
||||
## Decisions retained
|
||||
|
||||
- Keep three Rust crates and one authoritative typed Project with validated atomic
|
||||
Commands. Separate semantic identity/ownership, allocation/reference/order and
|
||||
- Keep explicit Rust crate responsibilities and one authoritative typed Project
|
||||
with validated atomic Commands. A thin source CLI reuses the shared core. Separate semantic identity/ownership, allocation/reference/order and
|
||||
diagram geometry. Forms are closed adapters, not a second metamodel. New persistent
|
||||
semantics require core/source/UI/API/reference/transition parity. [Decisions](DECISIONS.md).
|
||||
- Preserve authored source ownership/comments/includes, exact identity and accepted
|
||||
@@ -184,13 +208,13 @@ quantity/library/source details remain open. [Decision](DECISIONS.md),
|
||||
|
||||
## Current strategy
|
||||
|
||||
Finish the GUI/native loop after the green full desktop suite while separate
|
||||
agents investigate uncertain foundations. Integrate only coherent, verified
|
||||
external checkpoints respecting ownership. Use independently constructed complete
|
||||
model/source oracles and exact snapshot comparison for identity/history. Ratify
|
||||
analysis/library contracts through alternatives and falsifying examples, then
|
||||
implement shared semantics and ergonomic authoring together. Close current
|
||||
cross-platform/service/package and independent end-state gates before handoff.
|
||||
Finish the new workspace/chooser compile and native loop; independently review
|
||||
and measure the numeric foundation before expanding the ratified semantics.
|
||||
Integrate coherent external CLI/staged-source/B4 checkpoints while preserving
|
||||
ownership and source attribution. Use independent complete model/source oracles
|
||||
and exact identity/history comparisons. Publish coordinated runnable milestone
|
||||
prereleases; close actual analysis/library/source/API/native, cross-platform,
|
||||
service/package and independent end-state gates before the final handoff.
|
||||
|
||||
The [archived Task Ledger](history/2026-09-10-ledgers-before-continuation-refresh/TASK_LEDGER.md)
|
||||
and [archived STATE](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md)
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
//! Independent native creation oracle; no desktop producer is imported.
|
||||
//! self-check BASE | check STAGE BASE ACTUAL ROLES | same EXPECTED ACTUAL [UNDO_FROM]
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const PROCESS: &str = "Coordinate rescue";
|
||||
const SCENARIO: &str = "Rescue coordination conversation";
|
||||
const MACHINE: &str = "Rescue team operating modes";
|
||||
const VIEW: &str = "Rescue team operating modes · states";
|
||||
const CAPABILITY: u128 = 0xb451c00000004000800000000000001f;
|
||||
const OWNER: u128 = 0xc062944912094b50afacf558a9a0fc7f;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Stage {
|
||||
Baseline,
|
||||
Process,
|
||||
Scenario,
|
||||
Machine,
|
||||
}
|
||||
impl Stage {
|
||||
fn parse(value: &str) -> Self {
|
||||
match value {
|
||||
"baseline" => Self::Baseline,
|
||||
"process" => Self::Process,
|
||||
"scenario" => Self::Scenario,
|
||||
"machine" => Self::Machine,
|
||||
_ => panic!("Stage must be baseline, process, scenario or machine"),
|
||||
}
|
||||
}
|
||||
fn additions(self) -> usize {
|
||||
match self {
|
||||
Self::Baseline => 0,
|
||||
Self::Process => 1,
|
||||
Self::Scenario => 2,
|
||||
Self::Machine => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DiskSources = BTreeMap<String, Vec<u8>>;
|
||||
|
||||
fn source_paths(root: &Path, here: &Path, paths: &mut DiskSources) {
|
||||
for entry in fs::read_dir(here).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
assert!(
|
||||
!kind.is_symlink(),
|
||||
"Native checkpoint must contain ordinary files"
|
||||
);
|
||||
if kind.is_dir() {
|
||||
source_paths(root, &entry.path(), paths);
|
||||
} else if entry.path().extension().is_some_and(|ext| ext == "arcadia") {
|
||||
assert!(kind.is_file());
|
||||
paths.insert(
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace('\\', "/"),
|
||||
fs::read(entry.path()).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn load_checkpoint(entry: &Path) -> (CompiledSourceProject, DiskSources) {
|
||||
let files = source_files::load_project(entry, ProjectLimits::default()).unwrap();
|
||||
let raw = files.reachable_bundle();
|
||||
let compiled = compile_bundle(&raw, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.bundle, raw,
|
||||
"No migration, normalization or implicit identity insertion"
|
||||
);
|
||||
execute_batch(&compiled.decoded.project, &[]).unwrap();
|
||||
let mut disk = BTreeMap::new();
|
||||
let root = entry.parent().unwrap();
|
||||
source_paths(root, root, &mut disk);
|
||||
(compiled, disk)
|
||||
}
|
||||
fn predicted_disk(reachable: &SourceBundle, undo_from: Option<&DiskSources>) -> DiskSources {
|
||||
// Saving Undo writes the restored reachable files and deliberately keeps
|
||||
// detached files from the previously saved checkpoint. Never infer their
|
||||
// bytes from the actual Undo directory: its explicit origin is the oracle.
|
||||
let mut expected = undo_from.cloned().unwrap_or_default();
|
||||
expected.extend(
|
||||
reachable
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, source)| (path.clone(), source.as_bytes().to_vec())),
|
||||
);
|
||||
expected
|
||||
}
|
||||
fn load(entry: &Path) -> CompiledSourceProject {
|
||||
let (compiled, disk) = load_checkpoint(entry);
|
||||
assert_eq!(
|
||||
disk,
|
||||
predicted_disk(&compiled.bundle, None),
|
||||
"Ordinary checkpoints must contain exactly their reachable source files; retained Undo files require an explicit origin"
|
||||
);
|
||||
compiled
|
||||
}
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf29ce484222325, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
})
|
||||
}
|
||||
fn pinned_baseline(base: &CompiledSourceProject) -> bool {
|
||||
// Fixed byte fingerprints of the root-preserved transition/reopen input,
|
||||
// recorded before any creation. SHA-256 provenance is in the fixture README.
|
||||
let pins = [
|
||||
("project.arcadia", 4844, 0x450c6ffd8225af32),
|
||||
("rationale.arcadia", 57, 0xf87e3c2e303f9b62),
|
||||
("traceability.arcadia", 395, 0xb47992dbdacab344),
|
||||
("oa/participants.arcadia", 403, 0x1e109eef81968b5f),
|
||||
(
|
||||
"oa/activities/activity_assess_incident.arcadia",
|
||||
376,
|
||||
0x0e67e533db3cff78,
|
||||
),
|
||||
("sa/participants.arcadia", 124, 0xcadb35025aea18cc),
|
||||
];
|
||||
base.bundle.entry == "project.arcadia"
|
||||
&& base.bundle.files.len() == pins.len()
|
||||
&& pins.iter().all(|(path, length, hash)| {
|
||||
base.bundle
|
||||
.files
|
||||
.get(*path)
|
||||
.is_some_and(|text| text.len() == *length && fnv1a(text.as_bytes()) == *hash)
|
||||
})
|
||||
}
|
||||
fn baseline(entry: &Path) -> CompiledSourceProject {
|
||||
let base = load(entry);
|
||||
assert!(
|
||||
pinned_baseline(&base),
|
||||
"The six-file baseline differs from the frozen pre-creation input"
|
||||
);
|
||||
let p = &base.decoded.project;
|
||||
assert_eq!(
|
||||
(
|
||||
p.elements.len(),
|
||||
p.relations.len(),
|
||||
p.diagrams.len(),
|
||||
p.traces.len()
|
||||
),
|
||||
(10, 2, 4, 1)
|
||||
);
|
||||
let context = &p.elements[&Id::from_u128(CAPABILITY)];
|
||||
assert_eq!(
|
||||
(
|
||||
context.name.as_str(),
|
||||
context.kind(),
|
||||
context.layer(),
|
||||
context.parent
|
||||
),
|
||||
("Rescue capability", Kind::Capability, Layer::Oa, None)
|
||||
);
|
||||
let owner = &p.elements[&Id::from_u128(OWNER)];
|
||||
assert_eq!(
|
||||
(
|
||||
owner.name.as_str(),
|
||||
owner.kind(),
|
||||
owner.layer(),
|
||||
owner.parent
|
||||
),
|
||||
("Rescue team", Kind::SystemActor, Layer::Sa, None)
|
||||
);
|
||||
for name in [PROCESS, SCENARIO, MACHINE] {
|
||||
assert!(
|
||||
!p.elements.values().any(|e| e.name == name),
|
||||
"Baseline already contains requested creation"
|
||||
);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
struct Roles<'a> {
|
||||
actual: &'a Project,
|
||||
baseline: &'a Project,
|
||||
used: BTreeSet<Id>,
|
||||
}
|
||||
impl Roles<'_> {
|
||||
fn fresh(&mut self, choices: Vec<Id>, role: &str) -> Id {
|
||||
assert_eq!(
|
||||
choices.len(),
|
||||
1,
|
||||
"Exactly one literal identity role required: {role}"
|
||||
);
|
||||
let id = choices[0];
|
||||
assert_eq!(id.get_version_num(), 4, "{role} must be a fresh UUIDv4");
|
||||
assert!(
|
||||
!self.baseline.contains(id),
|
||||
"{role} reuses a baseline identity"
|
||||
);
|
||||
assert!(self.used.insert(id), "{role} reuses another fresh role");
|
||||
id
|
||||
}
|
||||
fn element(&mut self, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
self.fresh(
|
||||
self.actual
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == kind && e.layer() == layer && e.name == name)
|
||||
.map(|e| e.id)
|
||||
.collect(),
|
||||
name,
|
||||
)
|
||||
}
|
||||
fn view(&mut self, machine: Id) -> Id {
|
||||
self.fresh(
|
||||
self.actual
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.layer == Layer::Sa
|
||||
&& d.name == VIEW
|
||||
&& d.projection == ProjectionKind::StateMachine { machine }
|
||||
})
|
||||
.map(|d| d.id)
|
||||
.collect(),
|
||||
VIEW,
|
||||
)
|
||||
}
|
||||
fn node(&mut self, diagram: Id, machine: Id) -> Id {
|
||||
self.fresh(
|
||||
self.actual.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| node.element == machine)
|
||||
.map(|node| node.id)
|
||||
.collect(),
|
||||
"new machine's exact view placement",
|
||||
)
|
||||
}
|
||||
}
|
||||
fn element(p: &Project, id: Id, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id,
|
||||
alias: p.fresh_alias(name, "behavior", id),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
fn advance(base: &mut CompiledSourceProject, commands: Vec<Command>) {
|
||||
let project = execute_batch(&base.decoded.project, &[Command::Batch(commands)]).unwrap();
|
||||
*base = update_bundle(base, &project, ProjectLimits::default()).unwrap();
|
||||
}
|
||||
fn predict(base: &CompiledSourceProject, stage: Stage, carrier: &Project) -> CompiledSourceProject {
|
||||
let mut result = base.clone();
|
||||
let mut roles = Roles {
|
||||
actual: carrier,
|
||||
baseline: &base.decoded.project,
|
||||
used: BTreeSet::new(),
|
||||
};
|
||||
if stage >= Stage::Process {
|
||||
let id = roles.element(Kind::Process, Layer::Oa, PROCESS);
|
||||
let record = element(
|
||||
&result.decoded.project,
|
||||
id,
|
||||
PROCESS,
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Process { steps: vec![] }),
|
||||
);
|
||||
advance(&mut result, vec![Command::CreateElement(record)]);
|
||||
}
|
||||
if stage >= Stage::Scenario {
|
||||
let id = roles.element(Kind::Scenario, Layer::Oa, SCENARIO);
|
||||
let record = element(
|
||||
&result.decoded.project,
|
||||
id,
|
||||
SCENARIO,
|
||||
None,
|
||||
ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::ParticipantInteraction,
|
||||
context: Some(Id::from_u128(CAPABILITY)),
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![],
|
||||
},
|
||||
),
|
||||
);
|
||||
advance(&mut result, vec![Command::CreateElement(record)]);
|
||||
}
|
||||
if stage == Stage::Machine {
|
||||
let machine = roles.element(Kind::StateMachine, Layer::Sa, MACHINE);
|
||||
let diagram = roles.view(machine);
|
||||
let placement = roles.node(diagram, machine);
|
||||
let record = element(
|
||||
&result.decoded.project,
|
||||
machine,
|
||||
MACHINE,
|
||||
Some(Id::from_u128(OWNER)),
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::StateMachine),
|
||||
);
|
||||
let p = execute_batch(
|
||||
&result.decoded.project,
|
||||
&[Command::CreateElement(record.clone())],
|
||||
)
|
||||
.unwrap();
|
||||
// Published first-view contract, independently constructed: one root,
|
||||
// default options, no routes/regions/states. Shared core Layout owns the
|
||||
// final geometry; no observed geometry or desktop planner is consulted.
|
||||
let view = Diagram {
|
||||
id: diagram,
|
||||
alias: p.fresh_alias(VIEW, "state_view", diagram),
|
||||
name: VIEW.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
placement,
|
||||
NodePlacement {
|
||||
id: placement,
|
||||
alias: p.fresh_alias(&format!("{MACHINE} placement"), "placement", placement),
|
||||
element: machine,
|
||||
position: Position { x: 60., y: 60. },
|
||||
size: Size {
|
||||
width: 336.,
|
||||
height: 104.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)]),
|
||||
};
|
||||
advance(
|
||||
&mut result,
|
||||
vec![
|
||||
Command::CreateElement(record),
|
||||
Command::CreateDiagram(view),
|
||||
Command::Layout(diagram),
|
||||
],
|
||||
);
|
||||
}
|
||||
let p = &result.decoded.project;
|
||||
assert_eq!(
|
||||
p.elements.len(),
|
||||
base.decoded.project.elements.len() + stage.additions()
|
||||
);
|
||||
assert_eq!(
|
||||
p.diagrams.len(),
|
||||
base.decoded.project.diagrams.len() + usize::from(stage == Stage::Machine)
|
||||
);
|
||||
assert_eq!(p.relations, base.decoded.project.relations);
|
||||
assert_eq!(p.traces, base.decoded.project.traces);
|
||||
result
|
||||
}
|
||||
fn equal(left: &CompiledSourceProject, right: &CompiledSourceProject) -> bool {
|
||||
left.decoded.project == right.decoded.project && left.bundle == right.bundle
|
||||
}
|
||||
fn assert_equal(expected: &CompiledSourceProject, actual: &CompiledSourceProject) {
|
||||
assert_eq!(
|
||||
actual.decoded.project, expected.decoded.project,
|
||||
"Complete Project differs from independent prediction"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.bundle, expected.bundle,
|
||||
"Complete SourceBundle differs, including comments and all included bytes"
|
||||
);
|
||||
}
|
||||
fn test_id(n: u128) -> Id {
|
||||
Id::from_u128(0xc4ea7100000040008000000000000000 + n)
|
||||
}
|
||||
fn false_carrier(base: &Project) -> Project {
|
||||
let mut p = base.clone();
|
||||
for (n, kind, layer, name) in [
|
||||
(1, Kind::Process, Layer::Oa, PROCESS),
|
||||
(2, Kind::Scenario, Layer::Oa, SCENARIO),
|
||||
(3, Kind::StateMachine, Layer::Sa, MACHINE),
|
||||
] {
|
||||
p.elements.insert(
|
||||
test_id(n),
|
||||
Element {
|
||||
id: test_id(n),
|
||||
alias: format!("false_alias_{n}"),
|
||||
name: name.into(),
|
||||
description: "Must never be copied".into(),
|
||||
parent: None,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
p.diagrams.insert(
|
||||
test_id(4),
|
||||
Diagram {
|
||||
id: test_id(4),
|
||||
alias: "false_view_alias".into(),
|
||||
name: VIEW.into(),
|
||||
description: "False view description".into(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine {
|
||||
machine: test_id(3),
|
||||
},
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
test_id(5),
|
||||
NodePlacement {
|
||||
id: test_id(5),
|
||||
alias: "false_node_alias".into(),
|
||||
element: test_id(3),
|
||||
position: Position {
|
||||
x: 9999.,
|
||||
y: -9999.,
|
||||
},
|
||||
size: Size {
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
},
|
||||
collapsed: true,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
p
|
||||
}
|
||||
fn self_check(base: &CompiledSourceProject) {
|
||||
let original = base.clone();
|
||||
let carrier = false_carrier(&base.decoded.project);
|
||||
assert_equal(base, &predict(base, Stage::Baseline, &carrier));
|
||||
for stage in [Stage::Process, Stage::Scenario, Stage::Machine] {
|
||||
let expected = predict(base, stage, &carrier);
|
||||
assert_equal(&expected, &predict(base, stage, &expected.decoded.project));
|
||||
let parsed = compile_bundle(&expected.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_equal(&expected, &parsed);
|
||||
let mut altered = expected.clone();
|
||||
altered
|
||||
.decoded
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&test_id(1))
|
||||
.unwrap()
|
||||
.description = "Unrequested metadata".into();
|
||||
assert!(!equal(&expected, &altered));
|
||||
let mut altered = expected.clone();
|
||||
altered
|
||||
.decoded
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&Id::from_u128(CAPABILITY))
|
||||
.unwrap()
|
||||
.name = "Changed unrelated baseline".into();
|
||||
assert!(!equal(&expected, &altered));
|
||||
let mut changed_source = expected.bundle.clone();
|
||||
changed_source
|
||||
.files
|
||||
.get_mut("rationale.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// Extra comment\n");
|
||||
let altered = compile_bundle(&changed_source, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(altered.decoded.project, expected.decoded.project);
|
||||
assert!(
|
||||
!equal(&expected, &altered),
|
||||
"Identical semantics do not excuse changed source bytes"
|
||||
);
|
||||
if stage >= Stage::Scenario {
|
||||
let mut altered = expected.clone();
|
||||
*altered
|
||||
.decoded
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&test_id(2))
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::OperationalActivity,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![],
|
||||
};
|
||||
assert!(!equal(&expected, &altered));
|
||||
}
|
||||
if stage == Stage::Machine {
|
||||
assert_ne!(
|
||||
expected.decoded.project, carrier,
|
||||
"Never adopt role-carrier metadata/geometry"
|
||||
);
|
||||
assert_eq!(
|
||||
expected.decoded.project.elements[&test_id(3)].parent,
|
||||
Some(Id::from_u128(OWNER))
|
||||
);
|
||||
let view = &expected.decoded.project.diagrams[&test_id(4)];
|
||||
assert_eq!(view.nodes.len(), 1);
|
||||
let node = &view.nodes[&test_id(5)];
|
||||
println!(
|
||||
"PREDICT first view: {VIEW}; machine-root position {:?}, size {:?}; {} exact source files",
|
||||
node.position,
|
||||
node.size,
|
||||
expected.bundle.files.len()
|
||||
);
|
||||
let mut altered = expected.clone();
|
||||
altered
|
||||
.decoded
|
||||
.project
|
||||
.diagrams
|
||||
.get_mut(&test_id(4))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&test_id(5))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 1.;
|
||||
assert!(!equal(&expected, &altered));
|
||||
let mut altered = expected.clone();
|
||||
let extra = element(
|
||||
&altered.decoded.project,
|
||||
test_id(6),
|
||||
"Unrequested region",
|
||||
Some(test_id(3)),
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Region),
|
||||
);
|
||||
altered.decoded.project.elements.insert(extra.id, extra);
|
||||
assert!(!equal(&expected, &altered), "Reject extra semantic objects");
|
||||
let mut altered = expected.clone();
|
||||
altered
|
||||
.bundle
|
||||
.files
|
||||
.insert("unrequested.arcadia".into(), "// Extra file\n".into());
|
||||
assert!(!equal(&expected, &altered));
|
||||
}
|
||||
println!(
|
||||
"PASS self-check {stage:?}: full source fixed point; false metadata, unrelated edits and source-only drift rejected"
|
||||
);
|
||||
}
|
||||
let mut altered_base = base.clone();
|
||||
altered_base
|
||||
.bundle
|
||||
.files
|
||||
.get_mut("rationale.arcadia")
|
||||
.unwrap()
|
||||
.push(' ');
|
||||
assert!(
|
||||
!pinned_baseline(&altered_base),
|
||||
"Reject an altered baseline before use"
|
||||
);
|
||||
assert_equal(&original, base);
|
||||
let before = predict(base, Stage::Scenario, &carrier);
|
||||
let created = predict(base, Stage::Machine, &carrier);
|
||||
let origin = predicted_disk(&created.bundle, None);
|
||||
let detached: Vec<_> = created
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.filter(|path| !before.bundle.files.contains_key(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
detached.len(),
|
||||
1,
|
||||
"A machine creation adds one source module"
|
||||
);
|
||||
// Independently simulate the saver's observable result: restored reachable
|
||||
// bytes plus the one new module left on disk by the prior creation.
|
||||
let mut retained = predicted_disk(&before.bundle, None);
|
||||
retained.insert(
|
||||
detached[0].clone(),
|
||||
created.bundle.files[&detached[0]].as_bytes().to_vec(),
|
||||
);
|
||||
assert_eq!(
|
||||
retained[&detached[0]], origin[&detached[0]],
|
||||
"Retained bytes must come from the exact created checkpoint"
|
||||
);
|
||||
assert_ne!(
|
||||
retained,
|
||||
predicted_disk(&before.bundle, None),
|
||||
"Ordinary comparison must not accept detached history files"
|
||||
);
|
||||
assert_eq!(
|
||||
retained,
|
||||
predicted_disk(&before.bundle, Some(&origin)),
|
||||
"Explicit Undo origin permits only the expected retained bytes"
|
||||
);
|
||||
let mut unknown = retained.clone();
|
||||
unknown.insert("unknown.arcadia".into(), b"// Unclaimed file\n".to_vec());
|
||||
assert_ne!(
|
||||
unknown,
|
||||
predicted_disk(&before.bundle, Some(&origin)),
|
||||
"Reject unknown detached files"
|
||||
);
|
||||
let mut changed = retained.clone();
|
||||
changed.get_mut(&detached[0]).unwrap().push(b' ');
|
||||
assert_ne!(
|
||||
changed,
|
||||
predicted_disk(&before.bundle, Some(&origin)),
|
||||
"Reject changed detached bytes"
|
||||
);
|
||||
let mut missing = retained.clone();
|
||||
missing.remove(&detached[0]);
|
||||
assert_ne!(
|
||||
missing,
|
||||
predicted_disk(&before.bundle, Some(&origin)),
|
||||
"The retained file set is exact, not optional"
|
||||
);
|
||||
let mut reachable_drift = retained.clone();
|
||||
reachable_drift
|
||||
.get_mut("rationale.arcadia")
|
||||
.unwrap()
|
||||
.push(b' ');
|
||||
assert_ne!(
|
||||
reachable_drift,
|
||||
predicted_disk(&before.bundle, Some(&origin)),
|
||||
"An Undo exemption cannot hide reachable-source changes"
|
||||
);
|
||||
println!(
|
||||
"PASS explicit Undo disk contract: known detached retention accepted; unknown, changed, missing and reachable drift rejected"
|
||||
);
|
||||
println!("PASS baseline retained unchanged; no native acceptance claimed");
|
||||
}
|
||||
fn main() {
|
||||
assert_eq!(SCHEMA_VERSION, 7);
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("self-check") => {
|
||||
assert_eq!(args.len(), 3, "self-check BASELINE_ENTRY");
|
||||
self_check(&baseline(Path::new(&args[2])));
|
||||
}
|
||||
Some("check") => {
|
||||
assert_eq!(
|
||||
args.len(),
|
||||
6,
|
||||
"check baseline|process|scenario|machine BASELINE_ENTRY ACTUAL_ENTRY ROLE_ENTRY"
|
||||
);
|
||||
let stage = Stage::parse(&args[2]);
|
||||
let base = baseline(Path::new(&args[3]));
|
||||
let actual = load(Path::new(&args[4]));
|
||||
let carrier = load(Path::new(&args[5]));
|
||||
let expected = predict(&base, stage, &carrier.decoded.project);
|
||||
assert_equal(&expected, &actual);
|
||||
println!(
|
||||
"PASS {stage:?}: complete Project + {} exact source files; only fresh literal-role UUIDs borrowed",
|
||||
actual.bundle.files.len()
|
||||
);
|
||||
}
|
||||
Some("same") => {
|
||||
assert!(
|
||||
matches!(args.len(), 4 | 5),
|
||||
"same EXPECTED_CHECKPOINT ACTUAL_CHECKPOINT [UNDO_FROM_CHECKPOINT]"
|
||||
);
|
||||
let expected = load(Path::new(&args[2]));
|
||||
let (actual, disk) = load_checkpoint(Path::new(&args[3]));
|
||||
assert_equal(&expected, &actual);
|
||||
let origin = args.get(4).map(|entry| {
|
||||
// The first committed checkpoint is itself exact. This keeps
|
||||
// arbitrary pre-existing detached files out of the exception.
|
||||
let committed = load(Path::new(entry));
|
||||
predicted_disk(&committed.bundle, None)
|
||||
});
|
||||
assert_eq!(
|
||||
disk,
|
||||
predicted_disk(&expected.bundle, origin.as_ref()),
|
||||
"Disk source files differ from the exact checkpoint plus explicitly proven retained Undo files"
|
||||
);
|
||||
println!(
|
||||
"PASS exact checkpoint identity/model/{}-file SourceBundle equality; {} exact disk files ({} known retained)",
|
||||
actual.bundle.files.len(),
|
||||
disk.len(),
|
||||
disk.len() - actual.bundle.files.len()
|
||||
);
|
||||
}
|
||||
_ => panic!(
|
||||
"self-check BASE | check STAGE BASE ACTUAL ROLES | same EXPECTED ACTUAL [UNDO_FROM]"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Baseline contract fixtures
|
||||
|
||||
Companion to [the candidate contract](../../release-analysis-baseline-contract-20260911.md). This is proposed schema/source/JSON, not implemented Studio syntax.
|
||||
|
||||
- active.arcadia.proposed is a complete UUID-bearing local model with four base bindings, two cases and one shared check.
|
||||
- evaluate-*.json are complete candidate REST request bodies.
|
||||
- portable-document.json.proposed exposes the complete candidate Project, exact source, local definitions and explicitly empty library collections.
|
||||
- freeze-degraded.json.proposed retains those exact document bytes, a SHA256 commitment, complete effective bindings/checks, and private definition inventory.
|
||||
- expected-observations.json records hand-derived arithmetic/context expectations, not evaluator output.
|
||||
- r7-literal-boundaries.json contains full exact JSON literal nodes and their expected canonical source, including 301-digit and 4096-bit boundaries.
|
||||
- fixture-integrity.json records only transport/source/UUID checks and size counts.
|
||||
|
||||
Run build-fixtures.ps1 with PowerShell 7 to regenerate the JSON fixtures. It writes only within this directory. The script constructs the proposed typed data; it does not parse source, evaluate expressions, run Rust, or establish Project/source semantic equality. Changing the fixture file's exact line endings changes the captured document and digest; preserve these snapshot bytes when publishing.
|
||||
|
||||
The full local capture has no imports, so empty pins/package content are complete for this example. Nonempty pinned-package capture and offline replay remain a required paired library/evaluator acceptance test, not something this local transport fixture has proved.
|
||||
@@ -0,0 +1,55 @@
|
||||
arcadia 8;
|
||||
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
description "A complete proposed analysis model. Schema 8 and the new declarations are not implemented source7 syntax.";
|
||||
|
||||
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time: 1; }
|
||||
algebra Linear;
|
||||
}
|
||||
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
|
||||
kind @duration; scale 1; offset 0; symbol "s";
|
||||
}
|
||||
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
|
||||
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
|
||||
}
|
||||
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration);
|
||||
body $a + $b;
|
||||
}
|
||||
|
||||
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
|
||||
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
|
||||
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
|
||||
}
|
||||
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
|
||||
subject @receive; type Quantity(@duration); display_unit @millisecond;
|
||||
base 750 [@millisecond];
|
||||
}
|
||||
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
|
||||
subject @dispatch; type Quantity(@duration); display_unit @second;
|
||||
base 1 [@second];
|
||||
}
|
||||
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
// The formula is shared. Every read uses the requested evaluation context.
|
||||
base @combine(a: @receive_time, b: @dispatch_time);
|
||||
}
|
||||
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
|
||||
subject @respond; type Quantity(@duration); display_unit @second;
|
||||
base 2 [@second];
|
||||
}
|
||||
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
|
||||
subject none; documented_constraint none;
|
||||
predicate @total_time <= @limit;
|
||||
}
|
||||
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = 1250 [@millisecond]; }
|
||||
}
|
||||
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
|
||||
subject none; base ModelBaseline;
|
||||
overrides { @receive_time = unbound; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# Fixture construction only. This does not parse/evaluate the proposed language.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$taskDir = $PSScriptRoot
|
||||
$ids = @{
|
||||
project = '4210b9f2-7046-47f0-aa3b-153f626a579d'
|
||||
duration = '188b911c-5f8b-431c-9eea-2d41f3953767'
|
||||
second = '534f4672-1ba5-4157-8b0a-b9cf41d0a3ce'
|
||||
millisecond = '39053f37-fec9-4708-9f0a-3959d223f891'
|
||||
combine = 'fd7ce7ab-f789-40bc-b699-0c37f1b6902f'
|
||||
input_a = '28ff40b0-1b8f-4735-8536-5a6ab21b56cb'
|
||||
input_b = '7a47cc1e-8ae5-4551-b2d8-8a68108e0976'
|
||||
respond = 'cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59'
|
||||
receive = '4ab9a8a7-7552-488d-9862-e14b89e7b353'
|
||||
dispatch = '6df991ba-4c5e-4535-99e2-524ae274ee8b'
|
||||
receive_time = '63a931c2-675a-4fce-89e5-04361cb99450'
|
||||
dispatch_time = 'c33fc772-2a60-4044-9af9-73c61c563ace'
|
||||
total_time = 'bd5b68e8-65fc-4e35-9071-a73061316a4b'
|
||||
limit = '437bedbb-2cf4-44ee-a139-6563cc5086ec'
|
||||
degraded = '38036c65-16d4-449c-837b-91eb141c1d93'
|
||||
unmeasured = '99d3e09a-0ec3-4194-9dd4-9f3de6625cde'
|
||||
budget = 'bac0de99-9d2f-4e53-9158-d424ecac61bf'
|
||||
}
|
||||
function Exact([string]$n, [string]$d = '1') {
|
||||
return @{ numerator = $n; denominator = $d }
|
||||
}
|
||||
function Local([string]$name) { return @{ Local = $ids[$name] } }
|
||||
function ReadProperty([string]$name) { return @{ Property = $ids[$name] } }
|
||||
function Literal([string]$n, [string]$unit) {
|
||||
return @{ Quantity = @{ magnitude = (Exact $n); unit = (Local $unit) } }
|
||||
}
|
||||
function Bound($expr) { return @{ Expression = $expr } }
|
||||
function Record([string]$key, [string]$name, $body) {
|
||||
return @{
|
||||
id = $ids[$key]; alias = $key; name = $name; description = ''; body = $body
|
||||
}
|
||||
}
|
||||
function Element([string]$key, [string]$name, $body, $parent = $null) {
|
||||
$r = Record $key $name $body
|
||||
$r.parent = $parent
|
||||
return $r
|
||||
}
|
||||
function Property([string]$key, [string]$name, [string]$subject, [string]$unit, $binding) {
|
||||
return Element $key $name @{ Oa = @{ Shared = @{ EngineeringProperty = @{
|
||||
subject = @{ Element = $ids[$subject] }
|
||||
value_type = $quantityType
|
||||
display_unit = (Local $unit)
|
||||
base = $binding
|
||||
} } } }
|
||||
}
|
||||
function OrderedCopy($value) {
|
||||
if ($null -eq $value) { return $null }
|
||||
if ($value -is [Collections.IDictionary]) {
|
||||
$copy = [ordered]@{}
|
||||
[string[]]$keys = @($value.Keys)
|
||||
[Array]::Sort($keys, [StringComparer]::Ordinal)
|
||||
foreach ($key in $keys) { $copy[$key] = OrderedCopy $value[$key] }
|
||||
return $copy
|
||||
}
|
||||
if ($value -is [Collections.IEnumerable] -and $value -isnot [string]) {
|
||||
$copy = @()
|
||||
foreach ($item in $value) { $copy += ,(OrderedCopy $item) }
|
||||
return ,$copy
|
||||
}
|
||||
return $value
|
||||
}
|
||||
function JsonBytes($value) {
|
||||
$json = (OrderedCopy $value) | ConvertTo-Json -Depth 100
|
||||
$json = $json.Replace([string][char]13 + [string][char]10, [string][char]10)
|
||||
return [Text.UTF8Encoding]::new($false).GetBytes($json + [string][char]10)
|
||||
}
|
||||
function SaveBytes([string]$name, [byte[]]$bytes) {
|
||||
[IO.File]::WriteAllBytes((Join-Path $taskDir $name), $bytes)
|
||||
}
|
||||
$quantityType = @{ Scalar = @{ Quantity = (Local 'duration') } }
|
||||
$definitions = @{}
|
||||
$definitions[$ids.duration] = Record 'duration' 'Duration' @{ QuantityKind = @{
|
||||
dimension = @(0, 0, 1, 0, 0, 0, 0); algebra = 'Linear'
|
||||
} }
|
||||
$definitions[$ids.second] = Record 'second' 'Second' @{ Unit = @{
|
||||
kind = (Local 'duration'); scale = (Exact '1'); offset = (Exact '0'); symbol = 's'
|
||||
} }
|
||||
$definitions[$ids.millisecond] = Record 'millisecond' 'Millisecond' @{ Unit = @{
|
||||
kind = (Local 'duration'); scale = (Exact '1' '1000'); offset = (Exact '0'); symbol = 'ms'
|
||||
} }
|
||||
$definitions[$ids.combine] = Record 'combine' 'Combine durations' @{ Calculation = @{
|
||||
parameters = @(
|
||||
@{ id = $ids.input_a; name = 'a'; value_type = $quantityType },
|
||||
@{ id = $ids.input_b; name = 'b'; value_type = $quantityType }
|
||||
)
|
||||
result = $quantityType
|
||||
body = (Bound @{ Binary = @{
|
||||
op = 'Add'; left = @{ Parameter = $ids.input_a }; right = @{ Parameter = $ids.input_b }
|
||||
} })
|
||||
} }
|
||||
$receiveBase = Bound (Literal '750' 'millisecond')
|
||||
$dispatchBase = Bound (Literal '1' 'second')
|
||||
$totalBase = Bound @{ Call = @{
|
||||
definition = (Local 'combine')
|
||||
arguments = @{
|
||||
$ids.input_a = (ReadProperty 'receive_time')
|
||||
$ids.input_b = (ReadProperty 'dispatch_time')
|
||||
}
|
||||
} }
|
||||
$limitBase = Bound (Literal '2' 'second')
|
||||
$degradedBinding = Bound (Literal '1250' 'millisecond')
|
||||
$predicate = Bound @{ Binary = @{
|
||||
op = 'LessOrEqual'; left = (ReadProperty 'total_time'); right = (ReadProperty 'limit')
|
||||
} }
|
||||
$elements = @{}
|
||||
$elements[$ids.respond] = Element 'respond' 'Respond' @{ Oa = 'Activity' }
|
||||
$elements[$ids.receive] = Element 'receive' 'Receive' @{ Oa = 'Activity' } $ids.respond
|
||||
$elements[$ids.dispatch] = Element 'dispatch' 'Dispatch' @{ Oa = 'Activity' } $ids.respond
|
||||
$elements[$ids.receive_time] = Property 'receive_time' 'Receive time' 'receive' 'millisecond' $receiveBase
|
||||
$elements[$ids.dispatch_time] = Property 'dispatch_time' 'Dispatch time' 'dispatch' 'second' $dispatchBase
|
||||
$elements[$ids.total_time] = Property 'total_time' 'Total time' 'respond' 'second' $totalBase
|
||||
$elements[$ids.limit] = Property 'limit' 'Allowed duration' 'respond' 'second' $limitBase
|
||||
$elements[$ids.budget] = Element 'budget' 'Response budget' @{ Oa = @{ Shared = @{ AnalysisCheck = @{
|
||||
subject = $null; documented_constraint = $null; predicate = $predicate
|
||||
} } } }
|
||||
$elements[$ids.degraded] = Element 'degraded' 'Degraded radio' @{ Oa = @{ Shared = @{ AnalysisCase = @{
|
||||
subject = $null; base = 'ModelBaseline'; overrides = @{ $ids.receive_time = $degradedBinding }
|
||||
} } } }
|
||||
$elements[$ids.unmeasured] = Element 'unmeasured' 'Unmeasured radio' @{ Oa = @{ Shared = @{ AnalysisCase = @{
|
||||
subject = $null; base = 'ModelBaseline'; overrides = @{ $ids.receive_time = 'Unbound' }
|
||||
} } } }
|
||||
$sourceBytes = [IO.File]::ReadAllBytes((Join-Path $taskDir 'active.arcadia.proposed'))
|
||||
$source = [Text.UTF8Encoding]::new($false, $true).GetString($sourceBytes)
|
||||
$project = @{
|
||||
schema_version = 8; id = $ids.project; alias = 'response_budget'; name = 'Response budget'
|
||||
description = 'A complete proposed analysis model. Schema 8 and the new declarations are not implemented source7 syntax.'
|
||||
elements = $elements; relations = @{}; diagrams = @{}; artifact_links = @{}; traces = @{}
|
||||
definitions = $definitions; imports = @{}; definition_mounts = @{}; library_instances = @{}
|
||||
}
|
||||
$document = @{
|
||||
model = $project
|
||||
source = @{ entry = 'active.arcadia'; files = @{ 'active.arcadia' = $source } }
|
||||
packages = @{}
|
||||
}
|
||||
[byte[]]$documentBytes = JsonBytes $document
|
||||
SaveBytes 'portable-document.json.proposed' $documentBytes
|
||||
$documentDigest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($documentBytes)).ToLowerInvariant()
|
||||
$manifest = @(
|
||||
@{ property = $ids.receive_time; binding = $degradedBinding; origin = @{ CaseOverride = $ids.degraded } },
|
||||
@{ property = $ids.dispatch_time; binding = $dispatchBase; origin = 'ModelBaseline' },
|
||||
@{ property = $ids.total_time; binding = $totalBase; origin = 'ModelBaseline' },
|
||||
@{ property = $ids.limit; binding = $limitBase; origin = 'ModelBaseline' }
|
||||
) | Sort-Object { $_.property }
|
||||
$freeze = @{
|
||||
format = 'arcadia-analysis-freeze-1-proposal'
|
||||
captured_document = @{
|
||||
media_type = 'application/vnd.arcadia.portable-document+json'
|
||||
sha256 = $documentDigest
|
||||
bytes_base64 = [Convert]::ToBase64String($documentBytes)
|
||||
}
|
||||
origin = @{ Local = @{
|
||||
document_instance = 'd911a3c0-8adb-4203-850c-b16e9b4e4d0a'; accepted_revision = 1
|
||||
} }
|
||||
model_id = $ids.project
|
||||
context = @{ Case = $ids.degraded }
|
||||
targets = @{ properties = @($ids.total_time); checks = @($ids.budget) }
|
||||
numeric_profile = 'Q1-exact-1'
|
||||
effective_bindings = @($manifest)
|
||||
check_predicates = @(@{ check = $ids.budget; predicate = $predicate })
|
||||
definition_capture = @{
|
||||
document_sha256 = $documentDigest
|
||||
local_definitions = @($ids.duration, $ids.second, $ids.millisecond, $ids.combine | Sort-Object)
|
||||
imports = @()
|
||||
}
|
||||
}
|
||||
SaveBytes 'freeze-degraded.json.proposed' (JsonBytes $freeze)
|
||||
$expected = @{
|
||||
status = 'Hand-derived expectations, not evaluator output'
|
||||
baseline = @{ total_canonical_seconds = (Exact '7' '4'); check = 'Satisfied' }
|
||||
degraded = @{ total_canonical_seconds = (Exact '9' '4'); check = 'Violated' }
|
||||
unmeasured = @{ total = 'Missing'; input = $ids.receive_time; check = 'Unresolved' }
|
||||
after_clear_unmeasured_override = @{ total_canonical_seconds = (Exact '7' '4'); check = 'Satisfied' }
|
||||
after_baseline_dispatch_changes_to_two_seconds = @{
|
||||
active_degraded_total = (Exact '13' '4'); frozen_degraded_total = (Exact '9' '4')
|
||||
}
|
||||
}
|
||||
SaveBytes 'expected-observations.json' (JsonBytes $expected)
|
||||
$largeInteger = [Numerics.BigInteger]::Pow([Numerics.BigInteger]10, 300).ToString()
|
||||
$smallDenominator = [Numerics.BigInteger]::Pow([Numerics.BigInteger]2, 4095).ToString()
|
||||
$boundInteger = ([Numerics.BigInteger]::Pow([Numerics.BigInteger]2, 4096) - 1).ToString()
|
||||
$literalFixtures = @(
|
||||
@{ name = 'ten_to_300'; expression = @{ Number = (Exact $largeInteger) }; canonical_source = $largeInteger },
|
||||
@{ name = 'one_over_two_to_4095'; expression = @{ Number = (Exact '1' $smallDenominator) }; canonical_source = ('ratio(1, ' + $smallDenominator + ')') },
|
||||
@{ name = 'negative_4096_bit_bound'; expression = @{ Number = (Exact ('-' + $boundInteger)) }; canonical_source = ('ratio(-' + $boundInteger + ', 1)') }
|
||||
)
|
||||
SaveBytes 'r7-literal-boundaries.json' (JsonBytes $literalFixtures)
|
||||
# Verify only JSON transport, UUID completeness, raw source retention and hashes.
|
||||
$parsed = Get-Content -LiteralPath (Join-Path $taskDir 'freeze-degraded.json.proposed') -Raw | ConvertFrom-Json
|
||||
$decoded = [Convert]::FromBase64String($parsed.captured_document.bytes_base64)
|
||||
if ([Convert]::ToBase64String($decoded) -cne [Convert]::ToBase64String($documentBytes)) { throw 'Document capsule differs' }
|
||||
$parsedDoc = [Text.Encoding]::UTF8.GetString($decoded) | ConvertFrom-Json
|
||||
if ($parsedDoc.source.files.'active.arcadia' -cne $source) { throw 'Source changed' }
|
||||
if ($ids.Count -ne (@($ids.Values | Sort-Object -Unique)).Count) { throw 'Duplicate fixture UUID' }
|
||||
foreach ($value in $ids.Values) {
|
||||
[void][guid]::ParseExact($value, 'D')
|
||||
if (-not $source.Contains('id "' + $value + '"')) { throw "Missing source identity $value" }
|
||||
}
|
||||
$stats = @{
|
||||
source_lines = ($source -split [string][char]10).Count - 1
|
||||
source_utf8_bytes = $sourceBytes.Length
|
||||
explicit_source_uuids = $ids.Count
|
||||
project_elements = $elements.Count
|
||||
local_definitions = $definitions.Count
|
||||
scoped_parameters = 2
|
||||
base_bindings = 4
|
||||
override_bindings = 2
|
||||
shared_checks = 1
|
||||
portable_document_sha256 = $documentDigest
|
||||
verified = 'JSON transport, 17 syntactically valid distinct UUIDs, exact source bytes, capsule equality. No proposed parser/evaluator execution.'
|
||||
}
|
||||
SaveBytes 'fixture-integrity.json' (JsonBytes $stats)
|
||||
$stats | ConvertTo-Json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"context": "ModelBaseline",
|
||||
"targets": {
|
||||
"properties": ["bd5b68e8-65fc-4e35-9071-a73061316a4b"],
|
||||
"checks": ["bac0de99-9d2f-4e53-9158-d424ecac61bf"]
|
||||
},
|
||||
"numeric_profile": "Q1-exact-1"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"context": {"Case": "38036c65-16d4-449c-837b-91eb141c1d93"},
|
||||
"targets": {
|
||||
"properties": ["bd5b68e8-65fc-4e35-9071-a73061316a4b"],
|
||||
"checks": ["bac0de99-9d2f-4e53-9158-d424ecac61bf"]
|
||||
},
|
||||
"numeric_profile": "Q1-exact-1"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"context": {"Case": "99d3e09a-0ec3-4194-9dd4-9f3de6625cde"},
|
||||
"targets": {
|
||||
"properties": ["bd5b68e8-65fc-4e35-9071-a73061316a4b"],
|
||||
"checks": ["bac0de99-9d2f-4e53-9158-d424ecac61bf"]
|
||||
},
|
||||
"numeric_profile": "Q1-exact-1"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"after_baseline_dispatch_changes_to_two_seconds": {
|
||||
"active_degraded_total": {
|
||||
"denominator": "4",
|
||||
"numerator": "13"
|
||||
},
|
||||
"frozen_degraded_total": {
|
||||
"denominator": "4",
|
||||
"numerator": "9"
|
||||
}
|
||||
},
|
||||
"after_clear_unmeasured_override": {
|
||||
"check": "Satisfied",
|
||||
"total_canonical_seconds": {
|
||||
"denominator": "4",
|
||||
"numerator": "7"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"check": "Satisfied",
|
||||
"total_canonical_seconds": {
|
||||
"denominator": "4",
|
||||
"numerator": "7"
|
||||
}
|
||||
},
|
||||
"degraded": {
|
||||
"check": "Violated",
|
||||
"total_canonical_seconds": {
|
||||
"denominator": "4",
|
||||
"numerator": "9"
|
||||
}
|
||||
},
|
||||
"status": "Hand-derived expectations, not evaluator output",
|
||||
"unmeasured": {
|
||||
"check": "Unresolved",
|
||||
"input": "63a931c2-675a-4fce-89e5-04361cb99450",
|
||||
"total": "Missing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"base_bindings": 4,
|
||||
"explicit_source_uuids": 17,
|
||||
"local_definitions": 4,
|
||||
"override_bindings": 2,
|
||||
"portable_document_sha256": "6667773d90f83626f760b2faa2bf7d57638947d95574e3fa3e80b64416f01497",
|
||||
"project_elements": 10,
|
||||
"scoped_parameters": 2,
|
||||
"shared_checks": 1,
|
||||
"source_lines": 55,
|
||||
"source_utf8_bytes": 2678,
|
||||
"verified": "JSON transport, 17 syntactically valid distinct UUIDs, exact source bytes, capsule equality. No proposed parser/evaluator execution."
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,425 @@
|
||||
{
|
||||
"model": {
|
||||
"alias": "response_budget",
|
||||
"artifact_links": {},
|
||||
"definition_mounts": {},
|
||||
"definitions": {
|
||||
"188b911c-5f8b-431c-9eea-2d41f3953767": {
|
||||
"alias": "duration",
|
||||
"body": {
|
||||
"QuantityKind": {
|
||||
"algebra": "Linear",
|
||||
"dimension": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "188b911c-5f8b-431c-9eea-2d41f3953767",
|
||||
"name": "Duration"
|
||||
},
|
||||
"39053f37-fec9-4708-9f0a-3959d223f891": {
|
||||
"alias": "millisecond",
|
||||
"body": {
|
||||
"Unit": {
|
||||
"kind": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
},
|
||||
"offset": {
|
||||
"denominator": "1",
|
||||
"numerator": "0"
|
||||
},
|
||||
"scale": {
|
||||
"denominator": "1000",
|
||||
"numerator": "1"
|
||||
},
|
||||
"symbol": "ms"
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "39053f37-fec9-4708-9f0a-3959d223f891",
|
||||
"name": "Millisecond"
|
||||
},
|
||||
"534f4672-1ba5-4157-8b0a-b9cf41d0a3ce": {
|
||||
"alias": "second",
|
||||
"body": {
|
||||
"Unit": {
|
||||
"kind": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
},
|
||||
"offset": {
|
||||
"denominator": "1",
|
||||
"numerator": "0"
|
||||
},
|
||||
"scale": {
|
||||
"denominator": "1",
|
||||
"numerator": "1"
|
||||
},
|
||||
"symbol": "s"
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce",
|
||||
"name": "Second"
|
||||
},
|
||||
"fd7ce7ab-f789-40bc-b699-0c37f1b6902f": {
|
||||
"alias": "combine",
|
||||
"body": {
|
||||
"Calculation": {
|
||||
"body": {
|
||||
"Expression": {
|
||||
"Binary": {
|
||||
"left": {
|
||||
"Parameter": "28ff40b0-1b8f-4735-8536-5a6ab21b56cb"
|
||||
},
|
||||
"op": "Add",
|
||||
"right": {
|
||||
"Parameter": "7a47cc1e-8ae5-4551-b2d8-8a68108e0976"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"id": "28ff40b0-1b8f-4735-8536-5a6ab21b56cb",
|
||||
"name": "a",
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7a47cc1e-8ae5-4551-b2d8-8a68108e0976",
|
||||
"name": "b",
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "fd7ce7ab-f789-40bc-b699-0c37f1b6902f",
|
||||
"name": "Combine durations"
|
||||
}
|
||||
},
|
||||
"description": "A complete proposed analysis model. Schema 8 and the new declarations are not implemented source7 syntax.",
|
||||
"diagrams": {},
|
||||
"elements": {
|
||||
"38036c65-16d4-449c-837b-91eb141c1d93": {
|
||||
"alias": "degraded",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"AnalysisCase": {
|
||||
"base": "ModelBaseline",
|
||||
"overrides": {
|
||||
"63a931c2-675a-4fce-89e5-04361cb99450": {
|
||||
"Expression": {
|
||||
"Quantity": {
|
||||
"magnitude": {
|
||||
"denominator": "1",
|
||||
"numerator": "1250"
|
||||
},
|
||||
"unit": {
|
||||
"Local": "39053f37-fec9-4708-9f0a-3959d223f891"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"subject": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "38036c65-16d4-449c-837b-91eb141c1d93",
|
||||
"name": "Degraded radio",
|
||||
"parent": null
|
||||
},
|
||||
"437bedbb-2cf4-44ee-a139-6563cc5086ec": {
|
||||
"alias": "limit",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"EngineeringProperty": {
|
||||
"base": {
|
||||
"Expression": {
|
||||
"Quantity": {
|
||||
"magnitude": {
|
||||
"denominator": "1",
|
||||
"numerator": "2"
|
||||
},
|
||||
"unit": {
|
||||
"Local": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_unit": {
|
||||
"Local": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"
|
||||
},
|
||||
"subject": {
|
||||
"Element": "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59"
|
||||
},
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "437bedbb-2cf4-44ee-a139-6563cc5086ec",
|
||||
"name": "Allowed duration",
|
||||
"parent": null
|
||||
},
|
||||
"4ab9a8a7-7552-488d-9862-e14b89e7b353": {
|
||||
"alias": "receive",
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
},
|
||||
"description": "",
|
||||
"id": "4ab9a8a7-7552-488d-9862-e14b89e7b353",
|
||||
"name": "Receive",
|
||||
"parent": "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59"
|
||||
},
|
||||
"63a931c2-675a-4fce-89e5-04361cb99450": {
|
||||
"alias": "receive_time",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"EngineeringProperty": {
|
||||
"base": {
|
||||
"Expression": {
|
||||
"Quantity": {
|
||||
"magnitude": {
|
||||
"denominator": "1",
|
||||
"numerator": "750"
|
||||
},
|
||||
"unit": {
|
||||
"Local": "39053f37-fec9-4708-9f0a-3959d223f891"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_unit": {
|
||||
"Local": "39053f37-fec9-4708-9f0a-3959d223f891"
|
||||
},
|
||||
"subject": {
|
||||
"Element": "4ab9a8a7-7552-488d-9862-e14b89e7b353"
|
||||
},
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "63a931c2-675a-4fce-89e5-04361cb99450",
|
||||
"name": "Receive time",
|
||||
"parent": null
|
||||
},
|
||||
"6df991ba-4c5e-4535-99e2-524ae274ee8b": {
|
||||
"alias": "dispatch",
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
},
|
||||
"description": "",
|
||||
"id": "6df991ba-4c5e-4535-99e2-524ae274ee8b",
|
||||
"name": "Dispatch",
|
||||
"parent": "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59"
|
||||
},
|
||||
"99d3e09a-0ec3-4194-9dd4-9f3de6625cde": {
|
||||
"alias": "unmeasured",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"AnalysisCase": {
|
||||
"base": "ModelBaseline",
|
||||
"overrides": {
|
||||
"63a931c2-675a-4fce-89e5-04361cb99450": "Unbound"
|
||||
},
|
||||
"subject": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "99d3e09a-0ec3-4194-9dd4-9f3de6625cde",
|
||||
"name": "Unmeasured radio",
|
||||
"parent": null
|
||||
},
|
||||
"bac0de99-9d2f-4e53-9158-d424ecac61bf": {
|
||||
"alias": "budget",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"AnalysisCheck": {
|
||||
"documented_constraint": null,
|
||||
"predicate": {
|
||||
"Expression": {
|
||||
"Binary": {
|
||||
"left": {
|
||||
"Property": "bd5b68e8-65fc-4e35-9071-a73061316a4b"
|
||||
},
|
||||
"op": "LessOrEqual",
|
||||
"right": {
|
||||
"Property": "437bedbb-2cf4-44ee-a139-6563cc5086ec"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"subject": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "bac0de99-9d2f-4e53-9158-d424ecac61bf",
|
||||
"name": "Response budget",
|
||||
"parent": null
|
||||
},
|
||||
"bd5b68e8-65fc-4e35-9071-a73061316a4b": {
|
||||
"alias": "total_time",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"EngineeringProperty": {
|
||||
"base": {
|
||||
"Expression": {
|
||||
"Call": {
|
||||
"arguments": {
|
||||
"28ff40b0-1b8f-4735-8536-5a6ab21b56cb": {
|
||||
"Property": "63a931c2-675a-4fce-89e5-04361cb99450"
|
||||
},
|
||||
"7a47cc1e-8ae5-4551-b2d8-8a68108e0976": {
|
||||
"Property": "c33fc772-2a60-4044-9af9-73c61c563ace"
|
||||
}
|
||||
},
|
||||
"definition": {
|
||||
"Local": "fd7ce7ab-f789-40bc-b699-0c37f1b6902f"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_unit": {
|
||||
"Local": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"
|
||||
},
|
||||
"subject": {
|
||||
"Element": "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59"
|
||||
},
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "bd5b68e8-65fc-4e35-9071-a73061316a4b",
|
||||
"name": "Total time",
|
||||
"parent": null
|
||||
},
|
||||
"c33fc772-2a60-4044-9af9-73c61c563ace": {
|
||||
"alias": "dispatch_time",
|
||||
"body": {
|
||||
"Oa": {
|
||||
"Shared": {
|
||||
"EngineeringProperty": {
|
||||
"base": {
|
||||
"Expression": {
|
||||
"Quantity": {
|
||||
"magnitude": {
|
||||
"denominator": "1",
|
||||
"numerator": "1"
|
||||
},
|
||||
"unit": {
|
||||
"Local": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_unit": {
|
||||
"Local": "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"
|
||||
},
|
||||
"subject": {
|
||||
"Element": "6df991ba-4c5e-4535-99e2-524ae274ee8b"
|
||||
},
|
||||
"value_type": {
|
||||
"Scalar": {
|
||||
"Quantity": {
|
||||
"Local": "188b911c-5f8b-431c-9eea-2d41f3953767"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": "c33fc772-2a60-4044-9af9-73c61c563ace",
|
||||
"name": "Dispatch time",
|
||||
"parent": null
|
||||
},
|
||||
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59": {
|
||||
"alias": "respond",
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
},
|
||||
"description": "",
|
||||
"id": "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
|
||||
"name": "Respond",
|
||||
"parent": null
|
||||
}
|
||||
},
|
||||
"id": "4210b9f2-7046-47f0-aa3b-153f626a579d",
|
||||
"imports": {},
|
||||
"library_instances": {},
|
||||
"name": "Response budget",
|
||||
"relations": {},
|
||||
"schema_version": 8,
|
||||
"traces": {}
|
||||
},
|
||||
"packages": {},
|
||||
"source": {
|
||||
"entry": "active.arcadia",
|
||||
"files": {
|
||||
"active.arcadia": "arcadia 8;\nproject response_budget \"Response budget\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n description \"A complete proposed analysis model. Schema 8 and the new declarations are not implemented source7 syntax.\";\n\n quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" {\n dimension { time: 1; }\n algebra Linear;\n }\n unit second \"Second\" id \"534f4672-1ba5-4157-8b0a-b9cf41d0a3ce\" {\n kind @duration; scale 1; offset 0; symbol \"s\";\n }\n unit millisecond \"Millisecond\" id \"39053f37-fec9-4708-9f0a-3959d223f891\" {\n kind @duration; scale ratio(1, 1000); offset 0; symbol \"ms\";\n }\n calculation combine \"Combine durations\" id \"fd7ce7ab-f789-40bc-b699-0c37f1b6902f\" {\n input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration);\n input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration);\n result Quantity(@duration);\n body $a + $b;\n }\n\n oa_activity respond \"Respond\" id \"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59\" {\n oa_activity receive \"Receive\" id \"4ab9a8a7-7552-488d-9862-e14b89e7b353\" {}\n oa_activity dispatch \"Dispatch\" id \"6df991ba-4c5e-4535-99e2-524ae274ee8b\" {}\n }\n oa_property receive_time \"Receive time\" id \"63a931c2-675a-4fce-89e5-04361cb99450\" {\n subject @receive; type Quantity(@duration); display_unit @millisecond;\n base 750 [@millisecond];\n }\n oa_property dispatch_time \"Dispatch time\" id \"c33fc772-2a60-4044-9af9-73c61c563ace\" {\n subject @dispatch; type Quantity(@duration); display_unit @second;\n base 1 [@second];\n }\n oa_property total_time \"Total time\" id \"bd5b68e8-65fc-4e35-9071-a73061316a4b\" {\n subject @respond; type Quantity(@duration); display_unit @second;\n // The formula is shared. Every read uses the requested evaluation context.\n base @combine(a: @receive_time, b: @dispatch_time);\n }\n oa_property limit \"Allowed duration\" id \"437bedbb-2cf4-44ee-a139-6563cc5086ec\" {\n subject @respond; type Quantity(@duration); display_unit @second;\n base 2 [@second];\n }\n oa_check budget \"Response budget\" id \"bac0de99-9d2f-4e53-9158-d424ecac61bf\" {\n subject none; documented_constraint none;\n predicate @total_time <= @limit;\n }\n oa_analysis degraded \"Degraded radio\" id \"38036c65-16d4-449c-837b-91eb141c1d93\" {\n subject none; base ModelBaseline;\n overrides { @receive_time = 1250 [@millisecond]; }\n }\n oa_analysis unmeasured \"Unmeasured radio\" id \"99d3e09a-0ec3-4194-9dd4-9f3de6625cde\" {\n subject none; base ModelBaseline;\n overrides { @receive_time = unbound; }\n }\n}\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,840 @@
|
||||
{
|
||||
"source_commit": "7d14bd0dd1cf4a2293c00c185573fd4a7fc364ae",
|
||||
"command": "cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1",
|
||||
"exit_code": 0,
|
||||
"passed": 26,
|
||||
"failed": 0,
|
||||
"ignored": 0,
|
||||
"filtered_out": 42,
|
||||
"test_seconds": 0.29,
|
||||
"build_seconds": 385,
|
||||
"scope": "Exact numeric foundation unit tests only; no schema8 model/evaluator or desktop acceptance.",
|
||||
"toolchain": "Rust/Cargo1.98.1 x86_64-pc-windows-gnullvm; LLVM-MinGW20260908; jobs1",
|
||||
"prior_attempt": "The broader command without --lib was intentionally stopped before tests while compiling unrelated integration executables. It supplies no pass result.",
|
||||
"artifacts": {
|
||||
"oa-core-tests.exe": {
|
||||
"bytes": 24529920,
|
||||
"sha256": "F92A4C4CF01DB0AD4810527F0D8AC2212196AEB3A4907E8EEA12FD5EDEADDB0D"
|
||||
},
|
||||
"libunwind.dll": {
|
||||
"bytes": 190464,
|
||||
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
|
||||
},
|
||||
"numeric-unit.log": {
|
||||
"bytes": 2754,
|
||||
"sha256": "DF7F71D037CF2B3D38234C834F9F399F3BB30CC4757F453D72833DE9A9C3C063"
|
||||
}
|
||||
},
|
||||
"input_files": [
|
||||
{
|
||||
"path": "Cargo.lock",
|
||||
"bytes": 119973,
|
||||
"sha256": "065C23C94908FE499D32ED91D55A70946ED5B08ECFDA3B483EC1E635572257BF"
|
||||
},
|
||||
{
|
||||
"path": "Cargo.toml",
|
||||
"bytes": 400,
|
||||
"sha256": "369F5B4B2B60705A94623C8C0B68F8E0B048F263280CE07AE0629AD353797D62"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/Cargo.toml",
|
||||
"bytes": 408,
|
||||
"sha256": "6B5C062474EBB62D5082E07DABED99EE85E4A833EDF26690FB4F887028D3416F"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/build.rs",
|
||||
"bytes": 45,
|
||||
"sha256": "AB1BE6AE1FD8BF3EBAEEF6348347A9D5E666BDE693126F199E041659E0B094A9"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/examples/generate_demo.rs",
|
||||
"bytes": 795,
|
||||
"sha256": "E26F432C95331AECA50985DBB3AD2C558AFFD43023B585811F0F7BFAA59F6661"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/examples/generate_release1.rs",
|
||||
"bytes": 1798,
|
||||
"sha256": "1D5F11165D74893E4BC18DBF3A2F2111F4026BD89FD06EE7F0BAE69E45C4D4B5"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/examples/release_dense_views.rs",
|
||||
"bytes": 14664,
|
||||
"sha256": "A83158488EF9B7394F136C52B1496719BE935C4E5BDFE457CAB5C9894F5F9412"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/examples/routing_workshop.rs",
|
||||
"bytes": 17248,
|
||||
"sha256": "571307B18B6D4FA04700C5A4D4B0C2154C419E93A21292B2028E7B6F46A2FAA7"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric.rs",
|
||||
"bytes": 3735,
|
||||
"sha256": "6C88EEF3119162AEB6C07B9E75812C3AF9FBFE75554B0503AD00F103CB478408"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/dimension.rs",
|
||||
"bytes": 3045,
|
||||
"sha256": "D4AD8E5307A1620E31791D724A0E31FDBA860D00DC83E3E1F2209D9C5E22A340"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/exact.rs",
|
||||
"bytes": 17338,
|
||||
"sha256": "4EB8CB0F66B10E5DA204899AF82EE26ADFEC74BAB47B1A6C9842B5488EE04C4D"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/fixtures/r7-literal-boundaries.json",
|
||||
"bytes": 6085,
|
||||
"sha256": "3B959DEC5E100A6F03817F5314BEF497347BA579EBD235476516D4BD83399181"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/meter.rs",
|
||||
"bytes": 7129,
|
||||
"sha256": "1F7174BEB992935B4C399ACDEF91114AF73FAF041070FEAB968455AC827E8EE4"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/quantity.rs",
|
||||
"bytes": 12257,
|
||||
"sha256": "EAD5142D6EEEA8F99399D57E02D8F7D819980923DE76387CD454694D6A747AEE"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/analysis_numeric/tests.rs",
|
||||
"bytes": 27486,
|
||||
"sha256": "323CE82FC057D36F44463569FD8EA324C7D700FDDFBF32BCE15E04BD2A66970B"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/data.rs",
|
||||
"bytes": 42696,
|
||||
"sha256": "7F9C7C7DD8353CB5A38597B92CDE6D3E890203D392473187EB387D41FF37414A"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/demo.rs",
|
||||
"bytes": 20657,
|
||||
"sha256": "AD62123F96C20FF023CAFC1A9849B0FA06BC62EDD1FB9701799E4599B458600C"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/language.rs",
|
||||
"bytes": 99938,
|
||||
"sha256": "638F08A42E9CF4996B5D07F69B4D6F517A518369F3604D25BE1006E40E080765"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/language/semantic_source.rs",
|
||||
"bytes": 7326,
|
||||
"sha256": "584B5BB59D87A7B8D44D5DD2D124C3A1323B21492B138A17BC9526C0087C9E29"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/language_nesting_tests.rs",
|
||||
"bytes": 20639,
|
||||
"sha256": "BD061611E8CB6F28AB203C73E544AC98BF275EB464CD314AD5FBD25F773DF33B"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/lib.rs",
|
||||
"bytes": 549,
|
||||
"sha256": "EA1D9BF5ACBA8381BA204DB12FF5B5FE1F320417D17AF517E46B90FD6BCB241C"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/README.md",
|
||||
"bytes": 1515,
|
||||
"sha256": "FB61D3FAA33D7BDFBA73415415A0882C0FBD3E0B45DF42F972A19AEC6DA927B9"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/adapter.rs",
|
||||
"bytes": 9490,
|
||||
"sha256": "66741B4C8354A23B8A932ECC5BF1BC3BB6E74279558757B8C35BC2157A7E51B5"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/mod.rs",
|
||||
"bytes": 1158,
|
||||
"sha256": "857D8BB13B6814DABA6780CA770C16352943C01CB130245D055F11B3B957B3E9"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/model.rs",
|
||||
"bytes": 52059,
|
||||
"sha256": "8AC63EB59B2E57BDECFF2477EE0634096090924D3DD1851E1C454880A889DACB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/references.rs",
|
||||
"bytes": 13369,
|
||||
"sha256": "33A6819ABC83139FDAEBEEBD1B553238A328637106CCE7DD5A535F1791D43E20"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/source.rs",
|
||||
"bytes": 21946,
|
||||
"sha256": "74FEE9B24E7C912182C998AB9D9AEEC125E2009C3214959229E844C67AFE2BF6"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/state_validation.rs",
|
||||
"bytes": 15769,
|
||||
"sha256": "E29EF071BAB0A68460DDF55C46131C6F66C022D52C3C602C37C013C3649DA1B0"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/transitions.rs",
|
||||
"bytes": 704,
|
||||
"sha256": "F74174C532BA56B57C80B719A11FB4ADEA0A78CCC2828FFF72FE2B9EDA5A10B1"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v2/validation.rs",
|
||||
"bytes": 98736,
|
||||
"sha256": "6D5E4A4E2805AE5045BF789A0485E14A61C63346587BACE2B4A91173A688F780"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v3/README.md",
|
||||
"bytes": 2128,
|
||||
"sha256": "3755A63F840D7F0EADF68AFA7A9D587877A9468FF982D3610539E8A0BB067A82"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v3/mod.rs",
|
||||
"bytes": 23023,
|
||||
"sha256": "F4EDB6A0B50ECFE8670973776D703047B064420CB1F05913129C00FC656B3722"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/migration_v4.rs",
|
||||
"bytes": 4020,
|
||||
"sha256": "50C652964F0F309E7F3F54B6EF12C40D42FCECC0490F005995C713DF1179AF70"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/model.rs",
|
||||
"bytes": 66352,
|
||||
"sha256": "4585C895C64C4A183941C92CF047A9E52FF0CBE41971E16E78D229EA9DC101FB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/operations.rs",
|
||||
"bytes": 11405,
|
||||
"sha256": "A5FFBBF54CD440316A106D1188BFB699B57DAE59F466EDCB33618C333CD19F41"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/persistence.rs",
|
||||
"bytes": 31774,
|
||||
"sha256": "1C01474F906A63E552C0D590B272C95821C00A100B3E79E58E67DBBEAC341A14"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/ports.rs",
|
||||
"bytes": 45413,
|
||||
"sha256": "5CAC12EEA70A54EBEBBD6BE77B63CE92E3195325C4BC7D27705C518686CBAC80"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/projection.rs",
|
||||
"bytes": 21374,
|
||||
"sha256": "69ED12BACE2CC5024E151752A174AB14034AC2D05EB741FCF13E9A27521C059B"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/projection/port_layout.rs",
|
||||
"bytes": 13474,
|
||||
"sha256": "A08D17D10A58BB8B206DD001B19B91D5AA3A9F743B298A4DA57D503223C20079"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/projection/state.rs",
|
||||
"bytes": 5233,
|
||||
"sha256": "73A607BAC713EC7FF49A509FEFB1A53C7686DCA93EE241FFC835564D743AFB28"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/references.rs",
|
||||
"bytes": 21957,
|
||||
"sha256": "93553C234F32DA35F1F41A7B83AD81C471C9803EFA78CA222CF7DE3C2F86C0BB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/routing.rs",
|
||||
"bytes": 50309,
|
||||
"sha256": "C5EBB350BA76F731F5C48D968BFBBA4B6EF68EC046EB5893D116C4637EF15080"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic.rs",
|
||||
"bytes": 540,
|
||||
"sha256": "F9895DD5B8FB27D10D4CFBB41C3542EF2018365A589B8D5778E5674AE5A1F239"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/catalog.rs",
|
||||
"bytes": 49407,
|
||||
"sha256": "1FF74FCCA18084D178681D7FF38EE1CCAC632C27C4DE0595BA1CEAB8067AB3A4"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/historical.rs",
|
||||
"bytes": 15271,
|
||||
"sha256": "EAF3284BF234312698514CDB636BC8348E1B5D746CB16B18DF777E77D312ABFB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/linked.rs",
|
||||
"bytes": 6035,
|
||||
"sha256": "6F335B14E809AECFC35DEFCAF77CB821B080D1506DBC46297912B25AF98C07A2"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/mutate.rs",
|
||||
"bytes": 22683,
|
||||
"sha256": "DC2B4C4A6EB156FE468EAA8C85FD37A505C0CD8EA1F226CB69CD01689AFD3D9D"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/review.rs",
|
||||
"bytes": 51116,
|
||||
"sha256": "0C157D2C327E72E89CCA3B423E71EE73798105C5BB7271439C1A101642C91E3E"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/semantic/types.rs",
|
||||
"bytes": 10405,
|
||||
"sha256": "6922FE35089F12FAD43AB0CE1C6886D01A3855F90B8E2543E99802AC5E6F1BBB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/session.rs",
|
||||
"bytes": 35237,
|
||||
"sha256": "1EB77B329B2986FC6F5DC0171E77FD860375CDFD8C769A1367B445D6C49EBE00"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/source_files.rs",
|
||||
"bytes": 74803,
|
||||
"sha256": "669491DBDDB8E8E6556161426DFFA1A49AC1C826D31020BFF0956D81A4D84683"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/source_project.rs",
|
||||
"bytes": 61843,
|
||||
"sha256": "1394370BCC5908873B830C2C11312E6E1611AF41F46C4F43CA0A6BCB713BB466"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/source_project/source_migration.rs",
|
||||
"bytes": 4700,
|
||||
"sha256": "ADD463AE85C420F853D324B00A221F0F874164CC3F3ED6BBB07557A9ED7A8C95"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/source_project/source_structure.rs",
|
||||
"bytes": 20225,
|
||||
"sha256": "9F1F83315856A7025FE662C0FBB13452F31613224A03663150D6225D1DDC162F"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/state_validation.rs",
|
||||
"bytes": 15665,
|
||||
"sha256": "E28D7F74385E2A78763716B34491BD73A00BFECCC085970CEB130627E3FAEE18"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/topology_context.rs",
|
||||
"bytes": 13734,
|
||||
"sha256": "16DE69B297DE95D93C73808621607833B713AF811DD082F0169186AE1E5C3E2D"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/topology_context/analysis.rs",
|
||||
"bytes": 20036,
|
||||
"sha256": "DEAD6194F0AA60CC46045C0E5EE10DBDBB689158C38634DFBBBC45D2BA88AC65"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/topology_context/analysis_tests.rs",
|
||||
"bytes": 5258,
|
||||
"sha256": "17839263410815E96FE027D4EBA3AAE0A9AB3B82B16947E2B29EBE0D09782B56"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/transitions.rs",
|
||||
"bytes": 51998,
|
||||
"sha256": "FADD1B07B89CAE4117B91ABB4F1AC9EA81FF818EF20F2175E76EE8CF21FB708D"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/transitions/communication.rs",
|
||||
"bytes": 8634,
|
||||
"sha256": "2B476630A88EDD9021B9156CA12A5402E01604095F0D4002AFB452671903A028"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/validation.rs",
|
||||
"bytes": 112792,
|
||||
"sha256": "7C1B4AE5E4EBBA13E99AF9158CCDE138DFC5E9A7AFB580E76FBA72D0B64BA74E"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/validation/coverage.rs",
|
||||
"bytes": 6478,
|
||||
"sha256": "B08E4B344ECAD055714168C83BEFF7FE29BA4EDC53EDBFFA2EAE04E5580A4D5F"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/view_options.rs",
|
||||
"bytes": 24481,
|
||||
"sha256": "421EDE0A04C593CA1DF0E6E0D06FABCFCC8113CDBCFB0ADC7F5C8EF4D1724208"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/src/view_options_tests.rs",
|
||||
"bytes": 21433,
|
||||
"sha256": "64AAC44DE2DF42A1012239A3DD75C473DAF0C8990B3170B7BF7E78A556080208"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/communication_boundary_scenarios.rs",
|
||||
"bytes": 8816,
|
||||
"sha256": "E1100B8F363910A487D90C73ACC7DF543DC6D532158C3C63AD11CD3D40D8E12B"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/communication_means.rs",
|
||||
"bytes": 18102,
|
||||
"sha256": "A25F2749180BC6F4BEB1445BFAD3351EEDF0669131A7439B633AB804439E95A4"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/coverage_diagnostics.rs",
|
||||
"bytes": 20527,
|
||||
"sha256": "E2E9C3996DAAB47F2BD23F5D8C48B4E372AF33331152F5FC85B76F06D04FCDF4"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v2-coastal-rescue.arcadia",
|
||||
"bytes": 29220,
|
||||
"sha256": "F0DF129C1914BFC01D0B047B0CD4B520D8CF0C249EA9CED085DE400C0BE87FF3"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v2-coastal-rescue.json",
|
||||
"bytes": 43990,
|
||||
"sha256": "3ED53508EB544BBD788832F7BEBEA2EC31B3D66191F24F45098CC9750EC4C005"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v3-data-contract.arcadia",
|
||||
"bytes": 2450,
|
||||
"sha256": "305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v3-data-contract.json",
|
||||
"bytes": 8240,
|
||||
"sha256": "1B41299C53204E6232397D25A58D5BCCDBBC16E770C75E8EDB55CE36CCFE58A2"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia",
|
||||
"bytes": 1222,
|
||||
"sha256": "5FA53A26A5AD010C8E59EB56D9B2B52003569E66C0377FFB58840590168AD0C1"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/fixtures/v4-reviewed-prose.json",
|
||||
"bytes": 1734,
|
||||
"sha256": "2808088AFCC165F4BD6DA5129151143411C997041C3DE61D600AB76E05D24A47"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/language.rs",
|
||||
"bytes": 56482,
|
||||
"sha256": "77F6CB2A69976BFB5F60B243B71C07FB0EDC7505002C1E9E73FCD6DDF6D9496E"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/port_anchors.rs",
|
||||
"bytes": 26893,
|
||||
"sha256": "EFBC0FA18733B1AAAC501F9785A4C392FC49C05C45A5778D26E9B5DD951CA6FA"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/port_migration.rs",
|
||||
"bytes": 15620,
|
||||
"sha256": "1801C376A5435AC7EFB2A8342348B2C7FFEF930BBA65DECA4ACB66B06E0483D7"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/port_source.rs",
|
||||
"bytes": 4858,
|
||||
"sha256": "8D2DDACA4045D330F6EDA2591561497B858AAD53769A823249D0BEA4F5916E12"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/prepared_commands.rs",
|
||||
"bytes": 9555,
|
||||
"sha256": "52CAB1C668064777412DF0A6A3C20D74D579B33794B4B00525B9F151C06EAC9B"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/process_diagnostics.rs",
|
||||
"bytes": 1104,
|
||||
"sha256": "1DBBC590239EF7542BE4A2BDC9543AAA64D108417B03DD312A0894E364745906"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r3_constructor_ack.rs",
|
||||
"bytes": 24163,
|
||||
"sha256": "E5C44676EA0AAB97F2E091DDAC2F4BC4A4A90F5877F7BBF08B61229600944DD0"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r3_migration.rs",
|
||||
"bytes": 6590,
|
||||
"sha256": "EBD04120F5215B3956FDC79EE78F6E38734C7D13392F1FC28AABA957912265BD"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r3_semantic.rs",
|
||||
"bytes": 77473,
|
||||
"sha256": "49C3763EAF2131070A7C46CC23A84F917D292D41610B6404510E5607F8890C32"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r3_source.rs",
|
||||
"bytes": 15311,
|
||||
"sha256": "F31F60659DF3C7EF601650D1CFF140589D8729F01A3F65A4A6A6C2272F15C408"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r6_data.rs",
|
||||
"bytes": 41437,
|
||||
"sha256": "5F7FFB3BC73C3C85423623FE56F8680646DC97040327BDBEAD9D47A37E80F155"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r6_migration.rs",
|
||||
"bytes": 9174,
|
||||
"sha256": "81519BC1512F5E069432E9A1CDAD04C10216D492DF01B4B75B02102480EBD6A1"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/r6_source.rs",
|
||||
"bytes": 9519,
|
||||
"sha256": "88A0AA087A7678E2898FF6057538072CA92072190712F05FFF08372CC56EBED6"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release1_teaching.rs",
|
||||
"bytes": 10010,
|
||||
"sha256": "BE179290D871125DC97CFD2A3067C7AC89C8A538FDB0AFAFC5B825BCDFB2BEF2"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_capabilities.rs",
|
||||
"bytes": 19422,
|
||||
"sha256": "B92CBA2A562E78287DDD7DC3E94557848D17FAA0C8A102601B50286B9AF48A30"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_diagrams.rs",
|
||||
"bytes": 7689,
|
||||
"sha256": "CEFCC0582D91266D5DE6EE5DAEFCFE41B606AB971AB235B59034A8E1DBC24B7D"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_language_limits.rs",
|
||||
"bytes": 5204,
|
||||
"sha256": "8578DA4F76BBD2D0E1BFE295AB1259E2162A3FE3D0D2BA8901915D7A40942806"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_ports.rs",
|
||||
"bytes": 32740,
|
||||
"sha256": "506E62BEAB0C7EC0CECE83E9E0C1A810FA1F99285C9E1CAB0C77F06DE55A40E1"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_routing.rs",
|
||||
"bytes": 23165,
|
||||
"sha256": "AE168FAABA23769B7CA1FD65F217B9DB671726E67276EB5A04E5E72B1447A462"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_scenarios.rs",
|
||||
"bytes": 41067,
|
||||
"sha256": "0F5D876BADEEC653FC7B12610A77D867E9A53582E605253DEFF8D379DA21572F"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_states.rs",
|
||||
"bytes": 26873,
|
||||
"sha256": "B1A26ACEE95EABA7F9920F9DA738A1E33606552EB078E6D298895DAFF7E3D811"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/release_topology.rs",
|
||||
"bytes": 17643,
|
||||
"sha256": "C51DBE5AA5A038F9ED830DAA02AA67714472179EB7FD6DA5CE36DC6931C8B3A9"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/review_regressions.rs",
|
||||
"bytes": 21823,
|
||||
"sha256": "F154E5C88D35E31512E2B07733A1327EA3E2FCFD1EF8EB23066A4E9573F387C1"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/route_source.rs",
|
||||
"bytes": 7270,
|
||||
"sha256": "1A73FDBAC0D0AE0E84D8D67B6AD398E11223958F5DCA007F73757C91827DECFB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/routing_geometry.rs",
|
||||
"bytes": 31890,
|
||||
"sha256": "2D8EEA4A2BBA535ED0EA6DAABCCDAF87B375828C404BAA10518C7BD7FD6253D3"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/routing_labels.rs",
|
||||
"bytes": 5530,
|
||||
"sha256": "DFF884D799C3C5C9257210248C55767E294E3CB317C605CBD0DDC9600C216EB6"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/semantic_artifact_absence_oracle.rs",
|
||||
"bytes": 4640,
|
||||
"sha256": "D93EAF9667F4221232168BEF0FE9F069E5BE1B1F23410BDA5DD67010B96E5C8A"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/semantic_attachments_gui_oracle.rs",
|
||||
"bytes": 14957,
|
||||
"sha256": "7F526D363BA05E7AC89FAF0FEAD0796E419C34EE204EDF44A1B46C78308B0970"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source6_view_contract.rs",
|
||||
"bytes": 14307,
|
||||
"sha256": "FA566DE53946D40BBBD9033EDFEED177180C4DC44AFA2F2F4667FFDC622EB3BB"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source7_contract.rs",
|
||||
"bytes": 43262,
|
||||
"sha256": "CAD4F1A135DDBD947BDF3C71D1B44F1E438725095EA8C4D893464BC721C2363F"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source_files.rs",
|
||||
"bytes": 13168,
|
||||
"sha256": "F0429A0B89A443458EA9AF7083C1E4E7411417A7E6B47E618BFBB9266DC164F9"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source_identity_updates.rs",
|
||||
"bytes": 5139,
|
||||
"sha256": "F52AA38B4D317E532C641AD7463472E4B40E802E60052CF0017556FFC63DA06C"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source_nested_updates.rs",
|
||||
"bytes": 16892,
|
||||
"sha256": "9AC3390936C0D250651EA94A1B8FAF0ADFB680F061ECECC56F2A52909B5BA855"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/source_project.rs",
|
||||
"bytes": 45910,
|
||||
"sha256": "CEF58ADB16AA4D2527DDC0628AEC3E26F0B42B4BE70014A7E360FAFDAC13A942"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/state_diagrams.rs",
|
||||
"bytes": 23973,
|
||||
"sha256": "6B02B387A3AE559CB3AD72DE24292A5ABCB76AAAC0ADBAB5D2AA5211B22CD2E5"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-core/tests/workflows.rs",
|
||||
"bytes": 33426,
|
||||
"sha256": "BD843DFDDBD5724EC08F657571F887496203056FE2FA5864EF50A168A613B89B"
|
||||
},
|
||||
{
|
||||
"path": "docs/language.rst",
|
||||
"bytes": 130753,
|
||||
"sha256": "AC6B1D982C55F246DE2F3C844B24B874599A3C20CFBD7ED22EFE4561AC622B83"
|
||||
},
|
||||
{
|
||||
"path": "examples/coastal-rescue.arcadia",
|
||||
"bytes": 33107,
|
||||
"sha256": "7E183EF9FD8154D3074303F1956CD0D9871C19E3FFC630D333B0BFFBD6E30E17"
|
||||
},
|
||||
{
|
||||
"path": "examples/coastal-rescue.json",
|
||||
"bytes": 49715,
|
||||
"sha256": "242F0B031229A87AF5C4EBE5D651DEFE59C191A6B7A69DC0A77F7E203411F195"
|
||||
},
|
||||
{
|
||||
"path": "examples/legacy-v1-coastal-rescue.arcadia",
|
||||
"bytes": 3449,
|
||||
"sha256": "4DBBBDF8BC7E6CB8E0A81DD678069DAE585A9E5187C59BC3665E1FDAB3F1A86A"
|
||||
},
|
||||
{
|
||||
"path": "examples/legacy-v1-coastal-rescue.oa.json",
|
||||
"bytes": 5976,
|
||||
"sha256": "FD0E9DEDFB7ADB922DC0575CC2B820D40344D639F1D8052BD70363AA59DE9C24"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/README.md",
|
||||
"bytes": 11137,
|
||||
"sha256": "113CFB1CF38A98B51BFF60442D86A35D636A3D80EF206BECE7D53D0AAE67F2D5"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/evidence/repair-check.md",
|
||||
"bytes": 1419,
|
||||
"sha256": "F4DB17BDCA4ECBB1BF0CAEF01B2DA5F1F7F798AB3152B648CE6DD8696A6A68E5"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/expected-diagnostics.json",
|
||||
"bytes": 288,
|
||||
"sha256": "3700393D76B940CFC076BF52861BD7B1F43370F8EABCE3594531BD0BF1FC851E"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/model.json",
|
||||
"bytes": 134520,
|
||||
"sha256": "EF0EF4006C3E09D5DB6E1B621256B3F7D6642F3A7B09CC34607CFBCCA830C727"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/model.rs",
|
||||
"bytes": 28762,
|
||||
"sha256": "15F30F6BDE655808FE754665C26D560E36F2A9BAD340F346B09137D0D05045F5"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/activities/activity_oa_restore_work.arcadia",
|
||||
"bytes": 1307,
|
||||
"sha256": "83E73D0A013D5C3E1A4AF0D9139AB0CFA362D25A846671FC2DAC59181061A9D4"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/process_oa_assessment_procedure.arcadia",
|
||||
"bytes": 697,
|
||||
"sha256": "172C32C044EF4B5BC58A1F4635CC81F48DABB4014479B47DCE7B25FEDF2DBD0D"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/process_oa_field_procedure.arcadia",
|
||||
"bytes": 1781,
|
||||
"sha256": "D2552DC5383E2E91CB302A790364E197F663C2EDC013A2AB1978789825C9C324"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/process_oa_response.arcadia",
|
||||
"bytes": 3225,
|
||||
"sha256": "CF86F46FA5C5D70592EF43F1BC1CB4D968EFD8ADD384BA88036BEF6B88AA4ABC"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/scenario_oa_conversation.arcadia",
|
||||
"bytes": 1795,
|
||||
"sha256": "B25BE524E98A35EBAC5E4481B7609C027B32BAF9605E8097F807560F94A77CA6"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/scenario_oa_full_scenario.arcadia",
|
||||
"bytes": 1261,
|
||||
"sha256": "00A1EDF2C9707A0EB62AC070C2D02467874F2C9C1F273CC6DDB53D6D65999A1C"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/behavior/state_machine_oa_monitor.arcadia",
|
||||
"bytes": 2022,
|
||||
"sha256": "447C9AD1359F4A31ECF975AD07C0B08D8E7E03191FB0858E2FC56F377B223CD5"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/capabilities.arcadia",
|
||||
"bytes": 781,
|
||||
"sha256": "FC8012E5554350B12E1CB80080F8FEAB80D933C9B33C0F2A7BB500CB7D609936"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/model.arcadia",
|
||||
"bytes": 661,
|
||||
"sha256": "F7CF914766EA73EC53B85E32D117EA9A9DBA52D3E038246863541AE7069B1A15"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/participants.arcadia",
|
||||
"bytes": 1834,
|
||||
"sha256": "951764C14179F27C10E315ACF2EA37D1FFF54C77B8441E23ADDD239E8C715CE3"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/oa/types.arcadia",
|
||||
"bytes": 788,
|
||||
"sha256": "9D0396FB82DD5733EDD8D1CF52F968CA84906927C704F9CF0944A7FAE215CF49"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/project.arcadia",
|
||||
"bytes": 1958,
|
||||
"sha256": "B2674E6BF77CFE0F6F0BA79C81D2E96D92F39C02CA2C6266D80E91723BFB9FDA"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/process_sa_assessment_procedure.arcadia",
|
||||
"bytes": 706,
|
||||
"sha256": "73928CED61C06CBC08728D7956676A50FD4C05AA5B7F1FF4F29CE27A0B0F2088"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/process_sa_field_procedure.arcadia",
|
||||
"bytes": 1790,
|
||||
"sha256": "886D462AF83110CBFA55DD232A667EA1446761234471357CBB878794E98E8768"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/process_sa_response.arcadia",
|
||||
"bytes": 3234,
|
||||
"sha256": "041785FC22842423822431BD5E6E8D16094EBD0716965E7C765FB8C1D7549E8A"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/scenario_sa_conversation.arcadia",
|
||||
"bytes": 2127,
|
||||
"sha256": "97276F5757EE9AA12A0DBD9D3CCD58174B3091162BB8BCCE357CEC20A45F5D85"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/scenario_sa_full_scenario.arcadia",
|
||||
"bytes": 1256,
|
||||
"sha256": "E12FC406B99D60A2F7768AAA0B25BA560E8605F0B8E37F3A7CDD3B8443562B74"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/behavior/state_machine_sa_monitor.arcadia",
|
||||
"bytes": 2022,
|
||||
"sha256": "05CE29AF99424C491C47FE2450DC6F5F8CE995511C01EC7E17BB5855BE3C9736"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/capabilities.arcadia",
|
||||
"bytes": 781,
|
||||
"sha256": "B6912FB3ECE439912345FA71724ACD462E716A1E5A88203A1058A5C3B2C20A4F"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/functions/function_sa_audit_thresholds.arcadia",
|
||||
"bytes": 297,
|
||||
"sha256": "E5A04325D52183956DCA814DD90F4E56C96C6B7DB0F4F202F203ECB49C3E0F4F"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/functions/function_sa_restore_work.arcadia",
|
||||
"bytes": 2576,
|
||||
"sha256": "FDDEB67308051325041FEE641FE758DDF43345B7C47B66D433616AD07FDCCDD6"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/model.arcadia",
|
||||
"bytes": 661,
|
||||
"sha256": "D431509251ACB859FBB98733B6D18ADD952495E10A78703FBE8F31E40BC083C4"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/participants.arcadia",
|
||||
"bytes": 4012,
|
||||
"sha256": "780D2EB08B217B67B55FEBB3D34846F727B8EE9018DF6C5741748647E36B2FF8"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/sa/types.arcadia",
|
||||
"bytes": 1227,
|
||||
"sha256": "101FC0FBD99F1101514AA7B6DA4F8E535AAE0BB94C9E2B9E7E6C151135B28660"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/traceability.arcadia",
|
||||
"bytes": 29506,
|
||||
"sha256": "48A96F543456C9B5A5798FC795594E4119147FE45F11A6F109F141B7F514560F"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/transition-before.json",
|
||||
"bytes": 35801,
|
||||
"sha256": "2253E6434F741687E22327C37D520ED15C043BC36025498650BC2366BB6A874D"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/transition-request.json",
|
||||
"bytes": 4276,
|
||||
"sha256": "43B9A22E5F95AD3BC0EBD2478EAD0E14406854ABC989AE94DED9367B013D812A"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/views/diagram_oa_allocation_view.arcadia",
|
||||
"bytes": 2176,
|
||||
"sha256": "DFC0EB61E0E2AA5BDA45524571FBFA3494F44CE19696F3E15E07438074A07BC7"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/views/diagram_sa_allocation_view.arcadia",
|
||||
"bytes": 2173,
|
||||
"sha256": "B78B43A26A82EFB8FBD5374B09C329FA341F57B29F635D397B9EF8AEDE71D389"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/views/diagram_sa_capability_view.arcadia",
|
||||
"bytes": 1620,
|
||||
"sha256": "75E8DC9FB30F25D6F8FD3C6B595593AB4F98958A25798F961E336E0FAC557649"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/views/diagram_sa_service_view.arcadia",
|
||||
"bytes": 2209,
|
||||
"sha256": "B08C84F3EA633E7C78DFDAD3272F75415F6256CF3614B6D99BFD4F0269076C9B"
|
||||
},
|
||||
{
|
||||
"path": "examples/release1/views/diagram_sa_state_view.arcadia",
|
||||
"bytes": 1931,
|
||||
"sha256": "5F42F4C605E57B817D8400ED21F2B14BFC4E1592AEBE941FF113767504C2B869"
|
||||
},
|
||||
{
|
||||
"path": "examples/routing-workshop.arcadia",
|
||||
"bytes": 66431,
|
||||
"sha256": "DC6B75DA72884D399132ED9E300FA5A26B73D802BB526C9AF986AE9EEB0A646A"
|
||||
},
|
||||
{
|
||||
"path": "examples/routing-workshop.json",
|
||||
"bytes": 100281,
|
||||
"sha256": "8592714F75B271133F21CE017D87896B6CF91EA0E4837F1C2ED453E21BE26B14"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/oa.arcadia",
|
||||
"bytes": 2142,
|
||||
"sha256": "5F60642F211B2D352A951092FAEEB7503D4FB7F6F7FCA99E810347231101D31C"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/project.arcadia",
|
||||
"bytes": 606,
|
||||
"sha256": "787F6B67B179FFD221F7E5FAF1A4EDD74CFD116AA1E3B5E9ED9F22FBFF612D8D"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/sa/assurance.arcadia",
|
||||
"bytes": 682,
|
||||
"sha256": "AB76044836E4DCD42A82D316846EC87DA2108939AA2037640C5BCA1D2ECDEE0F"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/sa/participants.arcadia",
|
||||
"bytes": 1231,
|
||||
"sha256": "1ABAD579812E77877B45A3D8E3C3E9DA734BE2D04A0254CC730A5257EBD2DC88"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/sa/reporting.arcadia",
|
||||
"bytes": 2839,
|
||||
"sha256": "F0B7443F728912CA163DE39B6055876B533A77FEAA3219B2D40A2DD65712E058"
|
||||
},
|
||||
{
|
||||
"path": "examples/source-project/views.arcadia",
|
||||
"bytes": 3508,
|
||||
"sha256": "E29AD3F406A6A50D2F0AE2CA2A53EE3B0C18222CD4098704A46D4F48B64EA17F"
|
||||
},
|
||||
{
|
||||
"path": "examples/text-first/README.md",
|
||||
"bytes": 1367,
|
||||
"sha256": "4EC8250D5ED556F1D496E8C85137E973E802ECB11DF1A36488411E8CDC5A9F9E"
|
||||
},
|
||||
{
|
||||
"path": "examples/text-first/project.arcadia",
|
||||
"bytes": 480,
|
||||
"sha256": "F441535C623E0CF20E1875EB2CD33B587ED00E078C37AF39CB36D89BB5EDC19A"
|
||||
},
|
||||
{
|
||||
"path": "rust-toolchain.toml",
|
||||
"bytes": 66,
|
||||
"sha256": "A6A0BBD29FFAA8182DC22D1D9149709F1091E47DF40ED96EB8A78A711C66A4CE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
{
|
||||
"reviewer": "Codex-9e8r6t /root/palette_finish",
|
||||
"started_utc": "2026-09-11T09:18:19.944507+00:00",
|
||||
"finished_utc": "2026-09-11T09:18:23.213965+00:00",
|
||||
"scope": "Frozen checkpoints only; no live work/profile/UI access or compilation.",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": ".local/behavior-creation-oracle-20260911/behavior-creation-contract-v2.exe",
|
||||
"bytes": 20757504,
|
||||
"sha256": "056eefd444924916bf6ac3c4897ab2ff4a8a4bb892e1dbc16adad32e59bf6a36"
|
||||
},
|
||||
{
|
||||
"path": "harness/behavior-creation-contract.rs",
|
||||
"bytes": 23982,
|
||||
"sha256": "51ab2ffa1826dd949b79f3a9690042a2f9e38daf9ed91517cade3861482117ba"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-creation-native-20260911/verify.py",
|
||||
"bytes": 5348,
|
||||
"sha256": "4921c4615071240c3ac3bbe1223c0db3bf472fe5e3eec2ae5dac12f95b1674df"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/bin/arcadia-studio.exe",
|
||||
"bytes": 111163904,
|
||||
"sha256": "d4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/bin/libunwind.dll",
|
||||
"bytes": 190976,
|
||||
"sha256": "f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/bin/manifest.json",
|
||||
"bytes": 41579,
|
||||
"sha256": "e0470afae0ae21941ac6da63fadfb3c70d0ce646317c65fafa5c186e94f18fe2"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/baseline-source-manifest.json",
|
||||
"bytes": 927,
|
||||
"sha256": "7b5bcd66739ad9f3ddaa3a50db7a4bba3be144d13d161e1db85d2d43f3038259"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/cancel-save-verification.json",
|
||||
"bytes": 1439,
|
||||
"sha256": "5fb79d14a000fa0ecc046fba64bcd2846d3bc2eb8708a88e334db0ede4ce16a1"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/scenario-empty-saved.png",
|
||||
"bytes": 90419,
|
||||
"sha256": "b03fb33d3f4cf1e5f95e7ac4198a4dee30ffd9af5e250d481e0d5dc2a2a0f90c"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/machine-created-saved.png",
|
||||
"bytes": 98897,
|
||||
"sha256": "1e7af3d6219c430eaafb0de5afd8df4c51b4efeb80de3f642084188633dcb65b"
|
||||
}
|
||||
],
|
||||
"checkpoints": {
|
||||
"baseline": {
|
||||
"path": ".local/native-qa/palette-20260910-v3/checkpoint-transition-reopen",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4844,
|
||||
"sha256": "b35a3f63f223cc4fe2760d6308e87b016ed1eadfbef0a3a4bd051b52c9080958"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"process": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-process",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4906,
|
||||
"sha256": "961d99c287dd7728c8ece8b20ca1a7965ac2d0581b3eaedf11a2b243975b0ea8"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"process-undo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-process-undo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4844,
|
||||
"sha256": "b35a3f63f223cc4fe2760d6308e87b016ed1eadfbef0a3a4bd051b52c9080958"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"process-redo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-process-redo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4906,
|
||||
"sha256": "961d99c287dd7728c8ece8b20ca1a7965ac2d0581b3eaedf11a2b243975b0ea8"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-scenario",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4984,
|
||||
"sha256": "1fa6dd826fd53ccdf3c93cfdcdd657487e344313d76dabe0c438e08128851ee6"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario-undo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-scenario-undo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4906,
|
||||
"sha256": "961d99c287dd7728c8ece8b20ca1a7965ac2d0581b3eaedf11a2b243975b0ea8"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario-redo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-scenario-redo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4984,
|
||||
"sha256": "1fa6dd826fd53ccdf3c93cfdcdd657487e344313d76dabe0c438e08128851ee6"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"machine": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-machine",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 5057,
|
||||
"sha256": "59b61b321b0cf4817c1ff9276fa59deafc09518765e761a2ebd9e5602afab9cb"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 285,
|
||||
"sha256": "9b77b88f66e4e80540ae33b83f1af1f3edcd37dafb98c33a861d4c34c66d2559"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
}
|
||||
},
|
||||
"machine-undo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-machine-undo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 4984,
|
||||
"sha256": "1fa6dd826fd53ccdf3c93cfdcdd657487e344313d76dabe0c438e08128851ee6"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 124,
|
||||
"sha256": "fb975b83d3ec164c38936bf27fe7d2021cc46805574902a534744b040f7d3ae8"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
}
|
||||
},
|
||||
"machine-redo": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-machine-redo",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 5057,
|
||||
"sha256": "59b61b321b0cf4817c1ff9276fa59deafc09518765e761a2ebd9e5602afab9cb"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 285,
|
||||
"sha256": "9b77b88f66e4e80540ae33b83f1af1f3edcd37dafb98c33a861d4c34c66d2559"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
}
|
||||
},
|
||||
"creation-reopen": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen",
|
||||
"files": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 5057,
|
||||
"sha256": "59b61b321b0cf4817c1ff9276fa59deafc09518765e761a2ebd9e5602afab9cb"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"bytes": 285,
|
||||
"sha256": "9b77b88f66e4e80540ae33b83f1af1f3edcd37dafb98c33a861d4c34c66d2559"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"label": "self-check",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"self-check",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.967588,
|
||||
"stdout": "PASS self-check Process: full source fixed point; false metadata, unrelated edits and source-only drift rejected\nPASS self-check Scenario: full source fixed point; false metadata, unrelated edits and source-only drift rejected\nPREDICT first view: Rescue team operating modes \u00b7 states; machine-root position Position { x: 60.0, y: 60.0 }, size Size { width: 336.0, height: 104.0 }; 9 exact source files\nPASS self-check Machine: full source fixed point; false metadata, unrelated edits and source-only drift rejected\nPASS explicit Undo disk contract: known detached retention accepted; unknown, changed, missing and reachable drift rejected\nPASS baseline retained unchanged; no native acceptance claimed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "baseline",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"baseline",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.119426,
|
||||
"stdout": "PASS Baseline: complete Project + 6 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "process",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"process",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.163785,
|
||||
"stdout": "PASS Process: complete Project + 7 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "process-undo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"same",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process-undo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.155607,
|
||||
"stdout": "PASS exact checkpoint identity/model/6-file SourceBundle equality; 7 exact disk files (1 known retained)\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "process-redo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"process",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process-redo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.187937,
|
||||
"stdout": "PASS Process: complete Project + 7 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "scenario",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"scenario",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.253057,
|
||||
"stdout": "PASS Scenario: complete Project + 8 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "scenario-undo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"same",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-process\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario-undo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.160712,
|
||||
"stdout": "PASS exact checkpoint identity/model/7-file SourceBundle equality; 8 exact disk files (1 known retained)\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "scenario-redo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"scenario",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario-redo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.222,
|
||||
"stdout": "PASS Scenario: complete Project + 8 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "machine",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"machine",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.266748,
|
||||
"stdout": "PASS Machine: complete Project + 9 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "machine-undo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"same",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-scenario\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine-undo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.122982,
|
||||
"stdout": "PASS exact checkpoint identity/model/8-file SourceBundle equality; 9 exact disk files (1 known retained)\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "machine-redo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"machine",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine-redo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.229706,
|
||||
"stdout": "PASS Machine: complete Project + 9 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "reopen-contract",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"check",
|
||||
"machine",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\palette-20260910-v3\\checkpoint-transition-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-creation-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.192465,
|
||||
"stdout": "PASS Machine: complete Project + 9 exact source files; only fresh literal-role UUIDs borrowed\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"label": "reopen-exact-redo",
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"same",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-machine-redo\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-creation-reopen\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.131742,
|
||||
"stdout": "PASS exact checkpoint identity/model/9-file SourceBundle equality; 9 exact disk files (0 known retained)\n",
|
||||
"stderr": ""
|
||||
}
|
||||
],
|
||||
"all_checkpoint_files_unchanged": true,
|
||||
"machine_redo_reopen_inventory_equal": true,
|
||||
"machine_redo_reopen_direct_byte_comparisons": {
|
||||
"oa/activities/activity_assess_incident.arcadia": true,
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": true,
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": true,
|
||||
"oa/participants.arcadia": true,
|
||||
"project.arcadia": true,
|
||||
"rationale.arcadia": true,
|
||||
"sa/participants.arcadia": true,
|
||||
"traceability.arcadia": true,
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": true
|
||||
},
|
||||
"log": {
|
||||
"path": "harness/experiments/behavior-creation-native-20260911/commands-results.log",
|
||||
"bytes": 9392,
|
||||
"sha256": "f5bfbee185f7064419b1bd9fe33ec6a48526b5bbe3f818c006feb74eddf8e189"
|
||||
},
|
||||
"pass": true
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"reviewer": "Codex-9e8r6t /root/palette_finish",
|
||||
"started_utc": "2026-09-11T09:22:33.387447+00:00",
|
||||
"finished_utc": "2026-09-11T09:22:34.502189+00:00",
|
||||
"scope": "Immutable evidence only; native observations supplied separately by root. No live work/profile/UI/Cargo access; no persisted Undo claim.",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "harness/experiments/behavior-creation-native-20260911/verify-recovery.py",
|
||||
"bytes": 5560,
|
||||
"sha256": "3546b4fe68d3e1a2474015be1b652a665af238e5fcf63e04bac3f25df3d35e90"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-creation-native-20260911/manifest.json",
|
||||
"bytes": 30406,
|
||||
"sha256": "356dfbb27dec04764a4b3f2372602f39953b472baac0a3de3e72acd9c1d76b8a"
|
||||
},
|
||||
{
|
||||
"path": ".local/behavior-creation-oracle-20260911/behavior-creation-contract-v2.exe",
|
||||
"bytes": 20757504,
|
||||
"sha256": "056eefd444924916bf6ac3c4897ab2ff4a8a4bb892e1dbc16adad32e59bf6a36"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/recovery-evidence/before-interruption.json",
|
||||
"bytes": 27382,
|
||||
"sha256": "0c355aa3c7b9be623d767ff35c4b85cf7b2907eb9bc3be39d619aa610c989692"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/recovery-evidence/after-recovery-and-typing.json",
|
||||
"bytes": 27263,
|
||||
"sha256": "fafa7a884275bac09afab365f8ab0063f998ca56c5ffc70be596db6f78a10103"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/recovery-evidence/restored-draft.png",
|
||||
"bytes": 113820,
|
||||
"sha256": "8ae0fe8957d5a935b6e2605a6ffb68934bd0e8840f3594c43e63865ab89d03c9"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/recovery-launch.json",
|
||||
"bytes": 812,
|
||||
"sha256": "641ff05ea50d551a51e642f728650cf254dbf131cfb2f585865db436a8877396"
|
||||
},
|
||||
{
|
||||
"path": ".local/native-qa/behavior-20260911-v4/bin/arcadia-studio.exe",
|
||||
"bytes": 111163904,
|
||||
"sha256": "d4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31"
|
||||
}
|
||||
],
|
||||
"input_file_inventories": {
|
||||
".local\\native-qa\\behavior-20260911-v4\\checkpoint-creation-reopen": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/oa/activities/activity_assess_incident.arcadia",
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/oa/behavior/process_coordinate_rescue.arcadia",
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/oa/behavior/scenario_rescue_coordination_conversation.arcadia",
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/oa/participants.arcadia",
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/project.arcadia",
|
||||
"bytes": 5057,
|
||||
"sha256": "59b61b321b0cf4817c1ff9276fa59deafc09518765e761a2ebd9e5602afab9cb"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/rationale.arcadia",
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/sa/participants.arcadia",
|
||||
"bytes": 285,
|
||||
"sha256": "9b77b88f66e4e80540ae33b83f1af1f3edcd37dafb98c33a861d4c34c66d2559"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/traceability.arcadia",
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-creation-reopen/views/diagram_rescue_team_operating_modes_states.arcadia",
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
},
|
||||
".local\\native-qa\\behavior-20260911-v4\\checkpoint-recovery-cancel": {
|
||||
"oa/activities/activity_assess_incident.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/oa/activities/activity_assess_incident.arcadia",
|
||||
"bytes": 376,
|
||||
"sha256": "ae41d7c20b062b39c2edab7fb890a3d88a9ce18fd8d03018ef2d6f95eeff62e8"
|
||||
},
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/oa/behavior/process_coordinate_rescue.arcadia",
|
||||
"bytes": 149,
|
||||
"sha256": "8f393d1cee5e7eed5e41af14539b1fe7ae1c7300dd9383ca0ef1ee17254b6253"
|
||||
},
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/oa/behavior/scenario_rescue_coordination_conversation.arcadia",
|
||||
"bytes": 284,
|
||||
"sha256": "8eed98446dfb4f4dbd5261f550ce8cae1a21d638975d91b32baaf6f309daba98"
|
||||
},
|
||||
"oa/participants.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/oa/participants.arcadia",
|
||||
"bytes": 403,
|
||||
"sha256": "094cab67f34b64902942c10a35bbcd0f760886d862588a0816d6ceb57bacf51b"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/project.arcadia",
|
||||
"bytes": 5057,
|
||||
"sha256": "59b61b321b0cf4817c1ff9276fa59deafc09518765e761a2ebd9e5602afab9cb"
|
||||
},
|
||||
"rationale.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/rationale.arcadia",
|
||||
"bytes": 57,
|
||||
"sha256": "c01867c9dc0d40726853119fac3c6ad24a40d8d362a93a8b9d20df04ec2e273f"
|
||||
},
|
||||
"sa/participants.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/sa/participants.arcadia",
|
||||
"bytes": 285,
|
||||
"sha256": "9b77b88f66e4e80540ae33b83f1af1f3edcd37dafb98c33a861d4c34c66d2559"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/traceability.arcadia",
|
||||
"bytes": 395,
|
||||
"sha256": "f778acaaa85de0940cedcd9514cd803032a11e782d859454203235febb73e5f0"
|
||||
},
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": {
|
||||
"path": ".local/native-qa/behavior-20260911-v4/checkpoint-recovery-cancel/views/diagram_rescue_team_operating_modes_states.arcadia",
|
||||
"bytes": 842,
|
||||
"sha256": "498eab80da7ac4523883622a0d008b9033a1305e5bb5f168b8e9d4c41dde62de"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"literal_name_before": true,
|
||||
"literal_name_after": true,
|
||||
"sa_machine": true,
|
||||
"selected_exact_system_actor": true,
|
||||
"first_view_selected": true,
|
||||
"complete_behavior_equal_except_deliberate_suffix": true,
|
||||
"accepted_exact_json_equality": true,
|
||||
"workspace_exact_json_equality": true,
|
||||
"property_exact_json_equality": true,
|
||||
"workflow_exact_json_equality": true,
|
||||
"selected_exact_json_equality": true,
|
||||
"reserved_ids_unchanged": true,
|
||||
"complete_file_inventory_equal": true,
|
||||
"complete_file_bytes_equal": true,
|
||||
"accepted_snapshot_exactly_matches_saved_checkpoint": true,
|
||||
"reserved_objects_not_committed": true,
|
||||
"oracle_same_pass": true,
|
||||
"input_files_unchanged": true,
|
||||
"evidence_and_prior_manifest_unchanged": true
|
||||
},
|
||||
"reserved_ids": {
|
||||
"element": "c6f559f5-401e-4e0d-af93-91faee68d3f5",
|
||||
"diagram": "11ad8f0a-73aa-416a-bbeb-9b886bf39854",
|
||||
"placement": "171f7d7e-3a8f-4200-86f6-a9e2efd87610"
|
||||
},
|
||||
"reopen_recovery_cancel_direct_byte_comparisons": {
|
||||
"views/diagram_rescue_team_operating_modes_states.arcadia": true,
|
||||
"traceability.arcadia": true,
|
||||
"oa/activities/activity_assess_incident.arcadia": true,
|
||||
"sa/participants.arcadia": true,
|
||||
"rationale.arcadia": true,
|
||||
"oa/behavior/process_coordinate_rescue.arcadia": true,
|
||||
"project.arcadia": true,
|
||||
"oa/participants.arcadia": true,
|
||||
"oa/behavior/scenario_rescue_coordination_conversation.arcadia": true
|
||||
},
|
||||
"comparison_origin": "checkpoint-creation-reopen",
|
||||
"command": {
|
||||
"command_argv": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\behavior-creation-oracle-20260911\\behavior-creation-contract-v2.exe",
|
||||
"same",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-creation-reopen\\project.arcadia",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\native-qa\\behavior-20260911-v4\\checkpoint-recovery-cancel\\project.arcadia"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.147209,
|
||||
"stdout": "PASS exact checkpoint identity/model/9-file SourceBundle equality; 9 exact disk files (0 known retained)\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"log": {
|
||||
"path": "harness/experiments/behavior-creation-native-20260911/recovery-commands-results.log",
|
||||
"bytes": 641,
|
||||
"sha256": "1dc3e89aa46660bfad796e7b6bf821bebd7e5bc5a7902a826021d461a3e74c68"
|
||||
},
|
||||
"pass": true
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Verify immutable recovery evidence; never access live profile/work or UI."""
|
||||
from pathlib import Path
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
OUT = Path(__file__).resolve().parent
|
||||
STAGE = ROOT / '.local/native-qa/behavior-20260911-v4'
|
||||
EVIDENCE = STAGE / 'recovery-evidence'
|
||||
EXPECTED = STAGE / 'checkpoint-creation-reopen'
|
||||
ACTUAL = STAGE / 'checkpoint-recovery-cancel'
|
||||
EXE = ROOT / '.local/behavior-creation-oracle-20260911/behavior-creation-contract-v2.exe'
|
||||
|
||||
|
||||
def record(path):
|
||||
data = path.read_bytes()
|
||||
return {'path': path.relative_to(ROOT).as_posix(), 'bytes': len(data),
|
||||
'sha256': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def inventory(directory):
|
||||
return {p.relative_to(directory).as_posix(): record(p)
|
||||
for p in sorted(directory.rglob('*')) if p.is_file()}
|
||||
|
||||
|
||||
artifacts = [record(p) for p in (Path(__file__), OUT / 'manifest.json', EXE,
|
||||
EVIDENCE / 'before-interruption.json', EVIDENCE / 'after-recovery-and-typing.json',
|
||||
EVIDENCE / 'restored-draft.png', STAGE / 'recovery-launch.json', STAGE / 'bin/arcadia-studio.exe')]
|
||||
assert artifacts[1]['sha256'] == '356dfbb27dec04764a4b3f2372602f39953b472baac0a3de3e72acd9c1d76b8a'
|
||||
assert artifacts[2]['sha256'] == '056eefd444924916bf6ac3c4897ab2ff4a8a4bb892e1dbc16adad32e59bf6a36'
|
||||
before = json.loads((EVIDENCE / 'before-interruption.json').read_text(encoding='utf-8-sig'))
|
||||
after = json.loads((EVIDENCE / 'after-recovery-and-typing.json').read_text(encoding='utf-8-sig'))
|
||||
draft = before['behavior']['draft']
|
||||
expected_name = 'Recovery — secours Δ «operating modes»'
|
||||
checks = {'literal_name_before': draft['name'] == expected_name,
|
||||
'literal_name_after': after['behavior']['draft']['name'] == expected_name + ' · resumed',
|
||||
'sa_machine': draft['layer'] == 'Sa' and draft['family'] == 'Machine',
|
||||
'selected_exact_system_actor': draft['owner'] == 'c0629449-1209-4b50-afac-f558a9a0fc7f',
|
||||
'first_view_selected': draft['first_view'] is True}
|
||||
normalized = copy.deepcopy(after['behavior'])
|
||||
if checks['literal_name_after']:
|
||||
normalized['draft']['name'] = expected_name
|
||||
checks['complete_behavior_equal_except_deliberate_suffix'] = normalized == before['behavior']
|
||||
for field in ('accepted', 'workspace', 'property', 'workflow', 'selected'):
|
||||
checks[field + '_exact_json_equality'] = before[field] == after[field]
|
||||
reserved = {field: draft[field] for field in ('element', 'diagram', 'placement')}
|
||||
checks['reserved_ids_unchanged'] = all(after['behavior']['draft'][k] == v for k, v in reserved.items())
|
||||
inputs = {str(p.relative_to(ROOT)): inventory(p) for p in (EXPECTED, ACTUAL)}
|
||||
expected_names = set(inputs[str(EXPECTED.relative_to(ROOT))])
|
||||
actual_names = set(inputs[str(ACTUAL.relative_to(ROOT))])
|
||||
checks['complete_file_inventory_equal'] = expected_names == actual_names
|
||||
byte_results = {name: (EXPECTED / name).read_bytes() == (ACTUAL / name).read_bytes()
|
||||
for name in expected_names if name in actual_names}
|
||||
checks['complete_file_bytes_equal'] = all(byte_results.values()) and len(byte_results) == len(expected_names)
|
||||
checks['accepted_snapshot_exactly_matches_saved_checkpoint'] = (
|
||||
set(before['accepted']['files']) == expected_names and
|
||||
all(text.encode('utf-8') == (EXPECTED / name).read_bytes()
|
||||
for name, text in before['accepted']['files'].items()))
|
||||
checks['reserved_objects_not_committed'] = all(
|
||||
all(value.encode('utf-8') not in (ACTUAL / name).read_bytes() for value in reserved.values())
|
||||
for name in actual_names)
|
||||
command = [str(EXE), 'same', str(EXPECTED / 'project.arcadia'), str(ACTUAL / 'project.arcadia')]
|
||||
env = os.environ.copy()
|
||||
env['PATH'] = str(STAGE / 'bin') + os.pathsep + env.get('PATH', '')
|
||||
started = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
tick = time.perf_counter()
|
||||
run = subprocess.run(command, cwd=ROOT, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60)
|
||||
elapsed = time.perf_counter() - tick
|
||||
checks['oracle_same_pass'] = run.returncode == 0
|
||||
checks['input_files_unchanged'] = inputs == {str(p.relative_to(ROOT)): inventory(p) for p in (EXPECTED, ACTUAL)}
|
||||
checks['evidence_and_prior_manifest_unchanged'] = artifacts == [record(ROOT / item['path']) for item in artifacts]
|
||||
result = {'command_argv': command, 'exit_code': run.returncode,
|
||||
'elapsed_seconds': round(elapsed, 6), 'stdout': run.stdout, 'stderr': run.stderr}
|
||||
(OUT / 'recovery-commands-results.log').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8', newline='\n')
|
||||
manifest = {'reviewer': 'Codex-9e8r6t /root/palette_finish', 'started_utc': started,
|
||||
'finished_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'scope': 'Immutable evidence only; native observations supplied separately by root. No live work/profile/UI/Cargo access; no persisted Undo claim.',
|
||||
'artifacts': artifacts, 'input_file_inventories': inputs, 'checks': checks,
|
||||
'reserved_ids': reserved, 'reopen_recovery_cancel_direct_byte_comparisons': byte_results,
|
||||
'comparison_origin': 'checkpoint-creation-reopen', 'command': result,
|
||||
'log': record(OUT / 'recovery-commands-results.log'), 'pass': all(checks.values())}
|
||||
(OUT / 'recovery-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8', newline='\n')
|
||||
print(json.dumps({'pass': manifest['pass'], 'checks': checks, 'manifest': record(OUT / 'recovery-manifest.json')}))
|
||||
raise SystemExit(0 if manifest['pass'] else 1)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Read only frozen checkpoints; run the existing v2 oracle without compiling."""
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
OUT = Path(__file__).resolve().parent
|
||||
STAGE = ROOT / '.local/native-qa/behavior-20260911-v4'
|
||||
BASE = ROOT / '.local/native-qa/palette-20260910-v3/checkpoint-transition-reopen'
|
||||
EXE = ROOT / '.local/behavior-creation-oracle-20260911/behavior-creation-contract-v2.exe'
|
||||
|
||||
|
||||
def record(path):
|
||||
data = path.read_bytes()
|
||||
return {'path': path.relative_to(ROOT).as_posix(), 'bytes': len(data),
|
||||
'sha256': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def inventory(directory):
|
||||
return {p.relative_to(directory).as_posix(): {
|
||||
'bytes': p.stat().st_size, 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
} for p in sorted(directory.rglob('*')) if p.is_file()}
|
||||
|
||||
|
||||
def entry(directory):
|
||||
return str(directory / 'project.arcadia')
|
||||
|
||||
|
||||
checkpoints = {'baseline': BASE}
|
||||
for family in ('process', 'scenario', 'machine'):
|
||||
for suffix in ('', '-undo', '-redo'):
|
||||
checkpoints[family + suffix] = STAGE / ('checkpoint-' + family + suffix)
|
||||
checkpoints['creation-reopen'] = STAGE / 'checkpoint-creation-reopen'
|
||||
before = {name: inventory(path) for name, path in checkpoints.items()}
|
||||
artifacts = [record(p) for p in (
|
||||
EXE, ROOT / 'harness/behavior-creation-contract.rs', Path(__file__),
|
||||
STAGE / 'bin/arcadia-studio.exe', STAGE / 'bin/libunwind.dll',
|
||||
STAGE / 'bin/manifest.json', STAGE / 'baseline-source-manifest.json',
|
||||
STAGE / 'cancel-save-verification.json', STAGE / 'scenario-empty-saved.png',
|
||||
STAGE / 'machine-created-saved.png')]
|
||||
assert artifacts[0]['sha256'] == '056eefd444924916bf6ac3c4897ab2ff4a8a4bb892e1dbc16adad32e59bf6a36'
|
||||
assert artifacts[1]['sha256'] == '51ab2ffa1826dd949b79f3a9690042a2f9e38daf9ed91517cade3861482117ba'
|
||||
commands = [('self-check', ['self-check', entry(BASE)]),
|
||||
('baseline', ['check', 'baseline', entry(BASE), entry(BASE), entry(BASE)])]
|
||||
previous = BASE
|
||||
for family in ('process', 'scenario', 'machine'):
|
||||
first = checkpoints[family]
|
||||
commands.extend([
|
||||
(family, ['check', family, entry(BASE), entry(first), entry(first)]),
|
||||
(family + '-undo', ['same', entry(previous), entry(checkpoints[family + '-undo']), entry(first)]),
|
||||
(family + '-redo', ['check', family, entry(BASE), entry(checkpoints[family + '-redo']), entry(first)])])
|
||||
previous = first
|
||||
commands.extend([
|
||||
('reopen-contract', ['check', 'machine', entry(BASE), entry(checkpoints['creation-reopen']), entry(checkpoints['machine'])]),
|
||||
('reopen-exact-redo', ['same', entry(checkpoints['machine-redo']), entry(checkpoints['creation-reopen'])])])
|
||||
env = os.environ.copy()
|
||||
env['PATH'] = str(STAGE / 'bin') + os.pathsep + env.get('PATH', '')
|
||||
started = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
results = []
|
||||
for label, args in commands:
|
||||
t = time.perf_counter()
|
||||
command = [str(EXE), *args]
|
||||
run = subprocess.run(command, cwd=ROOT, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60)
|
||||
results.append({'label': label, 'command_argv': command, 'exit_code': run.returncode,
|
||||
'elapsed_seconds': round(time.perf_counter() - t, 6),
|
||||
'stdout': run.stdout, 'stderr': run.stderr})
|
||||
after = {name: inventory(path) for name, path in checkpoints.items()}
|
||||
redo_files = checkpoints['machine-redo']
|
||||
reopen_files = checkpoints['creation-reopen']
|
||||
same_names = set(before['machine-redo']) == set(before['creation-reopen'])
|
||||
byte_results = {name: (redo_files / name).read_bytes() == (reopen_files / name).read_bytes()
|
||||
for name in before['machine-redo'] if (reopen_files / name).is_file()}
|
||||
success = (all(r['exit_code'] == 0 for r in results) and before == after
|
||||
and same_names and all(byte_results.values()))
|
||||
log = '\n\n'.join(f"[{r['label']}] argv={json.dumps(r['command_argv'])}\nexit={r['exit_code']} elapsed={r['elapsed_seconds']}s\nstdout:\n{r['stdout']}stderr:\n{r['stderr']}" for r in results)
|
||||
(OUT / 'commands-results.log').write_text(log + '\n', encoding='utf-8', newline='\n')
|
||||
manifest = {'reviewer': 'Codex-9e8r6t /root/palette_finish', 'started_utc': started,
|
||||
'finished_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'scope': 'Frozen checkpoints only; no live work/profile/UI access or compilation.',
|
||||
'artifacts': artifacts, 'checkpoints': {name: {'path': path.relative_to(ROOT).as_posix(), 'files': before[name]} for name, path in checkpoints.items()},
|
||||
'commands': results, 'all_checkpoint_files_unchanged': before == after,
|
||||
'machine_redo_reopen_inventory_equal': same_names,
|
||||
'machine_redo_reopen_direct_byte_comparisons': byte_results,
|
||||
'log': record(OUT / 'commands-results.log'), 'pass': success}
|
||||
(OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8', newline='\n')
|
||||
print(json.dumps({'pass': success, 'commands_passed': sum(r['exit_code'] == 0 for r in results),
|
||||
'commands_total': len(results), 'unchanged_checkpoints': before == after,
|
||||
'reopen_exact_files': len(byte_results), 'manifest': record(OUT / 'manifest.json')}))
|
||||
raise SystemExit(0 if success else 1)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Existing source-7 constructor defaults
|
||||
|
||||
Compatibility experiment, not proposed grammar. `probe.rs` imports only the
|
||||
matching existing `oa_core` rlib; it performs no production or Cargo edits.
|
||||
|
||||
In a coordinated direct-rustc writer slot:
|
||||
|
||||
```powershell
|
||||
. .local/toolchain/env.ps1
|
||||
rustc --edition 2024 -D warnings --crate-name constructor_defaults_probe harness/experiments/constructor-defaults-20260911/probe.rs --extern oa_core=.local/target-gnullvm/debug/deps/liboa_core-3e81c616fea1d901.rlib -L dependency=.local/target-gnullvm/debug/deps -C linker=clang.exe -o .local/constructor-defaults-20260911/probe.exe
|
||||
& .local/constructor-defaults-20260911/probe.exe NEW_ISOLATED_OUTPUT_DIRECTORY
|
||||
```
|
||||
|
||||
Use a fresh output directory when reproducing so the committed evidence is not
|
||||
overwritten. The rlib SHA256 is
|
||||
`5018A1FD7286ED9DFEDCBBF5B68B399AD5D143F2A9AAAED3078CFC934C9589C4`.
|
||||
The exact compiler, core source hashes, executable hash and result inventory
|
||||
are in `provenance.json`.
|
||||
|
||||
`results/results.tsv` records **54 admitted explicit/omitted cases** and
|
||||
**12 missing-required-field refusals**, covering both OA and SA source 7.
|
||||
Every admitted occurrence is compared with a literal expected typed body and
|
||||
the full explicit-case Project. It also requires exact source retention on
|
||||
stabilization/no-op, a name-only patch retaining omissions, and canonical
|
||||
format/reparse equality. Both scenario viewpoint omission cases separately
|
||||
require MixedLegacy equality and canonical stability.
|
||||
|
||||
The `.canonical.arcadia` siblings intentionally expand hydrated defaults;
|
||||
they are not expected to retain authored omission. Exchange `operation none`
|
||||
is deliberately omitted by canonical serialization. Files named
|
||||
`omit-bindings`, `omit-kind`, `omit-operator`, `omit-branches`, `omit-guard`, or
|
||||
`omit-occurrences` are **expected invalid fixtures**, retained to show the
|
||||
specific missing-field diagnostic. They are not modeling examples.
|
||||
|
||||
The program/result files are durable evidence. The compiled executable and
|
||||
initial/final build logs remain in ignored `.local/constructor-defaults-20260911/`.
|
||||
Other constructor families and old schema versions were not exhaustively tested.
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Existing source-7 admission experiment; imports no desktop producer.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
struct Case {
|
||||
kind: &'static str,
|
||||
fields: Vec<(&'static str, &'static str, bool)>,
|
||||
expected: OccurrenceBody,
|
||||
}
|
||||
fn cases() -> Vec<Case> {
|
||||
vec![
|
||||
Case {
|
||||
kind: "Exchange",
|
||||
fields: vec![
|
||||
("exchange", "none", false),
|
||||
("sender", "none", false),
|
||||
("receiver", "none", false),
|
||||
("message_kind", "Asynchronous", false),
|
||||
("reply_to", "none", false),
|
||||
("values", "[]", false),
|
||||
("operation", "none", false),
|
||||
],
|
||||
expected: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "Action",
|
||||
fields: vec![("action", "none", false), ("lifeline", "none", false)],
|
||||
expected: OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "State",
|
||||
fields: vec![("state", "none", false), ("lifeline", "none", false)],
|
||||
expected: OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: None,
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "SequenceReference",
|
||||
fields: vec![("sequence", "none", false)],
|
||||
expected: OccurrenceBody::SequenceReference { sequence: None },
|
||||
},
|
||||
Case {
|
||||
kind: "ScenarioUse",
|
||||
fields: vec![("scenario", "none", false), ("bindings", "[]", true)],
|
||||
expected: OccurrenceBody::ScenarioUse {
|
||||
scenario: None,
|
||||
bindings: vec![],
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "Control",
|
||||
fields: vec![("kind", "Choice", true)],
|
||||
expected: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "Fragment",
|
||||
fields: vec![("operator", "Alternative", true), ("branches", "[]", true)],
|
||||
expected: OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
},
|
||||
Case {
|
||||
kind: "Branch",
|
||||
fields: vec![("guard", "\"\"", true), ("occurrences", "[]", true)],
|
||||
expected: OccurrenceBody::Branch {
|
||||
guard: String::new(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
fn id(last: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{last:012}")).unwrap()
|
||||
}
|
||||
fn fixture(layer: Layer, kind: &str, fields: &str) -> String {
|
||||
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
|
||||
let sequence = if layer == Layer::Oa {
|
||||
"oa_process"
|
||||
} else {
|
||||
"sa_functional_chain"
|
||||
};
|
||||
let viewpoint = if layer == Layer::Oa {
|
||||
"OperationalActivity"
|
||||
} else {
|
||||
"Functional"
|
||||
};
|
||||
let root_sequence = matches!(
|
||||
kind,
|
||||
"Exchange" | "Action" | "Control" | "SequenceReference"
|
||||
);
|
||||
let owner_kind = if root_sequence {
|
||||
sequence.to_owned()
|
||||
} else {
|
||||
format!("{prefix}_scenario")
|
||||
};
|
||||
let owner_view = if root_sequence {
|
||||
String::new()
|
||||
} else {
|
||||
format!("viewpoint {viewpoint};")
|
||||
};
|
||||
let step = if kind == "Branch" {
|
||||
"holder"
|
||||
} else {
|
||||
"subject"
|
||||
};
|
||||
let parent = if kind == "Branch" { "holder" } else { "owner" };
|
||||
let holder = if kind == "Branch" {
|
||||
format!(
|
||||
"{prefix}_occurrence holder \"Fragment owner\" id \"{}\" {{ parent @owner; body {{ Fragment {{ operator Alternative; branches [@subject]; }}; }}; }}",
|
||||
id(4)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"arcadia 7;\nproject defaults \"Constructor defaults\" id \"{}\" {{\n {owner_kind} owner \"Owner\" id \"{}\" {{ {owner_view} steps [@{step}]; }}\n {holder}\n // Authored omissions must stay omitted through no-op and a name edit.\n {prefix}_occurrence subject \"Subject\" id \"{}\" {{ parent @{parent}; body {{ {kind} {{ {fields} }}; }}; }}\n}}\n",
|
||||
id(1),
|
||||
id(2),
|
||||
id(3)
|
||||
)
|
||||
}
|
||||
fn bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: [("project.arcadia".into(), source.into())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn compile(source: &str) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
compile_bundle(&bundle(source), ProjectLimits::default())
|
||||
}
|
||||
fn body(project: &Project) -> &OccurrenceBody {
|
||||
let Some(SharedBody::Occurrence { body, .. }) = project.elements[&id(3)].shared() else {
|
||||
panic!("Subject is not an occurrence")
|
||||
};
|
||||
body
|
||||
}
|
||||
fn accepted(out: &Path, name: &str, source: &str, expected: &Project) -> usize {
|
||||
fs::write(out.join(format!("{name}.arcadia")), source).unwrap();
|
||||
let compiled = compile(source).unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
assert_eq!(
|
||||
&compiled.decoded.project, expected,
|
||||
"{name}: exact complete Project"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle,
|
||||
bundle(source),
|
||||
"{name}: explicit UUIDs need no stabilization edit"
|
||||
);
|
||||
execute_batch(expected, &[]).unwrap();
|
||||
let noop = update_bundle(&compiled, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
noop.bundle, compiled.bundle,
|
||||
"{name}: no-op preserves all authored omissions"
|
||||
);
|
||||
let renamed = execute_batch(
|
||||
expected,
|
||||
&[Command::Rename(id(3), "Renamed subject".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let edited = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
edited.bundle,
|
||||
bundle(&source.replace("\"Subject\"", "\"Renamed subject\"")),
|
||||
"{name}: name-only edit preserves constructor spelling"
|
||||
);
|
||||
let canonical = language::format(expected);
|
||||
fs::write(out.join(format!("{name}.canonical.arcadia")), &canonical).unwrap();
|
||||
assert_eq!(
|
||||
compile(&canonical).unwrap().decoded.project,
|
||||
*expected,
|
||||
"{name}: canonical roundtrip"
|
||||
);
|
||||
if matches!(
|
||||
body(expected),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
..
|
||||
}
|
||||
) {
|
||||
assert!(canonical.contains("message_kind Asynchronous;"));
|
||||
assert!(canonical.contains("reply_to none;") && canonical.contains("values [];"));
|
||||
assert!(
|
||||
!canonical.contains("operation "),
|
||||
"None operation is deliberately skipped by canonical serialization"
|
||||
);
|
||||
}
|
||||
validation::validate(expected).len()
|
||||
}
|
||||
fn main() {
|
||||
let out_arg = std::env::args().nth(1).expect("output directory");
|
||||
let out = Path::new(&out_arg);
|
||||
fs::create_dir_all(out).unwrap();
|
||||
let mut rows = String::from("case\tlayer\tresult\tdetail\n");
|
||||
let mut passes = 0;
|
||||
let mut rejects = 0;
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for case in cases() {
|
||||
let explicit_fields = case
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(name, value, _)| format!("{name} {value};"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let explicit = fixture(layer, case.kind, &explicit_fields);
|
||||
let baseline = compile(&explicit)
|
||||
.unwrap_or_else(|e| panic!("{layer} {} explicit: {e:?}", case.kind));
|
||||
assert_eq!(body(&baseline.decoded.project), &case.expected);
|
||||
let name = format!("{layer}-{}-explicit", case.kind);
|
||||
let issues = accepted(out, &name, &explicit, &baseline.decoded.project);
|
||||
rows.push_str(&format!("{name}\t{layer}\taccept\texact Project, raw/no-op/name/canonical; {issues} model issues\n"));
|
||||
passes += 1;
|
||||
for (omit, _, required) in &case.fields {
|
||||
let fields = case
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(field, _, _)| field != omit)
|
||||
.map(|(field, value, _)| format!("{field} {value};"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let source = fixture(layer, case.kind, &fields);
|
||||
let name = format!("{layer}-{}-omit-{omit}", case.kind);
|
||||
if *required {
|
||||
fs::write(out.join(format!("{name}.arcadia")), &source).unwrap();
|
||||
let errors = compile(&source).expect_err("Required field omission must reject");
|
||||
let detail = format!("{errors:?}");
|
||||
assert!(
|
||||
detail.contains("missing field") && detail.contains(omit),
|
||||
"{name}: must fail on the missing constructor field: {detail}"
|
||||
);
|
||||
rows.push_str(&format!("{name}\t{layer}\treject\t{detail}\n"));
|
||||
rejects += 1;
|
||||
} else {
|
||||
let issues = accepted(out, &name, &source, &baseline.decoded.project);
|
||||
rows.push_str(&format!("{name}\t{layer}\taccept\texact Project, raw/no-op/name/canonical; {issues} model issues\n"));
|
||||
passes += 1;
|
||||
}
|
||||
}
|
||||
if case.fields.iter().any(|(_, _, required)| !required) {
|
||||
let fields = case
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(_, _, required)| *required)
|
||||
.map(|(field, value, _)| format!("{field} {value};"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let name = format!("{layer}-{}-omit-all-optional", case.kind);
|
||||
let issues = accepted(
|
||||
out,
|
||||
&name,
|
||||
&fixture(layer, case.kind, &fields),
|
||||
&baseline.decoded.project,
|
||||
);
|
||||
rows.push_str(&format!("{name}\t{layer}\taccept\texact Project, raw/no-op/name/canonical; {issues} model issues\n"));
|
||||
passes += 1;
|
||||
}
|
||||
}
|
||||
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
|
||||
let source = format!(
|
||||
"arcadia 7;\nproject defaults \"Defaults\" id \"{}\" {{ {prefix}_scenario scene \"Scene\" id \"{}\" {{ }} }}\n",
|
||||
id(1),
|
||||
id(2)
|
||||
);
|
||||
let compiled = compile(&source).unwrap();
|
||||
let explicit = source.replace("{ }", "{ viewpoint MixedLegacy; }");
|
||||
assert_eq!(
|
||||
compiled.decoded.project,
|
||||
compile(&explicit).unwrap().decoded.project
|
||||
);
|
||||
assert_eq!(compiled.bundle, bundle(&source));
|
||||
assert!(matches!(
|
||||
compiled.decoded.project.elements[&id(2)].shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
let canonical = language::format(&compiled.decoded.project);
|
||||
assert_eq!(
|
||||
compile(&canonical).unwrap().decoded.project,
|
||||
compiled.decoded.project
|
||||
);
|
||||
fs::write(
|
||||
out.join(format!("{layer}-scenario-viewpoint-omitted.arcadia")),
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
out.join(format!("{layer}-scenario-viewpoint-explicit.arcadia")),
|
||||
explicit,
|
||||
)
|
||||
.unwrap();
|
||||
rows.push_str(&format!("{layer}-scenario-viewpoint\t{layer}\taccept\texact MixedLegacy Project, retained omission, canonical roundtrip\n"));
|
||||
passes += 1;
|
||||
}
|
||||
fs::write(out.join("results.tsv"), rows).unwrap();
|
||||
println!(
|
||||
"PASS: {passes} accepted explicit/omitted comparisons; {rejects} missing-required-field refusals; both source-7 layers. Full Project, canonical roundtrip, no-op and exact name-only source retention checked."
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" { steps [@subject]; }
|
||||
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @owner; body { Action { action none; lifeline none; }; }; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "";
|
||||
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@subject];
|
||||
}
|
||||
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
body {
|
||||
Action {
|
||||
action none;
|
||||
lifeline none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @owner;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" { steps [@subject]; }
|
||||
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @owner; body { Action { lifeline none; }; }; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "";
|
||||
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@subject];
|
||||
}
|
||||
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
body {
|
||||
Action {
|
||||
action none;
|
||||
lifeline none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @owner;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" { steps [@subject]; }
|
||||
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @owner; body { Action { }; }; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "";
|
||||
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@subject];
|
||||
}
|
||||
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
body {
|
||||
Action {
|
||||
action none;
|
||||
lifeline none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @owner;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" { steps [@subject]; }
|
||||
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @owner; body { Action { action none; }; }; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "";
|
||||
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
description "";
|
||||
parent none;
|
||||
steps [@subject];
|
||||
}
|
||||
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
body {
|
||||
Action {
|
||||
action none;
|
||||
lifeline none;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @owner;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" { viewpoint OperationalActivity; steps [@holder]; }
|
||||
oa_occurrence holder "Fragment owner" id "00000000-0000-4000-8000-000000000004" { parent @owner; body { Fragment { operator Alternative; branches [@subject]; }; }; }
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @holder; body { Branch { guard ""; occurrences []; }; }; }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "";
|
||||
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
context none;
|
||||
description "";
|
||||
parent none;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
steps [@holder];
|
||||
viewpoint OperationalActivity;
|
||||
}
|
||||
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
body {
|
||||
Branch {
|
||||
guard "";
|
||||
occurrences [];
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @holder;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
oa_occurrence holder "Fragment owner" id "00000000-0000-4000-8000-000000000004" {
|
||||
body {
|
||||
Fragment {
|
||||
branches [@subject];
|
||||
operator Alternative;
|
||||
};
|
||||
};
|
||||
description "";
|
||||
parent @owner;
|
||||
postcondition "";
|
||||
precondition "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
arcadia 7;
|
||||
project defaults "Constructor defaults" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" { viewpoint OperationalActivity; steps [@holder]; }
|
||||
oa_occurrence holder "Fragment owner" id "00000000-0000-4000-8000-000000000004" { parent @owner; body { Fragment { operator Alternative; branches [@subject]; }; }; }
|
||||
// Authored omissions must stay omitted through no-op and a name edit.
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" { parent @holder; body { Branch { occurrences []; }; }; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user