Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d99aacab8 | ||
|
|
b743a10d51 | ||
|
|
ff712b2419 | ||
|
|
b15345123a | ||
|
|
1711bff650 | ||
|
|
bed7f825fd | ||
|
|
3480b54201 | ||
|
|
2a3b9ba822 |
@@ -4,12 +4,12 @@
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing / selection, navigation, feedback, recovery, loading / empty / failure states, accessibility, layout, focus / disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human / AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
|
||||
|
||||
## User input
|
||||
@@ -24,10 +24,10 @@ Keep one authoritative home for each kind of information. Use links and brief sc
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -38,9 +38,9 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -64,10 +64,10 @@ Preserve concise records of decisions and rationale, useful experiments, failed
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
|
||||
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Deterministic Process native fixture and independent complete-Project oracle.
|
||||
//! Expected order is literal; no desktop reorder/layout planner is used here.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002930),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn occurrence(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
layer: Layer,
|
||||
owner: &str,
|
||||
value: OccurrenceBody,
|
||||
) {
|
||||
element(p, alias, name, Kind::Occurrence, layer, Some(id(owner)));
|
||||
let Some(SharedBody::Occurrence { body, .. }) =
|
||||
p.elements.get_mut(&id(alias)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Occurrence body");
|
||||
};
|
||||
*body = value;
|
||||
}
|
||||
fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) {
|
||||
*p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() =
|
||||
aliases.iter().map(|alias| id(alias)).collect();
|
||||
}
|
||||
fn sequence_link(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<()> {
|
||||
let old = p.relate(RelationKind::SequenceLink, name, id(source), id(target));
|
||||
reidentify(p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve link {alias} exactly.");
|
||||
if let RelationBody::SequenceLink { guard, .. } = &mut relation.body {
|
||||
*guard = "ready".into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "process_multiselect_native".into();
|
||||
p.name = "Process group native".into();
|
||||
p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into();
|
||||
for (layer, prefix, owner, name, owner_kind, work_kind) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
"oa",
|
||||
"oa_process",
|
||||
"01 OA group order",
|
||||
Kind::Process,
|
||||
Kind::Activity,
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
"sa",
|
||||
"sa_chain",
|
||||
"SA chain - preserve",
|
||||
Kind::FunctionalChain,
|
||||
Kind::Function,
|
||||
),
|
||||
] {
|
||||
element(&mut p, owner, name, owner_kind, layer, None);
|
||||
let shared = format!("{prefix}_shared_work");
|
||||
let downstream = format!("{prefix}_downstream_work");
|
||||
element(
|
||||
&mut p,
|
||||
&shared,
|
||||
"Shared request work",
|
||||
work_kind,
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
&downstream,
|
||||
"Downstream verification",
|
||||
work_kind,
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
for (suffix, label) in [
|
||||
("a", "Card A"),
|
||||
("u", "Downstream U"),
|
||||
("b", "Card B"),
|
||||
("v", "Downstream V"),
|
||||
("c", "Card C"),
|
||||
] {
|
||||
let body = if suffix == "b" {
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
}
|
||||
} else {
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id(if suffix == "a" || suffix == "c" {
|
||||
&shared
|
||||
} else {
|
||||
&downstream
|
||||
})),
|
||||
lifeline: None,
|
||||
}
|
||||
};
|
||||
occurrence(
|
||||
&mut p,
|
||||
&format!("{prefix}_{suffix}"),
|
||||
label,
|
||||
layer,
|
||||
owner,
|
||||
body,
|
||||
);
|
||||
}
|
||||
let order: Vec<_> = ["a", "u", "b", "v", "c"]
|
||||
.map(|suffix| format!("{prefix}_{suffix}"))
|
||||
.into_iter()
|
||||
.collect();
|
||||
set_steps(
|
||||
&mut p,
|
||||
owner,
|
||||
&order.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
sequence_link(
|
||||
&mut p,
|
||||
&format!("{prefix}_a_u"),
|
||||
"A to U",
|
||||
&format!("{prefix}_a"),
|
||||
&format!("{prefix}_u"),
|
||||
)?;
|
||||
sequence_link(
|
||||
&mut p,
|
||||
&format!("{prefix}_b_v"),
|
||||
"B to V",
|
||||
&format!("{prefix}_b"),
|
||||
&format!("{prefix}_v"),
|
||||
)?;
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"reusable",
|
||||
"Reusable request detail",
|
||||
Kind::Process,
|
||||
Layer::Oa,
|
||||
None,
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
"reused_leaf",
|
||||
"Shared detail",
|
||||
Layer::Oa,
|
||||
"reusable",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id("oa_shared_work")),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
set_steps(&mut p, "reusable", &["reused_leaf"]);
|
||||
element(
|
||||
&mut p,
|
||||
"reuse_trial",
|
||||
"02 Read-only preview trial",
|
||||
Kind::Process,
|
||||
Layer::Oa,
|
||||
None,
|
||||
);
|
||||
for (alias, name) in [
|
||||
("reference_one", "First use"),
|
||||
("reference_two", "Second use"),
|
||||
] {
|
||||
occurrence(
|
||||
&mut p,
|
||||
alias,
|
||||
name,
|
||||
Layer::Oa,
|
||||
"reuse_trial",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(id("reusable")),
|
||||
},
|
||||
);
|
||||
}
|
||||
set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]);
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_work_refinement".into(),
|
||||
name: "Shared request refinement".into(),
|
||||
description: "Process arrangement must preserve cross-layer correspondence.".into(),
|
||||
oa: id("oa_shared_work"),
|
||||
sa: id("sa_shared_work"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("oa_shared_work")).unwrap(),
|
||||
sa: p.properties(id("sa_shared_work")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let expected = serde_json::to_value(expected)?;
|
||||
let actual = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = expected
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| expected[*key] != actual[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let ids: std::collections::BTreeMap<_, _> = [
|
||||
"project",
|
||||
"oa_process",
|
||||
"oa_a",
|
||||
"oa_u",
|
||||
"oa_b",
|
||||
"oa_v",
|
||||
"oa_c",
|
||||
"oa_a_u",
|
||||
"oa_b_v",
|
||||
"oa_shared_work",
|
||||
"oa_downstream_work",
|
||||
"sa_chain",
|
||||
"sa_shared_work",
|
||||
"sa_downstream_work",
|
||||
"sa_a",
|
||||
"sa_u",
|
||||
"sa_b",
|
||||
"sa_v",
|
||||
"sa_c",
|
||||
"sa_a_u",
|
||||
"sa_b_v",
|
||||
"trace",
|
||||
"reusable",
|
||||
"reused_leaf",
|
||||
"reuse_trial",
|
||||
"reference_one",
|
||||
"reference_two",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name, id(name)))
|
||||
.collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"],
|
||||
"reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
|
||||
"gesture": "Select Card C then add Card A; Inspect Card C without changing membership, then drag Card C to After Card B",
|
||||
"fixed_slots": {"1": "oa_u", "3": "oa_v"},
|
||||
"read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references",
|
||||
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.",
|
||||
"evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact",
|
||||
"limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
// Pin the exact deterministic original, not merely a reusable project UUID.
|
||||
equal(&fixture()?, &before)
|
||||
.map_err(|e| format!("Baseline is not the immutable original: {e}"))?;
|
||||
let mut expected = before;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"reordered" => set_steps(
|
||||
&mut expected,
|
||||
"oa_process",
|
||||
&["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
|
||||
),
|
||||
_ => return Err("Mode must be reordered or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality; literal expected order",
|
||||
"limits": "No pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn reordered(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::SetSteps(
|
||||
id("oa_process"),
|
||||
["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(),
|
||||
))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
validate_candidate(baseline).expect("Baseline must be valid");
|
||||
validate_candidate(actual).expect("Corruption must remain semantically valid");
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(before: &Project, actual: &Project) {
|
||||
let error = through_json("reordered", before, actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"Unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn generate_is_deterministic_create_only_and_checks_real_source() {
|
||||
equal(&fixture().unwrap(), &fixture().unwrap()).unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
assert!(generate(&out).is_err());
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() {
|
||||
let before = fixture().unwrap();
|
||||
let actual = reordered(&before);
|
||||
through_json("reordered", &before, &actual).unwrap();
|
||||
through_json("unchanged", &before, &before).unwrap();
|
||||
mismatch(&before, &before);
|
||||
assert!(through_json("unchanged", &before, &actual).is_err());
|
||||
let error = through_json("unchanged", &actual, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("immutable original"), "{error}");
|
||||
}
|
||||
#[test]
|
||||
fn global_splice_reversed_block_and_singleton_c_results_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
// Native primary/pressed C is already after B, so a singleton C move
|
||||
// is unchanged. Group {C,A} instead puts B before both selected cards.
|
||||
// If A were primary, its singleton result could equal the group result.
|
||||
mismatch(&before, &before);
|
||||
for order in [
|
||||
["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"],
|
||||
["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"],
|
||||
["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"],
|
||||
] {
|
||||
let mut actual = before.clone();
|
||||
set_steps(&mut actual, "oa_process", &order);
|
||||
mismatch(&before, &actual);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = reordered(&before);
|
||||
let mutations: [fn(&mut Project); 7] = [
|
||||
|p| {
|
||||
if let RelationBody::SequenceLink { guard, .. } =
|
||||
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
|
||||
{
|
||||
*guard = "changed".into();
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::SequenceLink { target, .. } =
|
||||
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
|
||||
{
|
||||
*target = id("oa_v");
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("oa_shared_work"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("sa_shared_work"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]);
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("reused_leaf"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
p.traces.get_mut(&id("trace")).unwrap().baseline = None;
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(&before, &bad);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() {
|
||||
let before = fixture().unwrap();
|
||||
let actual = reordered(&before);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
for corrupt in [false, true] {
|
||||
let mut saved = actual.clone();
|
||||
if corrupt {
|
||||
saved
|
||||
.elements
|
||||
.get_mut(&id("sa_c"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" drift");
|
||||
}
|
||||
let bundle = roundtrip(&saved).unwrap();
|
||||
let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" });
|
||||
for (path, text) in &bundle.files {
|
||||
write_new(&folder.join(path), text.as_bytes()).unwrap();
|
||||
}
|
||||
let parsed = load(&folder.join(&bundle.entry)).unwrap();
|
||||
assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process);
|
||||
assert_eq!(
|
||||
parsed.elements[&id("sa_chain")].kind(),
|
||||
Kind::FunctionalChain
|
||||
);
|
||||
let result = check("reordered", &baseline, &folder.join(&bundle.entry));
|
||||
if corrupt {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Whole Project mismatch")
|
||||
);
|
||||
} else {
|
||||
result.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Deterministic persisted-State native fixture and complete-Project checker.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Dedicated State view is the smallest diagram UUID, for native opening.
|
||||
if alias == "state_view" {
|
||||
return Id::from_u128(0x29_0101);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002929),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn transition(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
kind: TransitionKind,
|
||||
) -> Result<()> {
|
||||
let old = p.relate(RelationKind::StateTransition, name, id(source), id(target));
|
||||
reidentify(p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve transition {alias}.");
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
guard,
|
||||
..
|
||||
} = &mut relation.body
|
||||
{
|
||||
*transition_kind = kind;
|
||||
*guard = if alias == "between" {
|
||||
"request_ready"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "state_multiselect_native".into();
|
||||
p.name = "State group native".into();
|
||||
p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into();
|
||||
for (alias, name, kind, parent) in [
|
||||
("crew", "Operations crew", Kind::Entity, None),
|
||||
(
|
||||
"machine",
|
||||
"Request lifecycle",
|
||||
Kind::StateMachine,
|
||||
Some(id("crew")),
|
||||
),
|
||||
("region", "Main region", Kind::Region, Some(id("machine"))),
|
||||
("state_a", "A - Processing", Kind::State, Some(id("region"))),
|
||||
("state_b", "B - Ready", Kind::State, Some(id("region"))),
|
||||
("state_c", "C - Complete", Kind::State, Some(id("region"))),
|
||||
(
|
||||
"inner",
|
||||
"A nested region",
|
||||
Kind::Region,
|
||||
Some(id("state_a")),
|
||||
),
|
||||
(
|
||||
"state_leaf",
|
||||
"A.1 - Classify",
|
||||
Kind::State,
|
||||
Some(id("inner")),
|
||||
),
|
||||
("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))),
|
||||
("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))),
|
||||
("shared_work", "Record request", Kind::Activity, None),
|
||||
] {
|
||||
element(&mut p, alias, name, kind, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical record request",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target, kind) in [
|
||||
(
|
||||
"between",
|
||||
"Ready\nfor review",
|
||||
"state_a",
|
||||
"state_b",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"external",
|
||||
"Complete request",
|
||||
"state_b",
|
||||
"state_c",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"entry_route",
|
||||
"Begin classification",
|
||||
"entry",
|
||||
"state_leaf",
|
||||
TransitionKind::Local,
|
||||
),
|
||||
(
|
||||
"exit_route",
|
||||
"Classification complete",
|
||||
"state_leaf",
|
||||
"exit",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"refresh",
|
||||
"Refresh record",
|
||||
"state_a",
|
||||
"state_a",
|
||||
TransitionKind::Internal,
|
||||
),
|
||||
] {
|
||||
transition(&mut p, alias, name, source, target, kind)?;
|
||||
}
|
||||
for (alias, source, phase) in [
|
||||
("action_a", "state_a", StateActionKind::Entry),
|
||||
("action_c", "state_c", StateActionKind::Do),
|
||||
] {
|
||||
let old = p.relate(
|
||||
RelationKind::StateAction,
|
||||
"Record shared work",
|
||||
id(source),
|
||||
id("shared_work"),
|
||||
);
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve shared-work action {alias}.");
|
||||
if let RelationBody::StateAction { phase: value, .. } = &mut relation.body {
|
||||
*value = phase;
|
||||
}
|
||||
}
|
||||
for (alias, name) in [
|
||||
("state_view", "State multiselect - main"),
|
||||
("state_other", "Other State view - preserve"),
|
||||
] {
|
||||
diagram(&mut p, alias, name, Layer::Oa);
|
||||
p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine {
|
||||
machine: id("machine"),
|
||||
};
|
||||
}
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
// The main machine and outer Region are deliberately unplaced to keep
|
||||
// the target headers readable in a 1442 x 932 native window after Fit.
|
||||
// A starts at the authored origin so saved A recovers the exact f32 delta;
|
||||
// subtracting a nonzero original can round it differently from sibling XY.
|
||||
for (alias, object, x, y, width, height) in [
|
||||
("node_a", "state_a", 0., 0., 340., 450.),
|
||||
("node_b", "state_b", 410., 30., 210., 150.),
|
||||
("node_c", "state_c", 410., 330., 210., 140.),
|
||||
("node_inner", "inner", 25., 220., 290., 200.),
|
||||
("node_leaf", "state_leaf", 60., 290., 180., 90.),
|
||||
("node_entry", "entry", -210., 280., 24., 24.),
|
||||
("node_exit", "exit", 870., 350., 24., 24.),
|
||||
] {
|
||||
place(
|
||||
&mut p,
|
||||
"state_view",
|
||||
alias,
|
||||
object,
|
||||
point(x, y),
|
||||
Size { width, height },
|
||||
);
|
||||
}
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_a",
|
||||
"state_a",
|
||||
point(40., 60.),
|
||||
Size {
|
||||
width: 340.,
|
||||
height: 220.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_b",
|
||||
"state_b",
|
||||
point(460., 80.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![
|
||||
route("between", vec![point(365., 70.), point(365., 120.)]),
|
||||
route("external", vec![point(660., 140.), point(660., 360.)]),
|
||||
route("entry_route", vec![point(40., 280.), point(40., 320.)]),
|
||||
route("exit_route", vec![point(290., 335.), point(290., 370.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed recording refinement".into(),
|
||||
description:
|
||||
"Moving states or hiding placements must preserve shared work and its refinement."
|
||||
.into(),
|
||||
oa: id("shared_work"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("shared_work")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"crew",
|
||||
"machine",
|
||||
"region",
|
||||
"inner",
|
||||
"entry",
|
||||
"exit",
|
||||
"shared_work",
|
||||
"node_inner",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
"entry_route",
|
||||
"exit_route",
|
||||
"refresh",
|
||||
"action_a",
|
||||
"action_c",
|
||||
"other_b",
|
||||
"project",
|
||||
"state_view",
|
||||
"state_other",
|
||||
"sa_view",
|
||||
"state_a",
|
||||
"state_b",
|
||||
"state_c",
|
||||
"state_leaf",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_leaf",
|
||||
"between",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"],
|
||||
"translated_routes": ["between", "entry_route", "exit_route"],
|
||||
"fixed_route": "external",
|
||||
"window": [1442, 932],
|
||||
"target_headers_world": {"A": [40, 35], "B": [450, 65]},
|
||||
"delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization",
|
||||
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.ok_or("Missing State view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing baseline State view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing saved State view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 300.
|
||||
|| d.x.hypot(d.y) < 0.01
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=0.01 and each axis <=300 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("state_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in [
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_inner",
|
||||
"node_leaf",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let route = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.ok_or("Missing explicitly expected moved route")?;
|
||||
for pin in &mut route.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("state_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [
|
||||
point(7.25, -2.75),
|
||||
point(-20.5, 30.125),
|
||||
point(0.25, 0.),
|
||||
point(0.01, 10.),
|
||||
point(0.1, -0.2),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] {
|
||||
let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let original_pins = edge(&mut before, alias).waypoints.clone();
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, alias).waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
}
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "between"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 9] = [
|
||||
|p| p.traces.get_mut(&id("trace")).unwrap().baseline = None,
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("shared_work"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateAction { phase, .. } =
|
||||
&mut p.relations.get_mut(&id("action_a")).unwrap().body
|
||||
{
|
||||
*phase = StateActionKind::Exit;
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateTransition { guard, .. } =
|
||||
&mut p.relations.get_mut(&id("refresh")).unwrap().body
|
||||
{
|
||||
*guard = "changed_refresh_guard".into();
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("state_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_excessive_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=0.01"),
|
||||
(point(310., 0.), "each axis <=300"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_leaf"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("state_a")),
|
||||
Command::Delete(id("state_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("state_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() {
|
||||
let before = fixture().unwrap();
|
||||
for (mode, actual) in [
|
||||
("moved", moved(&before, point(7.25, -2.75))),
|
||||
("hide", hidden(&before)),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
let bundle = roundtrip(&actual).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("saved").join(&bundle.entry),
|
||||
)
|
||||
.unwrap();
|
||||
let mut corrupt = actual.clone();
|
||||
corrupt.elements.get_mut(&id("shared_work")).unwrap().name =
|
||||
"Unrequested source edit".into();
|
||||
let bundle = roundtrip(&corrupt).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
let error = check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("corrupt").join(&bundle.entry),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Whole Project mismatch"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ mod reference_authoring;
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_focus_tests;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_tests;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
@@ -264,6 +268,7 @@ mod direct_scenario_tests {
|
||||
#[cfg(test)]
|
||||
mod behavior_lifecycle_tests;
|
||||
mod process_canvas;
|
||||
mod process_multiselect;
|
||||
mod state_diagram;
|
||||
#[cfg(test)]
|
||||
mod state_diagram_tests;
|
||||
@@ -814,6 +819,9 @@ impl App {
|
||||
.map(|n| n.id);
|
||||
}
|
||||
self.draft = Draft::from_project(self.project(), self.selected);
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
if self.exact_strings
|
||||
&& let Some(d) = &mut self.draft
|
||||
{
|
||||
@@ -1111,10 +1119,13 @@ impl App {
|
||||
previous != (self.layer, self.active_diagram)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
// An exact Process preview and its defining object share a semantic ID,
|
||||
// but ordinary Explorer/property navigation must still leave the use.
|
||||
let process_preview = self.process_preview_primary().is_some();
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id {
|
||||
if self.selected == id && !process_preview {
|
||||
return;
|
||||
}
|
||||
if !self.apply() {
|
||||
@@ -1123,6 +1134,10 @@ impl App {
|
||||
if !self.project().contains(id) {
|
||||
return;
|
||||
}
|
||||
self.behavior.topology.clear_context();
|
||||
if process_preview {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
let previous_state_view = self.state_view_identity();
|
||||
self.selected = id;
|
||||
self.selected_node = None;
|
||||
@@ -1290,13 +1305,85 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::DiagramSelection(event) = &message
|
||||
if let Message::DiagramSelection(event)
|
||||
| Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message
|
||||
&& !self.multiselect_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::StateDiagram(event) = &message
|
||||
&& let Some(captured) = event.context()
|
||||
&& self.state_context().is_none_or(|current| {
|
||||
crate::behavior::state_edit::validate_context(self.project(), captured, current)
|
||||
.is_err()
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
&message,
|
||||
Message::StateDiagram(
|
||||
crate::behavior::state_edit::Event::Select(..)
|
||||
| crate::behavior::state_edit::Event::Move { .. }
|
||||
| crate::behavior::state_edit::Event::Remove(_)
|
||||
)
|
||||
) && self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A queued singleton cannot retire the current group's navigation
|
||||
// or allocation ownership before its edit is refused.
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::TopologyCanvas(event) = &message {
|
||||
use crate::behavior::topology::CanvasEvent;
|
||||
if !matches!(
|
||||
event,
|
||||
CanvasEvent::Camera(..) | CanvasEvent::Scrolled(..) | CanvasEvent::Viewport(..)
|
||||
) && self.topology_context() != Some(event.context())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
CanvasEvent::Select(..)
|
||||
| CanvasEvent::SelectQualified(..)
|
||||
| CanvasEvent::Remove(..)
|
||||
| CanvasEvent::Reorder { .. }
|
||||
| CanvasEvent::Reconnect { .. }
|
||||
) && self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if self.process_order_noop(&message) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.process_preview_primary().is_some()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Field(..)
|
||||
| Message::FieldText(..)
|
||||
| Message::FieldShape(..)
|
||||
| Message::FieldAppend(..)
|
||||
| Message::FieldValuePath(..)
|
||||
| Message::FieldVariant(..)
|
||||
| Message::FieldArray(..)
|
||||
| Message::ElementKind(_)
|
||||
| Message::RelationKind(_)
|
||||
| Message::Apply
|
||||
| Message::Delete
|
||||
| Message::DiagramRemove(_)
|
||||
)
|
||||
{
|
||||
self.status = "Open the referenced definition to edit its contents.".into();
|
||||
return Task::none();
|
||||
}
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
@@ -1387,10 +1474,13 @@ impl App {
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
if self.multiselect_boundary() != selection_boundary
|
||||
|| (self.diagram_selection.is_none() && self.page == Page::Diagram)
|
||||
|| (self.diagram_selection.is_none() && self.multiselect_context().is_some())
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
||||
self.refresh_draft();
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -2304,6 +2394,9 @@ impl App {
|
||||
self.route_context()
|
||||
};
|
||||
}
|
||||
if request.process.is_some() {
|
||||
request.process = self.topology_context().map(Box::new);
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
@@ -2333,11 +2426,19 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_)) {
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
if self.page == Page::Behavior && menu.request.canvas.is_some() {
|
||||
self.behavior.diagram.placement = menu.request.placement;
|
||||
self.selected_node = None;
|
||||
} else {
|
||||
self.selected_node = menu.request.placement;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
@@ -3181,7 +3282,11 @@ impl App {
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
|
||||
self.status = if self.topology_context().is_some() {
|
||||
"Group removal needs an aggregate review. Select one contribution to remove it individually."
|
||||
} else {
|
||||
"Group model-deletion review is not available yet. Select one element or use Hide selected."
|
||||
}.into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.selected != self.project().id && self.apply() {
|
||||
|
||||
@@ -518,9 +518,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
|
||||
)) if actual == &endpoint
|
||||
)));
|
||||
assert_eq!(app.selected, references[0]);
|
||||
assert_eq!(app.selected, f.nodes[0]);
|
||||
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(
|
||||
app.behavior
|
||||
.topology
|
||||
|
||||
@@ -319,14 +319,7 @@ impl App {
|
||||
| Target::ScenarioObject(id)
|
||||
| Target::AllocationGrouping { relation: id, .. }
|
||||
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
|
||||
Target::ProcessOccurrence(endpoint) => (
|
||||
endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
None,
|
||||
),
|
||||
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
|
||||
};
|
||||
match usage.surface {
|
||||
Surface::Saved(id) => {
|
||||
|
||||
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
let first_camera = app.behavior.topology.camera_boundary();
|
||||
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
})
|
||||
.unwrap();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, second);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert!(app.behavior.topology.camera_boundary() != first_camera);
|
||||
assert_eq!(authored(&app), before);
|
||||
|
||||
@@ -19,6 +19,10 @@ pub(crate) enum Event {
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
ProcessReorder(
|
||||
Snapshot,
|
||||
crate::behavior::topology::group_reorder::Destination,
|
||||
),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
@@ -33,6 +37,17 @@ pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Process(c.owner),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
@@ -49,7 +64,12 @@ pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeS
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(view: &projection::ProjectedView, target: &Target) -> bool {
|
||||
fn visible(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &projection::ProjectedView,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
match target {
|
||||
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
|
||||
Target::Port { port, placement } => view
|
||||
@@ -57,6 +77,12 @@ fn visible(view: &projection::ProjectedView, target: &Target) -> bool {
|
||||
.iter()
|
||||
.any(|p| p.element == *port && p.owner == *placement),
|
||||
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => crate::behavior::state_canvas::visible_row_target(
|
||||
project, diagram, view, *placement, *relation,
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -70,14 +96,53 @@ impl App {
|
||||
self.active_diagram,
|
||||
self.selected,
|
||||
self.selected_node,
|
||||
(self.page == Page::Behavior).then(|| self.state_view_identity()),
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
// Other surfaces retain their existing adapters until their typed group
|
||||
// operations are implemented; never reuse an Architecture selection there.
|
||||
(self.page == Page::Diagram)
|
||||
.then(|| self.route_context().map(context))
|
||||
.flatten()
|
||||
match self.page {
|
||||
Page::Diagram => self.route_context().map(context),
|
||||
Page::Behavior => {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
Some(context(current))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner)
|
||||
.contains(target);
|
||||
}
|
||||
let Some(Context {
|
||||
scope:
|
||||
Scope {
|
||||
surface: Surface::Saved(diagram),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = self.multiselect_context()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
visible(self.project(), diagram, &view, target)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
|
||||
@@ -92,22 +157,55 @@ impl App {
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current))
|
||||
}
|
||||
Event::Move(s, _)
|
||||
| Event::Hide(s)
|
||||
| Event::ProcessReorder(s, _)
|
||||
| Event::Refuse(s, _)
|
||||
| Event::Collapse(s, _) => self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current)),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_target(&self, view: &projection::ProjectedView) -> Option<Target> {
|
||||
if let Some(id) = self.selected_node
|
||||
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if let Some(id) = placement
|
||||
&& view.nodes.iter().any(|node| {
|
||||
node.id == id && (node.element == self.selected || node.id == self.selected)
|
||||
})
|
||||
{
|
||||
return Some(Target::Placement(id));
|
||||
}
|
||||
if self.page == Page::Behavior {
|
||||
// Keep a row's exact occurrence while inspecting its relation.
|
||||
// Its owner placement is never a node-inspection or Hide target.
|
||||
if let Some(target @ Target::StateRow { relation, .. }) =
|
||||
self.diagram_selection.as_ref().and_then(|s| s.primary())
|
||||
&& *relation == self.selected
|
||||
&& visible(self.project(), diagram, view, target)
|
||||
{
|
||||
return Some(target.clone());
|
||||
}
|
||||
if let Some(node) = view.nodes.iter().find(|node| {
|
||||
crate::behavior::state_canvas::visible_row_target(
|
||||
self.project(),
|
||||
diagram,
|
||||
view,
|
||||
node.id,
|
||||
self.selected,
|
||||
)
|
||||
}) {
|
||||
return Some(Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: self.selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
|
||||
return Some(Target::Port {
|
||||
port: port.element,
|
||||
@@ -121,6 +219,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
@@ -133,28 +235,39 @@ impl App {
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
let legacy = self.legacy_target(&view);
|
||||
let legacy = self.legacy_target(diagram, &view);
|
||||
let lost_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| !visible(&view, target));
|
||||
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
|
||||
let primary_placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
let held_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| match target {
|
||||
Target::Placement(id) => self.selected_node == Some(*id),
|
||||
Target::Placement(id) => primary_placement == Some(*id),
|
||||
Target::Port { port, .. } => self.selected == *port,
|
||||
Target::Edge(id) => self.selected == *id,
|
||||
Target::StateRow { relation, .. } => {
|
||||
self.selected == *relation && primary_placement.is_none()
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let project = self.document.project();
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| visible(&view, target));
|
||||
selection.reconcile(current.scope, |target| {
|
||||
visible(project, diagram, &view, target)
|
||||
});
|
||||
// Navigation/property selection remains explicit. A removed exact target
|
||||
// is never rebound through its semantic identity to a different placement.
|
||||
if held_primary || lost_primary {
|
||||
@@ -163,7 +276,7 @@ impl App {
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |target| visible(&view, target));
|
||||
selection.replace(target, |target| visible(project, diagram, &view, target));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
@@ -171,26 +284,43 @@ impl App {
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
let diagram = self
|
||||
.multiselect_context()
|
||||
.and_then(|c| match c.scope.surface {
|
||||
Surface::Saved(diagram) => Some(diagram),
|
||||
_ => None,
|
||||
});
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(|s| s.primary())
|
||||
.cloned();
|
||||
self.selected_node = None;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = None;
|
||||
}
|
||||
match primary {
|
||||
Some(Target::Placement(id)) => {
|
||||
if let Some(node) = self
|
||||
.active_diagram
|
||||
if let Some(node) = diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
.and_then(|d| d.nodes.get(&id))
|
||||
{
|
||||
self.selected = node.element;
|
||||
self.selected_node = Some(id);
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = Some(id);
|
||||
} else {
|
||||
self.selected_node = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Target::Port { port, .. }) => self.selected = port,
|
||||
Some(Target::Edge(id)) => self.selected = id,
|
||||
_ => self.selected = self.active_diagram.unwrap_or(self.project().id),
|
||||
Some(Target::StateRow { relation, .. }) => self.selected = relation,
|
||||
_ => self.selected = diagram.unwrap_or(self.project().id),
|
||||
}
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
@@ -201,9 +331,15 @@ impl App {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.process_multiselect_event(event);
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the diagram selection.".into();
|
||||
@@ -219,23 +355,26 @@ impl App {
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::ProcessReorder(..) => {}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(&view, &target) {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
let project = self.document.project();
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |target| visible(&view, target));
|
||||
.replace(target, |target| visible(project, diagram, &view, target));
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible(&view, &target) {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
let project = self.document.project();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |target: &Target| visible(&view, target);
|
||||
let valid = |target: &Target| visible(project, diagram, &view, target);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
@@ -264,13 +403,13 @@ impl App {
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Move(snapshot, delta) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status = "Move requires only placed elements. Remove ports and connections from the selection.".into();
|
||||
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(&view, target))
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -318,7 +457,7 @@ impl App {
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(&view, target))
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,12 @@ impl App {
|
||||
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint);
|
||||
if let Ok(resolved) = resolved {
|
||||
// The definition may be opened from a nonprimary context
|
||||
// target. Remember that exact invocation, not whichever
|
||||
// qualified occurrence happened to be primary beforehand.
|
||||
self.behavior
|
||||
.topology
|
||||
.select_context(resolved.address.clone());
|
||||
self.behavior.topology.remember(
|
||||
context.owner,
|
||||
endpoint
|
||||
@@ -137,27 +143,18 @@ impl App {
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.topology.clear_context();
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::SelectQualified(_, endpoint) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& let Ok(resolved) =
|
||||
oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
let reference = endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence);
|
||||
self.select(reference);
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
self.multiselect_event(multiselect::Event::Choose(
|
||||
multiselect::process_context(context),
|
||||
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
|
||||
multiselect::Choice::Replace,
|
||||
));
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
@@ -198,6 +195,9 @@ impl App {
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
// Opening a local card returns to that local use, even if
|
||||
// a qualified tile was the previous inspection target.
|
||||
self.behavior.topology.clear_context();
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
@@ -222,8 +222,20 @@ impl App {
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
// Back restores a use, whereas ordinary select navigates
|
||||
// to a defining object. The validated return address owns
|
||||
// the exact subject; never infer it from the outer card ID.
|
||||
self.selected = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_address()
|
||||
.filter(|address| address.owner == owner)
|
||||
.map_or(occurrence, |address| address.endpoint.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
// Synchronize the exact primary and inspector before the
|
||||
// returned caller is drawn or accepts property edits.
|
||||
self.reconcile_process_multiselect();
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
@@ -293,8 +305,9 @@ impl App {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
Ok(topology::group_reorder::Plan::Noop) => {}
|
||||
Ok(topology::group_reorder::Plan::Change(command)) => {
|
||||
if self.execute(*command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Exact Process presentation selection and one atomic order transaction.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::{Plan, Prepared};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use multiselect::{Choice, Event};
|
||||
|
||||
fn subject(target: &Target) -> Option<Id> {
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence),
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn process_preview_primary(&self) -> Option<&Target> {
|
||||
let context = self.topology_context()?;
|
||||
let selection = self.diagram_selection.as_ref()?;
|
||||
if selection.scope() != multiselect::process_context(context).scope {
|
||||
return None;
|
||||
}
|
||||
selection
|
||||
.primary()
|
||||
.filter(|target| subject(target) == Some(self.selected))
|
||||
.filter(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_process_multiselect(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let current = multiselect::process_context(context);
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let address = self.behavior.topology.selection_address();
|
||||
let focused = self
|
||||
.diagram_focus(Surface::Process(context.owner))
|
||||
.map(|usage| usage.target.clone());
|
||||
let legacy = focused
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
address
|
||||
.as_ref()
|
||||
.filter(|a| a.owner == context.owner && a.endpoint.occurrence == self.selected)
|
||||
.map(|a| Target::ProcessOccurrence(a.endpoint.clone()))
|
||||
})
|
||||
.or_else(|| {
|
||||
visible
|
||||
.iter()
|
||||
.find(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
endpoint.references.is_empty() && endpoint.occurrence == self.selected
|
||||
}
|
||||
Target::ProcessLink(id) => *id == self.selected,
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.filter(|target| visible.contains(target));
|
||||
let held = old.as_ref().is_some_and(|target| {
|
||||
subject(target) == Some(self.selected)
|
||||
&& focused.as_ref().is_none_or(|focused| focused == target)
|
||||
&& match target {
|
||||
Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => {
|
||||
address
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selection.primary() != old.as_ref() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |t| visible.contains(t));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_process_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
self.behavior.topology.clear_context();
|
||||
self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner);
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
if let Some(Target::ProcessOccurrence(endpoint)) = primary
|
||||
&& !endpoint.references.is_empty()
|
||||
&& let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
self.refresh_draft();
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_multiselect_event(&mut self, event: Event) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
if let Event::Refuse(_, reason) = &event
|
||||
&& diagram_removal::enabled(self)
|
||||
{
|
||||
self.status = reason.clone();
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() {
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the process selection.".into();
|
||||
return;
|
||||
}
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
match event {
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_process_multiselect();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |t: &Target| visible.contains(t);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |t| visible.contains(t));
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
self.diagram_selection.as_mut().unwrap().clear();
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Hide(_) => {
|
||||
self.status =
|
||||
"Process cards are model contributions, not placements. Hide is unavailable."
|
||||
.into()
|
||||
}
|
||||
Event::Move(..) => {
|
||||
self.status =
|
||||
"Process positions come from their paths. Reorder within one column instead."
|
||||
.into()
|
||||
}
|
||||
Event::ProcessReorder(snapshot, destination) => {
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
match Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(destination))
|
||||
{
|
||||
Ok(Plan::Noop) => {}
|
||||
Ok(Plan::Change(command)) => {
|
||||
if self.execute(*command) {
|
||||
self.inspect_process_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Reordered {} contributions · Undo restores their order",
|
||||
snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A no-op must not retire a pending reveal or allocate a history entry.
|
||||
pub(super) fn process_order_noop(&self, message: &Message) -> bool {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return false;
|
||||
};
|
||||
match message {
|
||||
Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => {
|
||||
Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(*destination))
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop))
|
||||
}
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder {
|
||||
context: captured,
|
||||
occurrence,
|
||||
before,
|
||||
}) if *captured == context => crate::behavior::topology::reorder(
|
||||
self.project(),
|
||||
context.owner,
|
||||
*occurrence,
|
||||
*before,
|
||||
)
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,979 @@
|
||||
//! Process selection through the real App admission, document and source boundary.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::Destination;
|
||||
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
nodes: [Id; 5],
|
||||
link: Id,
|
||||
work: Id,
|
||||
other_owner: Id,
|
||||
}
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
project.elements[&owner].layer(),
|
||||
name,
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
*value = body;
|
||||
let id = element.id;
|
||||
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn open(project: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.topology_context().unwrap().owner, owner);
|
||||
assert_eq!(
|
||||
app.multiselect_context().unwrap().scope.surface,
|
||||
Surface::Process(owner)
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut nodes = vec![];
|
||||
for name in ["a", "u", "b", "v", "c"] {
|
||||
nodes.push(add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
let nodes: [Id; 5] = nodes.try_into().unwrap();
|
||||
let [a, u, b, v, _] = nodes;
|
||||
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
|
||||
project.relate(RelationKind::SequenceLink, "b to v", b, v);
|
||||
let other = project
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Other owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
app: open(project, f.owner),
|
||||
owner: f.owner,
|
||||
nodes,
|
||||
link,
|
||||
work: f.work[0],
|
||||
other_owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
send(app, Event::Choose(context, target, choice));
|
||||
}
|
||||
fn pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(f.nodes[4]), local(f.nodes[0])]
|
||||
);
|
||||
}
|
||||
fn source_matches(app: &App) {
|
||||
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
fn marker(app: &mut App, owner: Id, occurrence: Id) {
|
||||
use crate::diagram_usage as usage;
|
||||
let target = usage::collect(
|
||||
app.project(),
|
||||
occurrence,
|
||||
&app.behavior.topology,
|
||||
&app.behavior.scenario,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
|
||||
.unwrap();
|
||||
let context = usage::Context {
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
subject: occurrence,
|
||||
};
|
||||
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
|
||||
app.diagram_navigation_focus = Some(usage::Focus {
|
||||
context,
|
||||
usage: target,
|
||||
selected: app.selected,
|
||||
});
|
||||
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let [a, u, b, v, c] = f.nodes;
|
||||
pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.primary(), Some(&local(a)));
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(b)),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![b, u, a, v, c];
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected, a);
|
||||
let after_selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(after_selection.targets(), selection.targets());
|
||||
assert_eq!(after_selection.primary(), selection.primary());
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
|
||||
for group in [true, false] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
if group {
|
||||
pair(&mut f);
|
||||
} else {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
}
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
|
||||
f.app.status = "Unchanged order feedback".into();
|
||||
let message = if group {
|
||||
Message::DiagramSelection(Event::ProcessReorder(
|
||||
selection.clone(),
|
||||
Destination::Before(f.nodes[4]),
|
||||
))
|
||||
} else {
|
||||
Message::TopologyCanvas(CanvasEvent::Reorder {
|
||||
context: f.app.topology_context().unwrap(),
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[2],
|
||||
})
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
|
||||
assert_eq!(f.app.status, "Unchanged order feedback");
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for mixed in [true, false] {
|
||||
let mut f = fixture(layer);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let other = if mixed {
|
||||
Target::ProcessLink(f.link)
|
||||
} else {
|
||||
local(f.nodes[1])
|
||||
};
|
||||
choose(&mut f.app, other.clone(), Choice::Add);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
|
||||
let before = f.app.document.snapshot();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
|
||||
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
"revision" => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(f.work, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"owner" => {
|
||||
f.app.behavior.active_owner = Some(f.other_owner);
|
||||
f.app.selected = f.other_owner;
|
||||
f.app.reconcile_multiselect();
|
||||
}
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
_ => {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
stale.primary()
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let selected = f.app.selected;
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
f.app.status = "Current owner feedback".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{stale_by}"
|
||||
);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current owner feedback");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
|
||||
for pending in [
|
||||
"properties",
|
||||
"invalid properties",
|
||||
"source",
|
||||
"form",
|
||||
"tool",
|
||||
"busy",
|
||||
] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied contribution".into(),
|
||||
));
|
||||
}
|
||||
"invalid properties" => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/alias".into(), "invalid alias!".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"form" => {
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
|
||||
topology::Event::Name("Unsubmitted form".into()),
|
||||
)));
|
||||
assert!(f.app.behavior.has_pending());
|
||||
}
|
||||
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
|
||||
_ => f.app.busy = true,
|
||||
}
|
||||
if pending == "source" {
|
||||
assert!(f.app.source_dirty());
|
||||
}
|
||||
if pending.contains("properties") {
|
||||
assert!(f.app.draft_dirty());
|
||||
}
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
for event in [
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
Event::Clear(selection.context()),
|
||||
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
|
||||
] {
|
||||
send(&mut f.app, event);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{pending}"
|
||||
);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
f.app.status = "Group owns input".into();
|
||||
for event in [
|
||||
CanvasEvent::Select(context, f.nodes[2]),
|
||||
CanvasEvent::SelectQualified(
|
||||
context,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.nodes[2],
|
||||
},
|
||||
),
|
||||
CanvasEvent::Remove(context, f.nodes[0]),
|
||||
CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[4],
|
||||
},
|
||||
CanvasEvent::Reconnect {
|
||||
context,
|
||||
link: f.link,
|
||||
end: topology::End::Target,
|
||||
target: f.nodes[4],
|
||||
},
|
||||
] {
|
||||
let _ = f.app.update(Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(f.app.status, "Group owns input");
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let sequence = project
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
|
||||
// Collapsed reuse cards still show four preview entries. Keep the tested
|
||||
// leaf beyond that bound so collapsing really removes its exact target.
|
||||
for index in 0..4 {
|
||||
add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
&format!("Preview prefix {index}"),
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let leaf = add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
"Shared check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let first = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"First call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let second = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"Second call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
f.app = open(project, f.owner);
|
||||
let p = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
|
||||
(f, sequence_id, first, second, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
|
||||
let (mut f, _, first, second, leaf) = qualified_fixture();
|
||||
let p = f.app.project().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let target = Target::ProcessOccurrence(endpoint.clone());
|
||||
assert!(f.app.multiselect_target_visible(&target));
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(
|
||||
f.app.selected, leaf,
|
||||
"Inspect exact leaf, not the first caller or shared work"
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert_eq!(
|
||||
f.app
|
||||
.behavior
|
||||
.topology
|
||||
.selection_address()
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
for message in [
|
||||
Message::Field("/name".into(), "Forbidden definition rename".into()),
|
||||
Message::Apply,
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(!f.app.delete_pending);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
}
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
let mixed = f.app.multiselect_snapshot().unwrap();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
let current = f.app.multiselect_context().unwrap();
|
||||
send(&mut f.app, Event::Clear(current));
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
choose(&mut f.app, target, Choice::Replace);
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn menu(app: &mut App, target: Target, label: &str) -> Message {
|
||||
use crate::context_menu::Entry;
|
||||
use crate::model_menu;
|
||||
let object = match &target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
_ => panic!("This fixture opens contribution menus"),
|
||||
};
|
||||
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(target));
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let opened = app
|
||||
.model_menu
|
||||
.as_ref()
|
||||
.expect("Captured Process menu must survive the open epoch change");
|
||||
assert!(opened.valid(app));
|
||||
assert_eq!(
|
||||
opened.request.process.as_deref().copied(),
|
||||
app.topology_context()
|
||||
);
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action {
|
||||
label: name,
|
||||
message: Some(message),
|
||||
..
|
||||
} if name == label => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.topology_context().unwrap();
|
||||
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
|
||||
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
let _ = f.app.update(action);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
|
||||
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
|
||||
assert_eq!(f.app.selected, f.nodes[4]);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(definition));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_definition_back_restores_exact_invocation_readonly_subject_and_camera() {
|
||||
for open_nonprimary in [false, true] {
|
||||
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
||||
// The shared fixture has already expanded both fifth-tile invocations.
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.75),
|
||||
)));
|
||||
let first_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
let second_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf,
|
||||
});
|
||||
if open_nonprimary {
|
||||
// Keep the requested second path outside the current selection so
|
||||
// its ordinary definition action is available in its context menu.
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
choose(&mut f.app, first_target.clone(), Choice::Add);
|
||||
} else {
|
||||
choose(&mut f.app, first_target.clone(), Choice::Replace);
|
||||
choose(&mut f.app, second_target.clone(), Choice::Add);
|
||||
}
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(if open_nonprimary {
|
||||
&first_target
|
||||
} else {
|
||||
&second_target
|
||||
})
|
||||
);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.draft.is_none());
|
||||
let original = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let target = second_target;
|
||||
let action = if open_nonprimary {
|
||||
menu(&mut f.app, target.clone(), "Open referenced definition")
|
||||
} else {
|
||||
let Target::ProcessOccurrence(endpoint) = &target else {
|
||||
unreachable!()
|
||||
};
|
||||
Message::TopologyCanvas(CanvasEvent::OpenQualified(
|
||||
f.app.topology_context().unwrap(),
|
||||
endpoint.clone(),
|
||||
))
|
||||
};
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(f.app.draft.is_some(), "Definition view is editable");
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
std::slice::from_ref(&target)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&target)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.selected, leaf,
|
||||
"Back must not inspect the outer reference"
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
let Target::ProcessOccurrence(endpoint) = &target else {
|
||||
unreachable!()
|
||||
};
|
||||
let address = f.app.behavior.topology.selection_address().unwrap();
|
||||
assert_eq!(address.owner, f.owner);
|
||||
assert_eq!(&address.endpoint, endpoint);
|
||||
assert!(
|
||||
f.app.multiselect_target_visible(&target),
|
||||
"Caller expansions survive Back"
|
||||
);
|
||||
assert!(
|
||||
f.app.draft.is_none(),
|
||||
"Returned qualified inspector stays read-only"
|
||||
);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Must not edit the outer reference or definition".into(),
|
||||
));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert!(Arc::ptr_eq(&original, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
|
||||
for catalog in [false, true] {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
if catalog {
|
||||
let _ = f.app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(f.app.page, Page::Catalog);
|
||||
} else {
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
}
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app.draft.is_some(),
|
||||
"Leaving a read-only presentation restores normal property editing"
|
||||
);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Definition edit stays unapplied".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_explicit_object_navigation_leaves_qualified_use_even_for_same_leaf_id() {
|
||||
for edit_properties in [false, true] {
|
||||
for defining_leaf in [false, true] {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut f.app, qualified.clone(), Choice::Replace);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&qualified));
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let target = if defining_leaf { leaf } else { first };
|
||||
let expected_owner = if defining_leaf { definition } else { f.owner };
|
||||
let message = if edit_properties {
|
||||
Message::EditProperties(target)
|
||||
} else {
|
||||
Message::Select(target)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.selected, target);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, expected_owner);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(expected_owner));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(target)]
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&local(target))
|
||||
);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app.draft.is_some(),
|
||||
"Explicit model-object navigation restores its editor"
|
||||
);
|
||||
assert!(!f.app.draft_dirty());
|
||||
if !defining_leaf {
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_open_local_reference_after_qualified_inspection_returns_local_reference() {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
choose(
|
||||
&mut f.app,
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Choice::Replace,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Open(context, first)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
assert_eq!(f.app.selected, definition);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert_eq!(f.app.selected, first);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(first)]
|
||||
);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(f.app.draft.is_some());
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
|
||||
use crate::diagram_usage as usage;
|
||||
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let next = add(
|
||||
&mut project,
|
||||
definition,
|
||||
"Following check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
|
||||
f.app = open(project, f.owner);
|
||||
let project = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
|
||||
let before = f.app.document.snapshot();
|
||||
for call in [first, second] {
|
||||
let target = Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaf,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: next,
|
||||
},
|
||||
};
|
||||
let context = usage::Context {
|
||||
document: f.app.canvas_document,
|
||||
revision: f.app.document.revision(),
|
||||
subject: relation,
|
||||
};
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Open(context)));
|
||||
let token = f.app.diagram_usages.as_ref().unwrap().token;
|
||||
let rows = usage::collect(
|
||||
f.app.project(),
|
||||
relation,
|
||||
&f.app.behavior.topology,
|
||||
&f.app.behavior.scenario,
|
||||
);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(rows),
|
||||
}));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
|
||||
assert_eq!(f.app.selected, relation);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&target)
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ async fn armed() -> (App, Mounted, Id) {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::new(450., 400.),
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
|
||||
@@ -79,6 +79,7 @@ fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action(
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
bend: Some(1),
|
||||
route_segment: Some((2, point)),
|
||||
|
||||
@@ -114,6 +114,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Selection(event) => self.multiselect_event(event),
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
@@ -156,6 +157,7 @@ impl App {
|
||||
context,
|
||||
object,
|
||||
placement,
|
||||
selection_target,
|
||||
anchor,
|
||||
bend,
|
||||
route_segment,
|
||||
@@ -163,6 +165,7 @@ impl App {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.selection_target = selection_target.map(Box::new);
|
||||
request.bend = bend;
|
||||
request.route_segment = route_segment;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! State selection layout and modifier admission on the mounted production App.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, key},
|
||||
state_edit,
|
||||
};
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
a_element: Id,
|
||||
b_element: Id,
|
||||
edge: Id,
|
||||
action: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
|
||||
.unwrap();
|
||||
let a_element = p
|
||||
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
|
||||
.unwrap();
|
||||
let b_element = p
|
||||
.add_element(
|
||||
Kind::State,
|
||||
Layer::Oa,
|
||||
&format!("Second state {}", "with a long primary name ".repeat(20)),
|
||||
Some(region),
|
||||
)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
|
||||
.unwrap();
|
||||
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
|
||||
let edge = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Next state",
|
||||
a_element,
|
||||
b_element,
|
||||
);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let mut placements = vec![];
|
||||
// All State elements, including machine/Region, are initially placed.
|
||||
// A fixture already missing its containers would not test picker insertion.
|
||||
for (element, x, y, width, height) in [
|
||||
(machine, 0., 0., 800., 500.),
|
||||
(region, 20., 70., 760., 410.),
|
||||
(a_element, 60., 160., 230., 180.),
|
||||
(b_element, 430., 160., 260., 180.),
|
||||
] {
|
||||
let node = p.place(diagram, element, Position { x, y });
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size = oa_core::Size { width, height };
|
||||
placements.push(node);
|
||||
}
|
||||
let a = placements[2];
|
||||
let b = placements[3];
|
||||
validate_candidate(&p).unwrap();
|
||||
assert!(state_edit::unplaced(&p, diagram).is_empty());
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
fit: true,
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
a_element,
|
||||
b_element,
|
||||
edge,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
|
||||
let geometry = mounted.geometry(app);
|
||||
let (_, bounds, focused) = geometry
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("real State focus::Owner rectangle must be measurable");
|
||||
(bounds, focused)
|
||||
}
|
||||
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
|
||||
let state = &app.behavior.diagram;
|
||||
(state.zoom, state.pan, state.fit, state.viewport)
|
||||
}
|
||||
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(app, task).await;
|
||||
mounted.settle(app).await;
|
||||
}
|
||||
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
|
||||
let bounds = owner(mounted, &f.app).0;
|
||||
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
|
||||
let state = &f.app.behavior.diagram;
|
||||
Point::new(
|
||||
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
|
||||
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
|
||||
)
|
||||
}
|
||||
fn modifiers(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
struct FocusField(Rectangle);
|
||||
impl Operation for FocusField {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let baseline = f.app.document.snapshot();
|
||||
let bounds = owner(&mut mounted, &f.app).0;
|
||||
let fitted = camera(&f.app);
|
||||
assert!(
|
||||
fitted.2,
|
||||
"keep Fit active to expose any resize-triggered camera jump"
|
||||
);
|
||||
assert!(fitted.3.is_some());
|
||||
assert!(bounds.width > 400. && bounds.height > 200.);
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
for (target, expected) in [
|
||||
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
|
||||
(
|
||||
Some(Target::Placement(f.b)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Edge(f.edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
}),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
},
|
||||
],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
selection(&mut mounted, &mut f.app, target).await;
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"selection strip changed the measured State canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
camera(&f.app),
|
||||
fitted,
|
||||
"selection feedback silently refitted the camera"
|
||||
);
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
|
||||
let captured = f.app.multiselect_snapshot().unwrap();
|
||||
let task = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Hide(
|
||||
captured,
|
||||
)));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing,
|
||||
std::collections::BTreeSet::from([f.a_element, f.b_element])
|
||||
);
|
||||
let mut expected = baseline.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.a);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"the first unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let hidden_source = f.app.document.bundle().clone();
|
||||
let task = f.app.update(Message::Undo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"removing the unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let task = f.app.update(Message::Redo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &hidden_source);
|
||||
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
let field = mounted
|
||||
.geometry(&f.app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("State Inspector field is mounted");
|
||||
mounted.operate(&f.app, &mut FocusField(field));
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
let before = f.app.document.snapshot();
|
||||
mounted
|
||||
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!owner(&mut mounted, &f.app).1,
|
||||
"passive Shift must not acquire State canvas focus"
|
||||
);
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
let messages = mounted.click(&mut f.app, at).await;
|
||||
assert!(messages.iter().any(|message| matches!(message,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
|
||||
)), "StateCanvas must map its typed selection event to the common App boundary");
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
|
||||
assert_eq!(f.app.selected_node, None);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
// Explicitly revoke focus rather than relying on incidental initial focus.
|
||||
mounted.operate(
|
||||
&f.app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
f.app.busy = true;
|
||||
mounted.settle(&mut f.app).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[modifiers(true), key(keyboard::key::Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a)]
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
f.app.busy = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
// No second modifier event: the passive suspended update must survive.
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ struct Input {
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
bounds: Option<Rectangle>,
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
@@ -83,7 +84,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
(s.document, s.owner, s.revision)
|
||||
!= (self.scope.document, self.scope.owner, self.scope.revision)
|
||||
}) {
|
||||
*state = Input::default();
|
||||
let modifiers = state.modifiers;
|
||||
*state = Input {
|
||||
modifiers,
|
||||
..Input::default()
|
||||
};
|
||||
}
|
||||
if state
|
||||
.scope
|
||||
@@ -143,10 +148,29 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
// Physical modifiers outlive focus, busy-state child replacement,
|
||||
// and semantic revisions. Observing these emits no camera command.
|
||||
state.modifiers = *modifiers;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
// A traversal operation is applied after this Iced event batch. Keep
|
||||
// its focus witness, but retire keyboard admission immediately.
|
||||
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
|
||||
@@ -334,6 +358,20 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) {
|
||||
// A canvas may have been rebuilt while focus/commands were denied.
|
||||
// Restore held (or released) Shift before this new pointer gesture.
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&Event::Keyboard(keyboard::Event::ModifiersChanged(state.modifiers)),
|
||||
layout,
|
||||
child_cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
|
||||
@@ -3,6 +3,58 @@ use crate::behavior::canvas_workspace::tests::{Geometry, Mounted};
|
||||
use iced::Vector;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_scenario_passive_modifiers_do_not_acquire_keyboard_focus_or_mutate_model() {
|
||||
use crate::behavior::canvas_workspace::tests::{focused, key};
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + Vector::new(50., 50.);
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let source = app.document.bundle().clone();
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if held {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
for busy in [false, true, false] {
|
||||
app.busy = busy;
|
||||
mounted.settle(&mut app).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
shift(true),
|
||||
shift(false),
|
||||
key(iced::keyboard::key::Named::ArrowRight),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
mounted
|
||||
.pan(&mut app, point, point + Vector::new(-15., 25.))
|
||||
.await;
|
||||
assert!(app.behavior.scenario.camera_boundary() != camera);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() {
|
||||
use crate::behavior::canvas_workspace::{self, tests::Mounted};
|
||||
|
||||
@@ -391,7 +391,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
motion(&canvas, &mut state, pin, pin);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
|
||||
if context == canvas.context && object == relation && anchor == pin)
|
||||
);
|
||||
let (index, midpoint) = bend_segment(&scene, relation);
|
||||
@@ -399,7 +399,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) })
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
|
||||
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Persisted state-diagram geometry with context-bound, ordered pointer gestures.
|
||||
use super::state_edit::{self, Context, Endpoint, Event, Tool};
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent, context as selection_context};
|
||||
use crate::diagram::group_gesture::{
|
||||
DisplacementPolicy, Gesture as GroupGesture, Outcome, Viewport,
|
||||
};
|
||||
use crate::diagram_selection::{Selection, Snapshot};
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
|
||||
@@ -27,6 +33,7 @@ pub struct StateCanvas<'a> {
|
||||
pub diagram: Id,
|
||||
pub context: Context,
|
||||
pub selected: Id,
|
||||
pub selection: Option<&'a Selection>,
|
||||
/// Presentation-only navigation marker; never an authoring placement.
|
||||
pub navigation_focus: Option<Rectangle>,
|
||||
pub tool: Tool,
|
||||
@@ -41,6 +48,8 @@ pub struct State {
|
||||
tool: Option<Tool>,
|
||||
pointer: Option<Point>,
|
||||
press: Option<Press>,
|
||||
group: Option<GroupPress>,
|
||||
modifiers: keyboard::Modifiers,
|
||||
source: Option<Id>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
@@ -113,6 +122,7 @@ struct Scene {
|
||||
}
|
||||
struct CachedScene {
|
||||
context: Context,
|
||||
group_preview: Option<(Snapshot, Position)>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
@@ -121,6 +131,8 @@ struct CachedScene {
|
||||
#[derive(PartialEq)]
|
||||
struct PaintKey {
|
||||
context: Context,
|
||||
group_preview: Option<(Snapshot, Position)>,
|
||||
selection: Option<Snapshot>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
@@ -132,12 +144,82 @@ struct PaintKey {
|
||||
enum Hit {
|
||||
Node { placement: Id, element: Id },
|
||||
Transition(Id),
|
||||
StateAction(Id),
|
||||
Row { placement: Id, relation: Id },
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
fn target(self) -> Target {
|
||||
match self {
|
||||
Self::Node { placement, .. } => Target::Placement(placement),
|
||||
Self::Transition(id) => Target::Edge(id),
|
||||
Self::Row {
|
||||
placement,
|
||||
relation,
|
||||
} => Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsupported mixtures retain the same click/cancel lifecycle as placement groups.
|
||||
struct GroupPress {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
viewport: Viewport,
|
||||
movement: Option<GroupGesture>,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
impl GroupPress {
|
||||
fn move_to(&mut self, pointer: Point) {
|
||||
if self.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite()
|
||||
|| !pointer.y.is_finite()
|
||||
|| !self.viewport.bounds.contains(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
return;
|
||||
}
|
||||
self.crossed |= pointer.distance(self.start) > 3.;
|
||||
if let Some(movement) = &mut self.movement
|
||||
&& !movement.move_to(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn release(mut self, pointer: Point) -> Option<SelectionEvent> {
|
||||
self.move_to(pointer);
|
||||
if self.retired {
|
||||
return None;
|
||||
}
|
||||
if !self.crossed {
|
||||
return Some(SelectionEvent::Collapse(self.snapshot, self.target));
|
||||
}
|
||||
let Some(movement) = self.movement else {
|
||||
return Some(SelectionEvent::Refuse(self.snapshot,
|
||||
"Move requires only placed elements. Remove rows, ports and transitions from the selection.".into()));
|
||||
};
|
||||
match movement.release(pointer)? {
|
||||
Outcome::Click(id) => Some(SelectionEvent::Collapse(
|
||||
self.snapshot,
|
||||
Target::Placement(id),
|
||||
)),
|
||||
Outcome::Move(snapshot, delta) => Some(SelectionEvent::Move(snapshot, delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn cancel_edit(&mut self) {
|
||||
self.press = None;
|
||||
self.group = None;
|
||||
self.preview = None;
|
||||
self.attachment_preview = None;
|
||||
self.bend = None;
|
||||
@@ -485,6 +567,32 @@ pub(crate) fn visible_row_relation(
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate this occurrence, including the relation's current source. A surviving
|
||||
/// relation reassigned to another State must not rebound an old row selection.
|
||||
pub(crate) fn visible_row_target(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &ProjectedView,
|
||||
placement: Id,
|
||||
relation: Id,
|
||||
) -> bool {
|
||||
visible_row_relation(project, diagram, view, relation)
|
||||
&& view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == placement)
|
||||
.is_some_and(|node| {
|
||||
project
|
||||
.relations
|
||||
.get(&relation)
|
||||
.is_some_and(|row| row.source() == node.element)
|
||||
&& state_action_rows(project, node)
|
||||
.into_iter()
|
||||
.chain(internal_rows(project, node))
|
||||
.any(|(row, _)| row.id == relation)
|
||||
})
|
||||
}
|
||||
|
||||
fn state_action_label(project: &Project, relation: &Relation) -> String {
|
||||
let RelationBody::StateAction { target, phase, .. } = relation.body else {
|
||||
return String::new();
|
||||
@@ -663,6 +771,210 @@ pub(crate) fn diagram_usages(
|
||||
}
|
||||
|
||||
impl StateCanvas<'_> {
|
||||
fn current_selection(&self) -> Option<&Selection> {
|
||||
self.selection
|
||||
.filter(|s| s.scope() == selection_context(self.context).scope)
|
||||
}
|
||||
|
||||
fn group_viewport(&self, bounds: Rectangle) -> Viewport {
|
||||
Viewport {
|
||||
zoom: self.scale(),
|
||||
pan: self.pan,
|
||||
bounds: Rectangle::new(
|
||||
Point::new(bounds.x, bounds.y + GUIDANCE_HEIGHT),
|
||||
Self::content(bounds).size(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_current(&self, group: &GroupPress, bounds: Option<Rectangle>) -> bool {
|
||||
!group.retired
|
||||
&& self.editable
|
||||
&& self.tool == Tool::Select
|
||||
&& group.viewport.zoom == self.scale()
|
||||
&& group.viewport.pan == self.pan
|
||||
&& bounds.is_none_or(|b| group.viewport == self.group_viewport(b))
|
||||
&& self
|
||||
.current_selection()
|
||||
.is_some_and(|s| s.matches(&group.snapshot, selection_context(self.context)))
|
||||
}
|
||||
|
||||
fn group_preview_key(
|
||||
&self,
|
||||
state: &State,
|
||||
bounds: Option<Rectangle>,
|
||||
) -> Option<(Snapshot, Position)> {
|
||||
let group = state.group.as_ref()?;
|
||||
if !self.group_current(group, bounds) || !group.crossed {
|
||||
return None;
|
||||
}
|
||||
Some((group.snapshot.clone(), group.movement.as_ref()?.delta()))
|
||||
}
|
||||
|
||||
fn group_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
if let Some(group) = &state.group {
|
||||
let cancelled = matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
|
||||
) | canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if !self.group_current(group, Some(bounds)) || cancelled {
|
||||
state.cancel_edit();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(group) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
group.move_to(*position);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let group = state.group.take().unwrap();
|
||||
return Some(
|
||||
cursor
|
||||
.position()
|
||||
.and_then(|p| group.release(p))
|
||||
.map_or_else(
|
||||
|| Action::request_redraw().and_capture(),
|
||||
|event| Action::publish(Event::Selection(event)).and_capture(),
|
||||
),
|
||||
);
|
||||
}
|
||||
canvas::Event::Keyboard(_)
|
||||
| canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let selection = self.current_selection()?;
|
||||
if !self.editable || self.tool != Tool::Select || state.pan_from.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
use keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape) && state.press.is_none() && state.source.is_none()
|
||||
{
|
||||
return Some(
|
||||
Action::publish(Event::Selection(SelectionEvent::Clear(selection_context(
|
||||
self.context,
|
||||
))))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
let event = if modifiers.is_empty()
|
||||
&& crate::app::multiselect::placements(&snapshot).is_some()
|
||||
{
|
||||
SelectionEvent::Hide(snapshot)
|
||||
} else {
|
||||
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
|
||||
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
|
||||
} else {
|
||||
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
|
||||
}.into())
|
||||
};
|
||||
return Some(Action::publish(Event::Selection(event)).and_capture());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let window = cursor
|
||||
.position()
|
||||
.filter(|p| self.group_viewport(bounds).bounds.contains(*p))?;
|
||||
let local = Point::new(window.x - bounds.x, window.y - bounds.y);
|
||||
let scene = self.scene(state)?;
|
||||
let world = self.world(local);
|
||||
// Route handles belong to the exact edge, including when an endpoint
|
||||
// overlaps a node. They cannot bypass Shift/group admission.
|
||||
let handle = self
|
||||
.endpoint_at(&scene, world)
|
||||
.map(|(id, _)| Target::Edge(id))
|
||||
.or_else(|| {
|
||||
self.bend_at(&scene, world)
|
||||
.map(|_| Target::Edge(self.selected))
|
||||
});
|
||||
let target = handle.or_else(|| self.hit(&scene, world).map(Hit::target))?;
|
||||
if state.modifiers.shift() {
|
||||
state.cancel_edit();
|
||||
return Some(
|
||||
Action::publish(Event::Selection(SelectionEvent::Choose(
|
||||
selection_context(self.context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
let viewport = self.group_viewport(bounds);
|
||||
let movement = if crate::app::multiselect::placements(&snapshot).is_some()
|
||||
&& let Target::Placement(anchor) = target
|
||||
{
|
||||
let original = self
|
||||
.project
|
||||
.diagrams
|
||||
.get(&self.diagram)?
|
||||
.nodes
|
||||
.get(&anchor)?
|
||||
.position;
|
||||
Some(GroupGesture::new_with_policy(
|
||||
snapshot.clone(),
|
||||
anchor,
|
||||
original,
|
||||
window,
|
||||
viewport,
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.cancel_edit();
|
||||
state.group = Some(GroupPress {
|
||||
snapshot,
|
||||
target,
|
||||
start: window,
|
||||
viewport,
|
||||
movement,
|
||||
crossed: false,
|
||||
retired: false,
|
||||
});
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
fn select_event(&self, hit: Hit, semantic: Id) -> Event {
|
||||
if self.current_selection().is_some() {
|
||||
Event::Selection(SelectionEvent::Choose(
|
||||
selection_context(self.context),
|
||||
hit.target(),
|
||||
Choice::Replace,
|
||||
))
|
||||
} else {
|
||||
Event::Select(self.context, semantic)
|
||||
}
|
||||
}
|
||||
|
||||
fn scale(&self) -> f32 {
|
||||
if self.zoom.is_finite() {
|
||||
self.zoom.clamp(0.0001, 3.)
|
||||
@@ -695,6 +1007,7 @@ impl StateCanvas<'_> {
|
||||
return None;
|
||||
}
|
||||
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
|
||||
let group_preview = self.group_preview_key(state, bounds);
|
||||
let preview = self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
@@ -703,6 +1016,7 @@ impl StateCanvas<'_> {
|
||||
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
|
||||
if let Some(cached) = state.scene.borrow().as_ref()
|
||||
&& cached.context == self.context
|
||||
&& cached.group_preview == group_preview
|
||||
&& cached.preview == preview
|
||||
&& cached.attachment_preview == attachment_preview
|
||||
&& cached.bend_preview == bend_preview
|
||||
@@ -710,6 +1024,27 @@ impl StateCanvas<'_> {
|
||||
return Some(cached.scene.clone());
|
||||
}
|
||||
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
|
||||
if let Some((snapshot, delta)) = &group_preview
|
||||
&& let Some(ids) = crate::app::multiselect::placements(snapshot)
|
||||
&& let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
|
||||
self.project,
|
||||
self.diagram,
|
||||
&ids,
|
||||
*delta,
|
||||
)
|
||||
{
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::EditNode { node, .. } => {
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
Command::SetEdgeRoute { route, .. } => {
|
||||
crate::route_edit::with_route(&mut diagram, route)
|
||||
}
|
||||
_ => unreachable!("placement planner returned a non-geometry command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(route) = &bend_preview {
|
||||
crate::route_edit::with_route(&mut diagram, route.clone());
|
||||
}
|
||||
@@ -761,6 +1096,7 @@ impl StateCanvas<'_> {
|
||||
});
|
||||
*state.scene.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
group_preview,
|
||||
preview,
|
||||
attachment_preview,
|
||||
bend_preview,
|
||||
@@ -823,13 +1159,19 @@ impl StateCanvas<'_> {
|
||||
.into_iter()
|
||||
.find(|(_, r)| r.contains(world))
|
||||
{
|
||||
return Some(Hit::StateAction(relation.id));
|
||||
return Some(Hit::Row {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
});
|
||||
}
|
||||
if let Some((relation, _)) = internal_rows(self.project, node)
|
||||
.into_iter()
|
||||
.find(|(_, r)| r.contains(world))
|
||||
{
|
||||
return Some(Hit::Transition(relation.id));
|
||||
return Some(Hit::Row {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
});
|
||||
}
|
||||
let container = self
|
||||
.project
|
||||
@@ -1148,7 +1490,13 @@ impl StateCanvas<'_> {
|
||||
},
|
||||
_ => match world.and_then(|p| self.hit(scene, p)) {
|
||||
Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(),
|
||||
Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(),
|
||||
Some(Hit::Row { relation: id, .. }) => self.project.relations.get(&id).map(|r| {
|
||||
if matches!(r.body, RelationBody::StateAction { .. }) {
|
||||
format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))
|
||||
} else {
|
||||
format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))
|
||||
}
|
||||
}).unwrap_or_default(),
|
||||
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
|
||||
},
|
||||
}
|
||||
@@ -1172,6 +1520,15 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
if let canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
// Passive observation must not capture the event or grant focus.
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
let retired_group = state.group.is_some()
|
||||
&& (state.context != Some(self.context)
|
||||
|| state.tool != Some(self.tool)
|
||||
|| !self.editable);
|
||||
if state.context != Some(self.context) || state.tool != Some(self.tool) {
|
||||
if state.context != Some(self.context) {
|
||||
state.viewport = None;
|
||||
@@ -1226,6 +1583,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.modifiers = keyboard::Modifiers::default();
|
||||
state.reset();
|
||||
return None;
|
||||
}
|
||||
@@ -1239,6 +1597,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
let window = cursor.position();
|
||||
let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
||||
let inside = local.is_some_and(|p| Self::content(bounds).contains(p));
|
||||
if retired_group {
|
||||
// The event which retired a held gesture cannot be reinterpreted as
|
||||
// a fresh keyboard edit against the surviving selection.
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
use keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape) {
|
||||
@@ -1310,13 +1676,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
// Menu commands must target the committed route instead.
|
||||
let scene = self.scene(state)?;
|
||||
let bend = self.bend_at(&scene, world);
|
||||
let (object, placement) = if bend.is_some() {
|
||||
(self.selected, None)
|
||||
let (object, placement, selection_target) = if bend.is_some() {
|
||||
(self.selected, None, Some(Target::Edge(self.selected)))
|
||||
} else {
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { element, placement }) => (element, Some(placement)),
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
|
||||
None => (self.diagram, None),
|
||||
Some(hit @ Hit::Node { element, placement }) => {
|
||||
(element, Some(placement), Some(hit.target()))
|
||||
}
|
||||
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
|
||||
(id, None, Some(hit.target()))
|
||||
}
|
||||
None => (self.diagram, None, None),
|
||||
}
|
||||
};
|
||||
let route_segment = if bend.is_none() {
|
||||
@@ -1329,6 +1699,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
context: self.context,
|
||||
object,
|
||||
placement,
|
||||
selection_target,
|
||||
anchor: window,
|
||||
bend,
|
||||
route_segment,
|
||||
@@ -1336,12 +1707,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if *button != mouse::Button::Left || !self.editable {
|
||||
if *button != mouse::Button::Left || !self.editable || state.pan_from.is_some() {
|
||||
return None;
|
||||
}
|
||||
match self.tool {
|
||||
Tool::Select => {
|
||||
if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) {
|
||||
let singleton = self
|
||||
.current_selection()
|
||||
.is_none_or(|s| s.targets().len() <= 1);
|
||||
if singleton
|
||||
&& let Some((relation, endpoint)) = self.endpoint_at(&scene, world)
|
||||
{
|
||||
state.press = Some(Press::Reconnect {
|
||||
relation,
|
||||
endpoint,
|
||||
@@ -1349,7 +1725,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
});
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(index) = self.bend_at(&scene, world) {
|
||||
if singleton && let Some(index) = self.bend_at(&scene, world) {
|
||||
let route = crate::route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
@@ -1358,7 +1734,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { placement, element }) => {
|
||||
Some(hit @ Hit::Node { placement, element }) => {
|
||||
let original = self
|
||||
.project
|
||||
.diagrams
|
||||
@@ -1371,13 +1747,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
start: window,
|
||||
original,
|
||||
});
|
||||
Some(
|
||||
Action::publish(Event::Select(self.context, element))
|
||||
.and_capture(),
|
||||
)
|
||||
Some(Action::publish(self.select_event(hit, element)).and_capture())
|
||||
}
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
|
||||
if id == self.selected
|
||||
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
|
||||
if singleton
|
||||
&& id == self.selected
|
||||
&& let Some(edge) = scene.routed.edges.get(&id)
|
||||
&& !Rectangle::new(
|
||||
point(edge.label.bounds.position),
|
||||
@@ -1407,7 +1781,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
}
|
||||
Some(Action::publish(Event::Select(self.context, id)).and_capture())
|
||||
Some(Action::publish(self.select_event(hit, id)).and_capture())
|
||||
}
|
||||
None => {
|
||||
state.pan_from = Some((mouse::Button::Left, window));
|
||||
@@ -1643,6 +2017,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
|
||||
let key = PaintKey {
|
||||
context: self.context,
|
||||
group_preview: self.group_preview_key(state, Some(bounds)),
|
||||
selection: self
|
||||
.current_selection()
|
||||
.and_then(|s| s.snapshot(selection_context(self.context))),
|
||||
preview: self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
@@ -1671,23 +2049,40 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
frame.translate(self.offset());
|
||||
frame.scale(self.scale());
|
||||
for node in &scene.view.nodes {
|
||||
let selected =
|
||||
self.current_selection()
|
||||
.map_or(self.selected, |s| match s.primary() {
|
||||
Some(Target::Placement(id)) if *id == node.id => node.element,
|
||||
Some(Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
}) if *placement == node.id => *relation,
|
||||
_ => Id::nil(),
|
||||
});
|
||||
paint_node(
|
||||
frame,
|
||||
self.project,
|
||||
node,
|
||||
self.selected,
|
||||
selected,
|
||||
scene.symbol_labels.get(&node.id),
|
||||
);
|
||||
}
|
||||
// Containers are already painted. Route bodies have shared
|
||||
// clearance from state headers and internal-transition rows.
|
||||
for edge in scene.routed.edges.values() {
|
||||
let active = edge.id == self.selected;
|
||||
let active = self
|
||||
.current_selection()
|
||||
.map_or(edge.id == self.selected, |s| {
|
||||
s.contains(&Target::Edge(edge.id))
|
||||
});
|
||||
let primary = self
|
||||
.current_selection()
|
||||
.map_or(active, |s| s.primary() == Some(&Target::Edge(edge.id)));
|
||||
let color = if active { style::TEAL } else { style::INK };
|
||||
frame.stroke(
|
||||
&polyline(&edge.points),
|
||||
Stroke::default().with_color(color).with_width(if active {
|
||||
2.4
|
||||
if primary { 3.2 } else { 2.0 }
|
||||
} else {
|
||||
1.4
|
||||
}),
|
||||
@@ -1727,7 +2122,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
10.,
|
||||
style::MUTED,
|
||||
);
|
||||
if active {
|
||||
if active
|
||||
&& self
|
||||
.current_selection()
|
||||
.is_none_or(|s| s.targets().len() <= 1)
|
||||
{
|
||||
if !edge.custom_suspended && self.tool == Tool::Select {
|
||||
for pin in &edge.pins {
|
||||
let radius = 4. / self.scale();
|
||||
@@ -1758,6 +2157,42 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(selection) = self.current_selection() {
|
||||
for target in selection.targets() {
|
||||
let rect = match target {
|
||||
Target::Placement(id) => {
|
||||
scene.view.nodes.iter().find(|n| n.id == *id).map(rectangle)
|
||||
}
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => scene
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *placement)
|
||||
.and_then(|node| {
|
||||
state_action_rows(self.project, node)
|
||||
.into_iter()
|
||||
.chain(internal_rows(self.project, node))
|
||||
.find(|(row, _)| row.id == *relation)
|
||||
.map(|(_, rect)| rect)
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(rect) = rect {
|
||||
let primary = selection.primary() == Some(target);
|
||||
let rect =
|
||||
expanded(rect, if primary { 3. } else { 1.5 } / self.scale());
|
||||
frame.stroke(
|
||||
&Path::rectangle(rect.position(), rect.size()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(if primary { 3. } else { 1.5 } / self.scale()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let mut overlay = Frame::new(renderer, bounds.size());
|
||||
@@ -2159,6 +2594,7 @@ mod tests {
|
||||
use crate::document::Document;
|
||||
|
||||
include!("state_bend_tests.rs");
|
||||
include!("state_multiselect_canvas_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
@@ -2312,7 +2748,10 @@ mod tests {
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, row.bounds.center()),
|
||||
Some(Hit::StateAction(action))
|
||||
Some(Hit::Row {
|
||||
placement,
|
||||
relation: action
|
||||
})
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
f.project
|
||||
@@ -2427,6 +2866,7 @@ mod tests {
|
||||
epoch: 3,
|
||||
},
|
||||
selected: f.machine,
|
||||
selection: None,
|
||||
navigation_focus: None,
|
||||
tool,
|
||||
zoom,
|
||||
@@ -3245,7 +3685,10 @@ mod tests {
|
||||
for (relation, row) in rows {
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, row.center()),
|
||||
Some(Hit::StateAction(relation.id))
|
||||
Some(Hit::Row {
|
||||
placement: a.id,
|
||||
relation: relation.id
|
||||
})
|
||||
);
|
||||
let events = click(&canvas, &mut State::default(), row.center());
|
||||
assert!(
|
||||
@@ -3306,7 +3749,10 @@ mod tests {
|
||||
for (relation, rect) in rows {
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, rect.center()),
|
||||
Some(Hit::Transition(relation.id))
|
||||
Some(Hit::Row {
|
||||
placement: a.id,
|
||||
relation: relation.id
|
||||
})
|
||||
);
|
||||
}
|
||||
let initial = scene
|
||||
|
||||
@@ -25,6 +25,7 @@ pub enum Tool {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Selection(crate::app::multiselect::Event),
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
@@ -38,6 +39,7 @@ pub enum Event {
|
||||
context: Context,
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
selection_target: Option<crate::diagram_usage::Target>,
|
||||
anchor: iced::Point,
|
||||
bend: Option<usize>,
|
||||
route_segment: Option<(usize, Position)>,
|
||||
@@ -101,7 +103,8 @@ impl Event {
|
||||
pub fn context(&self) -> Option<Context> {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
Self::Choose(_) | Self::CreateView(_) => None,
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Included in state_canvas::tests. Drive production Program input and projection.
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_bend_uses_five_unit_grid_and_escape_keeps_selection() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for cancel in [false, true] {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
// A fractional pointer target distinguishes route snapping from the
|
||||
// unsnapped State placement policy, at every supported test zoom.
|
||||
let release = screen(&canvas, Point::new(683.75, 362.25));
|
||||
let expected_pin = Position { x: 685., y: 360. };
|
||||
motion(&canvas, &mut state, start, release);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), release).is_none());
|
||||
assert!(matches!(state.press, Some(Press::Bend { relation: id }) if id == relation));
|
||||
assert!(state.group.is_none());
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let mut expected_route = original.clone();
|
||||
expected_route.waypoints[0] = expected_pin;
|
||||
assert_eq!(state.bend_preview.as_ref(), Some(&expected_route));
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![expected_pin]);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
if cancel {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels the bend before it can emit selection Clear: {escape:?}");
|
||||
assert!(state.press.is_none() && state.bend.is_none() && state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
baseline.routed.edges[&relation].points);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).is_none());
|
||||
} else {
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).unwrap();
|
||||
assert!(matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![expected_pin]));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = expected_route;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_endpoint_reconnect_stationary_cancel_and_commit() {
|
||||
for outcome in 0..3 {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let handle = screen(&canvas, point(*baseline.routed.edges[&relation].points.last().unwrap()));
|
||||
let target = screen(&canvas, header(&canvas, f.c));
|
||||
motion(&canvas, &mut state, handle, target);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), target).is_none());
|
||||
assert!(matches!(state.press,
|
||||
Some(Press::Reconnect { relation: id, endpoint: Endpoint::Target, start: Some(at) })
|
||||
if id == relation && at == handle));
|
||||
assert!(state.group.is_none() && state.bend.is_none());
|
||||
if outcome > 0 { motion(&canvas, &mut state, target, target); }
|
||||
if outcome == 1 {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels reconnect without clearing the exact edge: {escape:?}");
|
||||
assert!(state.press.is_none());
|
||||
}
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), target);
|
||||
if outcome < 2 {
|
||||
assert!(event.is_none(), "stationary/cancelled reconnect produced {event:?}");
|
||||
} else {
|
||||
let event = event.unwrap();
|
||||
assert!(matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex }
|
||||
if *context == canvas.context && *id == relation && *vertex == f.c));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
assert_eq!(plan.selected, relation);
|
||||
assert_eq!(actual.relations[&relation].source(), f.a);
|
||||
assert_eq!(actual.relations[&relation].target(), f.c);
|
||||
assert_eq!(actual.elements, f.project.elements);
|
||||
}
|
||||
assert_eq!(f.project.relations[&relation].target(), f.b);
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_shift_handles_toggle_exact_edge_before_route_tools() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let baseline = canvas.scene(&State::default()).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
for world in [edge.pins[0], *edge.points.last().unwrap()] {
|
||||
let mut state = State::default();
|
||||
assert!(canvas::Program::update(&canvas, &mut state,
|
||||
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
|
||||
bounds(), mouse::Cursor::Unavailable).is_none());
|
||||
let at = screen(&canvas, point(world));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), at),
|
||||
Some(Event::Selection(SelectionEvent::Choose(context, Target::Edge(id), Choice::Toggle)))
|
||||
if context == selection_context(canvas.context) && id == relation));
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
|
||||
let away = at + Vector::new(35., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), away).is_none());
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points);
|
||||
}
|
||||
}
|
||||
|
||||
fn group_selection(canvas: &StateCanvas<'_>, targets: impl IntoIterator<Item = Target>) -> Selection {
|
||||
let mut selection = Selection::new(selection_context(canvas.context).scope);
|
||||
for target in targets { selection.add(target, |_| true); }
|
||||
selection
|
||||
}
|
||||
|
||||
fn group_key(canvas: &StateCanvas<'_>, state: &mut State, key: keyboard::key::Named,
|
||||
modifiers: keyboard::Modifiers) -> Option<Event>
|
||||
{
|
||||
canvas::Program::update(canvas, state, &canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key), modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Delete),
|
||||
location: keyboard::Location::Standard, modifiers, text: None, repeat: false,
|
||||
}), bounds(), mouse::Cursor::Unavailable).and_then(|action| action.into_inner().0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_ordered_press_preview_and_fractional_release_preserve_primary() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
let b = placement(&f, f.b);
|
||||
let c = placement(&f, f.c);
|
||||
let selection = group_selection(&canvas, [Target::Placement(b), Target::Placement(c)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = f.c;
|
||||
let before = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.b));
|
||||
let end = start + Vector::new(7.25 * zoom, 10.5 * zoom);
|
||||
motion(&canvas, &mut state, start, end);
|
||||
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), end).is_none());
|
||||
assert!(state.group.is_some() && state.press.is_none());
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(c)));
|
||||
motion(&canvas, &mut state, end, end);
|
||||
let scene = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
for id in [b, c] {
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == id).unwrap();
|
||||
let original = &f.project.diagrams[&f.diagram].nodes[&id];
|
||||
assert_eq!(node.position, Position { x: original.position.x + 7.25, y: original.position.y + 10.5 });
|
||||
}
|
||||
assert_eq!(scene.routed.edges[&relation].pins, vec![Position { x: 657.5, y: 351.25 }]);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start),
|
||||
Some(Event::Selection(SelectionEvent::Move(snapshot, Position { x: 7.25, y: 10.5 }))) if snapshot == before));
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_stationary_collapses_but_out_and_back_keeps_group() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start).is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, start + Vector::new(20., 0.), start);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Move(_, Position { x: 0., y: 0. })))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Placement(id)))) if id == a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_changed_authority_viewport_or_membership_retires_preview_and_release() {
|
||||
let f = fixture();
|
||||
for change in 0..7 {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
let mut replacement = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
replacement.remove(&Target::Placement(a));
|
||||
replacement.add(Target::Placement(a), |_| true);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, start, start);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start);
|
||||
motion(&canvas, &mut state, start + Vector::new(12., 8.), start);
|
||||
assert!(canvas.group_preview_key(&state, Some(bounds())).is_some());
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.context.revision += 1,
|
||||
1 => canvas.context.epoch += 1,
|
||||
2 => canvas.context.document = Id::new_v4(),
|
||||
3 => canvas.zoom = 1.1,
|
||||
4 => canvas.pan.x += 1.,
|
||||
5 => changed_bounds.x += 1.,
|
||||
_ => canvas.selection = Some(&replacement),
|
||||
}
|
||||
assert!(canvas.group_preview_key(&state, Some(changed_bounds)).is_none());
|
||||
if change < 3 {
|
||||
assert!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
}
|
||||
assert!(input_at_bounds(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, changed_bounds).is_none());
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_mixed_route_handle_refuses_drag_and_preserves_stationary_collapse() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b)), Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = relation;
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let at = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some() && state.bend.is_none() && state.press.is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, at + Vector::new(25., 10.), at);
|
||||
motion(&canvas, &mut state, at, at);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Edge(id)))) if id == relation));
|
||||
}
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_shift_row_hit_menu_and_reassignment_keep_exact_identity() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let internal = f.project.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
||||
if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body {
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
let a = placement(&f, f.a);
|
||||
for relation in [action, internal] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let rect = state_action_rows(&f.project, node).into_iter().chain(internal_rows(&f.project, node))
|
||||
.find(|(row, _)| row.id == relation).unwrap().1;
|
||||
assert!(visible_row_target(&f.project, f.diagram, &scene.view, a, relation));
|
||||
let target = Target::StateRow { placement: a, relation };
|
||||
assert_eq!(canvas.hit(&scene, rect.center()).unwrap().target(), target);
|
||||
let mut state = State::default();
|
||||
let modifiers = canvas::Program::update(&canvas, &mut state,
|
||||
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
|
||||
bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(modifiers.is_none());
|
||||
let at = screen(&canvas, rect.center());
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
|
||||
Some(Event::Selection(SelectionEvent::Choose(_, actual, Choice::Toggle))) if actual == target));
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { object, placement: None, selection_target: Some(actual), .. }) if object == relation && actual == target));
|
||||
canvas::Program::update(&canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
if let RelationBody::StateAction { source, .. } = &mut f.project.relations.get_mut(&action).unwrap().body { *source = f.b; }
|
||||
if let RelationBody::StateTransition { source, target, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *source = f.b; *target = f.b; }
|
||||
let view = projection::project_diagram(&f.project, &f.project.diagrams[&f.diagram]);
|
||||
for relation in [action, internal] {
|
||||
assert!(!visible_row_target(&f.project, f.diagram, &view, a, relation));
|
||||
assert!(visible_row_target(&f.project, f.diagram, &view, placement(&f, f.b), relation));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.a)), Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
use keyboard::key::Named;
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_some());
|
||||
assert!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_none());
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Clear(_)))));
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.pan_from.is_some());
|
||||
assert!(matches!(motion(&canvas, &mut state, at + Vector::new(20., 10.), at), Some(Event::Pan(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_entry_anchor_uses_authored_position_with_unsnapped_delta() {
|
||||
let mut f = fixture();
|
||||
let inner = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let entry = f.project.add_element(Kind::EntryPoint, Layer::Sa, "Enter", Some(inner)).unwrap();
|
||||
let original = Position { x: 450.25, y: 410.75 };
|
||||
let entry_node = f.project.place(f.diagram, entry, original);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(entry_node), Target::Placement(placement(&f, f.leaf))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let shown = scene.view.nodes.iter().find(|n| n.id == entry_node).unwrap();
|
||||
assert_ne!(shown.position.x, original.x);
|
||||
let at = screen(&canvas, rectangle(shown).center());
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some());
|
||||
let end = at + Vector::new(7.25, 8.5);
|
||||
motion(&canvas, &mut state, end, end);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Event::Selection(SelectionEvent::Move(_, Position { x: 7.25, y: 8.5 })))));
|
||||
assert_eq!(f.project.diagrams[&f.diagram].nodes[&entry_node].position, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_row_and_port_mixtures_refuse_complete_set_without_owner_promotion() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let a = placement(&f, f.a);
|
||||
for other in [Target::StateRow { placement: a, relation: action }, Target::Port { placement: a, port: work }] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), other]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
assert!(matches!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()),
|
||||
Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.as_ref().is_some_and(|g| g.movement.is_none()));
|
||||
let end = at + Vector::new(12., 8.);
|
||||
motion(&canvas, &mut state, end, end);
|
||||
assert!(canvas.group_preview_key(&state, Some(bounds())).is_none());
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Event::Selection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2));
|
||||
assert!(state.press.is_none() && state.preview.is_none());
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,10 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
match e {
|
||||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||||
other => Message::StateDiagram(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
@@ -143,8 +146,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.collect::<Vec<_>>();
|
||||
// Keep the optional picker in its own stable slot. Inserting it as a
|
||||
// sibling before the canvas would replace the positional Iced focus tree.
|
||||
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
|
||||
let mut missing_controls = Column::new();
|
||||
// Reserve the picker height even when all elements are placed. Group Hide
|
||||
// and Undo must not resize the canvas and silently refit its camera.
|
||||
let mut missing_controls = Column::new().height(34);
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
@@ -169,6 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
navigation_focus: app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||||
.map(|usage| usage.bounds),
|
||||
@@ -183,6 +188,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
selection_controls(app),
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
context,
|
||||
@@ -191,7 +197,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
Message::DiagramTraverse,
|
||||
)
|
||||
]
|
||||
.spacing(missing_spacing)
|
||||
.spacing(8)
|
||||
.height(Fill),
|
||||
);
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
@@ -202,6 +208,47 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::multiselect::{Event as SelectionEvent, placements};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut controls = row![label(format!("{count} selected"))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| s.targets().len() > 1) {
|
||||
if placements(&snapshot).is_some() {
|
||||
controls = controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if app.behavior.diagram.tool == Tool::Select {
|
||||
"Shift+click adds or removes from selection"
|
||||
} else {
|
||||
"Use Select / move to select multiple elements"
|
||||
};
|
||||
column![
|
||||
controls,
|
||||
label(hint).wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
|
||||
@@ -5,8 +5,11 @@ use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
pub(crate) mod group_reorder;
|
||||
mod usages;
|
||||
mod view;
|
||||
#[cfg(test)]
|
||||
pub(crate) use direct::End;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
@@ -909,6 +912,53 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Integer columns shared by rendering and order planning. Edges whose endpoints
|
||||
/// are absent from the displayed order cannot participate in its layout.
|
||||
fn column_ranks(order: &[Id], adjacency: &[(Id, Id)]) -> BTreeMap<Id, usize> {
|
||||
let ids: BTreeSet<_> = order.iter().copied().collect();
|
||||
let adjacency: Vec<_> = adjacency
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(source, target)| ids.contains(source) && ids.contains(target))
|
||||
.collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.get_mut(&target).expect("Displayed endpoint") += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| incoming[id] == 0)
|
||||
.collect();
|
||||
let mut done = BTreeSet::new();
|
||||
let mut offset = 0;
|
||||
while offset < queue.len() {
|
||||
let id = queue[offset];
|
||||
offset += 1;
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Displayed endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve deterministic fallback order for loops and cycle-blocked nodes.
|
||||
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
|
||||
for id in order {
|
||||
if !done.contains(id) {
|
||||
ranks.insert(*id, fallback);
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
ranks
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PreviewLink {
|
||||
relation: Id,
|
||||
@@ -1066,6 +1116,7 @@ struct Graph<'a> {
|
||||
selected: Id,
|
||||
selected_preview: Option<OccurrenceEndpoint>,
|
||||
selected_preview_link: Option<PreviewLink>,
|
||||
selection: Option<crate::diagram_selection::Snapshot>,
|
||||
scale: f32,
|
||||
}
|
||||
impl<'a> Graph<'a> {
|
||||
@@ -1084,50 +1135,19 @@ impl<'a> Graph<'a> {
|
||||
) -> Self {
|
||||
let context_index = oa_core::topology_context::TopologyIndex::new(p);
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let displayed: BTreeSet<_> = choices.iter().map(|choice| choice.id).collect();
|
||||
let links: Vec<_> = sequence_links(p, owner)
|
||||
.into_iter()
|
||||
.filter(|link| displayed.contains(&link.source()) && displayed.contains(&link.target()))
|
||||
.collect();
|
||||
let order = legacy_order(p, owner);
|
||||
let adjacency: Vec<_> = links
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
|
||||
.collect();
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.entry(target).or_default() += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = choices
|
||||
.iter()
|
||||
.filter(|c| incoming[&c.id] == 0)
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let mut done = BTreeSet::new();
|
||||
let mut offset = 0;
|
||||
while offset < queue.len() {
|
||||
let id = queue[offset];
|
||||
offset += 1;
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Graph endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cycles remain explicit edges; deterministic fallback placement must
|
||||
// terminate even while users are building a loop or incomplete graph.
|
||||
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
|
||||
for c in &choices {
|
||||
if !done.contains(&c.id) {
|
||||
ranks.insert(c.id, fallback);
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect();
|
||||
let ranks = column_ranks(&displayed_order, &adjacency);
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
@@ -1238,25 +1258,17 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
let ordered_paths = order
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[0])
|
||||
.expect("ordered source")
|
||||
.rect;
|
||||
let b = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[1])
|
||||
.expect("ordered target")
|
||||
.rect;
|
||||
OrderedPath {
|
||||
.filter_map(|pair| {
|
||||
let a = nodes.iter().find(|n| n.id == pair[0])?.rect;
|
||||
let b = nodes.iter().find(|n| n.id == pair[1])?.rect;
|
||||
Some(OrderedPath {
|
||||
source: pair[0],
|
||||
target: pair[1],
|
||||
points: vec![
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut edges = vec![];
|
||||
@@ -1400,6 +1412,7 @@ impl<'a> Graph<'a> {
|
||||
selected,
|
||||
selected_preview: None,
|
||||
selected_preview_link: None,
|
||||
selection: None,
|
||||
scale: 1.,
|
||||
}
|
||||
}
|
||||
@@ -1518,9 +1531,16 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
EdgeLayer::Contextual
|
||||
}
|
||||
}) {
|
||||
let selected = e.id == self.selected
|
||||
|| e.source == self.selected
|
||||
|| e.target == self.selected;
|
||||
let selected = self.selection.as_ref().map_or(
|
||||
e.id == self.selected
|
||||
|| e.source == self.selected
|
||||
|| e.target == self.selected,
|
||||
|selection| {
|
||||
selection
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ProcessLink(e.id))
|
||||
},
|
||||
);
|
||||
if e.incidence {
|
||||
f.stroke(
|
||||
&Path::new(|path| {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
#[path = "direct_selection.rs"]
|
||||
mod selection;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
@@ -445,27 +447,23 @@ pub(crate) fn reconnect(
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
pub(crate) fn reorder(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
before: Id,
|
||||
) -> Result<super::group_reorder::Plan, String> {
|
||||
super::group_reorder::Prepared::new(
|
||||
p,
|
||||
owner,
|
||||
&[crate::diagram_usage::Target::ProcessOccurrence(
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
},
|
||||
)],
|
||||
)?
|
||||
.plan(super::group_reorder::Destination::Before(before))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -476,6 +474,8 @@ pub(super) struct Input {
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<selection::GroupPress>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
@@ -504,6 +504,7 @@ impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
self.group = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
@@ -592,6 +593,9 @@ impl Graph<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let Some(action) = self.selection_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
@@ -835,6 +839,7 @@ impl Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
self.selection_overlay(state, f, bounds);
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
@@ -935,7 +940,12 @@ impl Graph<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if tool == CanvasTool::Select
|
||||
&& self
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.targets().len() == 1)
|
||||
{
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Exact-target selection and an order gesture over one immutable selection.
|
||||
use super::super::group_reorder::{Destination, Plan, Prepared};
|
||||
use super::*;
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
pub(super) struct GroupPress {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
bounds: Rectangle,
|
||||
pan: iced::Vector,
|
||||
scale: f32,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
prepared: Result<Prepared, String>,
|
||||
destination: Option<Destination>,
|
||||
plan: Option<Result<Plan, String>>,
|
||||
}
|
||||
|
||||
impl Graph<'_> {
|
||||
fn exact_target_at(&self, point: Point) -> Option<Target> {
|
||||
// Visible singleton reconnect rings are painted over the card/edge.
|
||||
// Shift and context selection must resolve that same topmost target.
|
||||
if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select)
|
||||
&& self.selection.as_ref().is_some_and(|snapshot| {
|
||||
snapshot.targets().len() == 1
|
||||
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
|
||||
})
|
||||
&& let Some((link, _)) = self.endpoint_at(point)
|
||||
{
|
||||
return Some(Target::ProcessLink(link));
|
||||
}
|
||||
if let Some(endpoint) = self.qualified_at(point) {
|
||||
return Some(Target::ProcessOccurrence(endpoint));
|
||||
}
|
||||
for node in &self.nodes {
|
||||
for link in &node.preview_links {
|
||||
if node
|
||||
.preview_link_visible_segment(link)
|
||||
.is_some_and(|[a, b]| {
|
||||
crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale
|
||||
})
|
||||
{
|
||||
return Some(Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.contextual_edge_at(point)
|
||||
.map(Target::ProcessLink)
|
||||
.or_else(|| {
|
||||
self.node_at(point).map(|id| {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| self.edge_at(point).map(Target::ProcessLink))
|
||||
}
|
||||
|
||||
fn destination_at(&self, point: Point) -> Option<Destination> {
|
||||
let id = self.node_at(point)?;
|
||||
let node = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(if point.y < node.rect.center_y() {
|
||||
Destination::Before(id)
|
||||
} else {
|
||||
Destination::After(id)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_group(&self, group: &mut GroupPress, pointer: Point) {
|
||||
if group.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) {
|
||||
group.retired = true;
|
||||
group.plan = None;
|
||||
return;
|
||||
}
|
||||
group.crossed |= pointer.distance(group.start) >= 3.;
|
||||
let world = Point::new(
|
||||
(pointer.x - group.bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - group.bounds.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
let destination = self.destination_at(world);
|
||||
// The captured semantic facts are prepared once. Only a changed object
|
||||
// anchor/side needs another order plan; motion within a slot is free.
|
||||
if destination != group.destination {
|
||||
group.destination = destination;
|
||||
group.plan = destination.map(|destination| match &group.prepared {
|
||||
Ok(prepared) => prepared.plan(destination),
|
||||
Err(reason) => Err(reason.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selection_input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
|
||||
match event {
|
||||
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => {
|
||||
state.modifiers = *modifiers
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.modifiers = iced::keyboard::Modifiers::empty()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
state.group = None;
|
||||
return None;
|
||||
};
|
||||
let snapshot = self
|
||||
.selection
|
||||
.as_ref()
|
||||
.filter(|s| s.context() == process_context(context));
|
||||
if let Some(group) = &state.group {
|
||||
let cancelled = !editable
|
||||
|| tool != CanvasTool::Select
|
||||
|| snapshot != Some(&group.snapshot)
|
||||
|| group.bounds != bounds
|
||||
|| group.pan != self.camera_pan
|
||||
|| group.scale != self.scale
|
||||
|| matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(
|
||||
mouse::Button::Right | mouse::Button::Middle
|
||||
)
|
||||
) | canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if cancelled {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(group) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
self.update_group(group, *position);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let mut group = state.group.take().unwrap();
|
||||
// An unavailable cursor must never reuse the last inside point.
|
||||
if let Some(pointer) = cursor.position() {
|
||||
self.update_group(&mut group, pointer);
|
||||
} else {
|
||||
group.retired = true;
|
||||
}
|
||||
let result = if group.retired {
|
||||
None
|
||||
} else if !group.crossed {
|
||||
Some(SelectionEvent::Collapse(group.snapshot, group.target))
|
||||
} else if let Err(reason) = group.prepared {
|
||||
Some(SelectionEvent::Refuse(group.snapshot, reason))
|
||||
} else {
|
||||
match (group.destination, group.plan) {
|
||||
(Some(destination), Some(Ok(Plan::Change(_)))) => {
|
||||
Some(SelectionEvent::ProcessReorder(group.snapshot, destination))
|
||||
}
|
||||
(_, Some(Err(reason))) => {
|
||||
Some(SelectionEvent::Refuse(group.snapshot, reason))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
return Some(
|
||||
result
|
||||
.map_or_else(Action::request_redraw, |event| {
|
||||
Action::publish(Message::DiagramSelection(event))
|
||||
})
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None,
|
||||
canvas::Event::Keyboard(_) => return Some(Action::capture()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let snapshot = snapshot?;
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
repeat: false,
|
||||
..
|
||||
})
|
||||
) && (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
{
|
||||
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
|
||||
}
|
||||
let pointer = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
_ => cursor.position().and(state.pointer.or(cursor.position())),
|
||||
}?;
|
||||
if !bounds.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
let world = Point::new(
|
||||
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
) {
|
||||
state.cancel();
|
||||
let target = self.exact_target_at(world)?;
|
||||
let object = match &target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id,
|
||||
_ => return None,
|
||||
};
|
||||
let mut request = crate::model_menu::Request::object(object, pointer);
|
||||
request.selection_target = Some(Box::new(target));
|
||||
request.process = Some(Box::new(context));
|
||||
return Some(
|
||||
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if tool == CanvasTool::Connect && snapshot.targets().len() > 1 {
|
||||
state.cancel();
|
||||
return publish(SelectionEvent::Refuse(
|
||||
snapshot.clone(),
|
||||
"Connecting a selection as a group is unavailable. Select one contribution first."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if tool != CanvasTool::Select {
|
||||
return None;
|
||||
}
|
||||
let target = self.exact_target_at(world);
|
||||
if state.modifiers.shift() {
|
||||
state.cancel();
|
||||
return target
|
||||
.and_then(|target| {
|
||||
publish(SelectionEvent::Choose(
|
||||
process_context(context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
))
|
||||
})
|
||||
.or_else(|| Some(Action::capture()));
|
||||
}
|
||||
let Some(target) = target else {
|
||||
state.cancel();
|
||||
// Clearing and starting the existing background camera pan can
|
||||
// share this press; only semantic object hits capture it.
|
||||
return Some(Action::publish(Message::DiagramSelection(
|
||||
SelectionEvent::Clear(process_context(context)),
|
||||
)));
|
||||
};
|
||||
// The ordinary endpoint gesture is valid only for an exact singleton
|
||||
// local link. A mixed selection must never reconnect just its primary.
|
||||
if snapshot.targets().len() == 1
|
||||
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
|
||||
&& self.endpoint_at(world).is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !snapshot.targets().contains(&target) {
|
||||
state.cancel();
|
||||
return publish(SelectionEvent::Choose(
|
||||
process_context(context),
|
||||
target,
|
||||
Choice::Replace,
|
||||
));
|
||||
}
|
||||
state.cancel();
|
||||
state.context = Some(context);
|
||||
state.tool = tool;
|
||||
state.bounds = Some(bounds);
|
||||
state.pointer = Some(pointer);
|
||||
state.group = Some(GroupPress {
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: pointer,
|
||||
bounds,
|
||||
pan: self.camera_pan,
|
||||
scale: self.scale,
|
||||
crossed: false,
|
||||
retired: false,
|
||||
prepared: Prepared::new(self.project, context.owner, snapshot.targets()),
|
||||
destination: None,
|
||||
plan: None,
|
||||
});
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) {
|
||||
if let Some(selection) = &self.selection {
|
||||
for target in selection.targets() {
|
||||
let primary = selection.primary() == Some(target);
|
||||
let width = if primary { 3.5 } else { 2. };
|
||||
let color = if primary {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(64, 123, 188)
|
||||
};
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
let rect = if endpoint.references.is_empty() {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == endpoint.occurrence)
|
||||
.map(|n| n.rect)
|
||||
} else {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint))
|
||||
.map(|n| n.endpoint_rect(endpoint))
|
||||
};
|
||||
if let Some(rect) = rect {
|
||||
frame.stroke(
|
||||
&Path::rounded_rectangle(rect.position(), rect.size(), 5.into()),
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
}
|
||||
Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source,
|
||||
target,
|
||||
} => {
|
||||
let link = PreviewLink {
|
||||
relation: *relation,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
};
|
||||
for node in &self.nodes {
|
||||
if node.preview_links.contains(&link)
|
||||
&& let Some([a, b]) = node.preview_link_visible_segment(&link)
|
||||
{
|
||||
frame.stroke(
|
||||
&Path::line(a, b),
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(group) = &state.group else {
|
||||
return;
|
||||
};
|
||||
if group.retired
|
||||
|| !group.crossed
|
||||
|| group.bounds != bounds
|
||||
|| self.selection.as_ref() != Some(&group.snapshot)
|
||||
|| group.pan != self.camera_pan
|
||||
|| group.scale != self.scale
|
||||
{
|
||||
return;
|
||||
}
|
||||
let reason = group
|
||||
.prepared
|
||||
.as_ref()
|
||||
.err()
|
||||
.or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err()));
|
||||
let color = if reason.is_some() {
|
||||
Color::from_rgb8(178, 49, 49)
|
||||
} else {
|
||||
style::TEAL
|
||||
};
|
||||
let mut position = state
|
||||
.pointer
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale + 12.,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale + 18.,
|
||||
)
|
||||
})
|
||||
.unwrap_or(Point::ORIGIN);
|
||||
if let Some(destination) = group.destination {
|
||||
let (id, after) = match destination {
|
||||
Destination::Before(id) => (id, false),
|
||||
Destination::After(id) => (id, true),
|
||||
};
|
||||
if let Some(node) = self.nodes.iter().find(|n| n.id == id) {
|
||||
let y = if after {
|
||||
node.rect.y + node.rect.height + 7.
|
||||
} else {
|
||||
node.rect.y - 7.
|
||||
};
|
||||
frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(node.rect.x, y),
|
||||
Point::new(node.rect.x + node.rect.width, y),
|
||||
),
|
||||
Stroke::default().with_color(color).with_width(3.),
|
||||
);
|
||||
position = Point::new(node.rect.x, y + 3.);
|
||||
}
|
||||
}
|
||||
let caption = reason.cloned().unwrap_or_else(|| match &group.plan {
|
||||
Some(Ok(Plan::Noop)) => "Order unchanged".into(),
|
||||
Some(Ok(Plan::Change(_))) => format!(
|
||||
"Insert {} in source order · paths unchanged",
|
||||
group.snapshot.targets().len()
|
||||
),
|
||||
_ => "Point at a card's upper / lower half to insert before / after".into(),
|
||||
});
|
||||
draw_text(frame, caption, position, 12. / self.scale, color);
|
||||
if reason.is_none() {
|
||||
let mut index = 0;
|
||||
// Graph nodes are in original steps order; these numbers describe
|
||||
// the block the exact same cached plan will commit on release.
|
||||
for node in &self.nodes {
|
||||
if group
|
||||
.snapshot
|
||||
.targets()
|
||||
.contains(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: node.id,
|
||||
}))
|
||||
{
|
||||
index += 1;
|
||||
draw_text(
|
||||
frame,
|
||||
format!("{index}"),
|
||||
Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.),
|
||||
13.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "direct_selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,403 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::direct_tests::{Fixture, context, fixture};
|
||||
use crate::diagram_selection::Selection;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
fn peers(layer: Layer) -> Fixture {
|
||||
let mut fixture = fixture(layer);
|
||||
fixture.p = execute_batch(&fixture.p, &[Command::Delete(fixture.link)]).unwrap();
|
||||
fixture
|
||||
}
|
||||
fn graph<'a>(fixture: &'a Fixture, targets: &[Target]) -> Graph<'a> {
|
||||
let context = context(fixture.owner);
|
||||
let mut selection = Selection::new(process_context(context).scope);
|
||||
for target in targets {
|
||||
selection.add(target.clone(), |_| true);
|
||||
}
|
||||
let mut graph = Graph::new(&fixture.p, fixture.owner, fixture.owner);
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
graph.selection = selection.snapshot(process_context(context));
|
||||
graph
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::ORIGIN, Size::new(1800., 1400.))
|
||||
}
|
||||
fn point(graph: &Graph<'_>, id: Id, after: bool) -> Point {
|
||||
let rect = graph.nodes.iter().find(|node| node.id == id).unwrap().rect;
|
||||
Point::new(
|
||||
rect.x + 70.,
|
||||
rect.y + if after { rect.height - 15. } else { 20. },
|
||||
)
|
||||
}
|
||||
fn send(
|
||||
graph: &Graph<'_>,
|
||||
input: &mut Input,
|
||||
event: canvas::Event,
|
||||
point: Point,
|
||||
) -> Option<Message> {
|
||||
graph
|
||||
.input(input, &event, bounds(), mouse::Cursor::Available(point))
|
||||
.and_then(|action| action.into_inner().0)
|
||||
}
|
||||
fn press(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
point,
|
||||
);
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
point,
|
||||
)
|
||||
}
|
||||
fn release(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
point,
|
||||
);
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
point,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_shift_toggle_and_stationary_group_click_defer_primary() {
|
||||
let f = peers(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[2]), local(f.nodes[0])]);
|
||||
let mut input = Input::default();
|
||||
let at = point(&graph, f.nodes[2], false);
|
||||
assert!(press(&graph, &mut input, at).is_none());
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Collapse(snapshot, target)))
|
||||
if target == local(f.nodes[2]) && snapshot == graph.selection.clone().unwrap())
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == local(f.nodes[2]))
|
||||
);
|
||||
assert!(input.group.is_none());
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
at,
|
||||
);
|
||||
assert!(!input.modifiers.shift());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_uses_midpoint_anchors_and_original_order_and_singleton_after_gap() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = peers(layer);
|
||||
for targets in [
|
||||
vec![local(f.nodes[2]), local(f.nodes[0])],
|
||||
vec![local(f.nodes[0])],
|
||||
] {
|
||||
let graph = graph(&f, &targets);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
let end = point(&graph, f.nodes[1], true);
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
let Some(Message::DiagramSelection(SelectionEvent::ProcessReorder(
|
||||
snapshot,
|
||||
destination,
|
||||
))) = release(&graph, &mut input, end)
|
||||
else {
|
||||
panic!("admitted after slot");
|
||||
};
|
||||
assert_eq!(snapshot, graph.selection.clone().unwrap());
|
||||
assert_eq!(destination, Destination::After(f.nodes[1]));
|
||||
let Plan::Change(command) = Prepared::new(&f.p, f.owner, snapshot.targets())
|
||||
.unwrap()
|
||||
.plan(destination)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("changed steps");
|
||||
};
|
||||
let Command::SetSteps(owner, steps) = *command else {
|
||||
panic!("expected SetSteps");
|
||||
};
|
||||
assert_eq!(owner, f.owner);
|
||||
assert_eq!(steps, vec![f.nodes[1], f.nodes[0], f.nodes[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion() {
|
||||
let f = peers(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
let away = start + iced::Vector::new(10., 0.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: away }),
|
||||
away,
|
||||
);
|
||||
assert!(release(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_none());
|
||||
press(&graph, &mut input, start);
|
||||
let outside = Point::new(-1., 100.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: outside }),
|
||||
outside,
|
||||
);
|
||||
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], true)), Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2)
|
||||
);
|
||||
let key = crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
|
||||
let iced::Event::Keyboard(key) = key else {
|
||||
panic!("keyboard");
|
||||
};
|
||||
assert!(matches!(
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||
assert!(matches!(
|
||||
press(&graph, &mut input, start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_busy_context_bounds_and_escape_retire_captured_gestures() {
|
||||
let f = peers(Layer::Oa);
|
||||
for change in 0..6 {
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
match change {
|
||||
0 => graph.camera_pan.x += 1.,
|
||||
1 => graph.scale = 2.,
|
||||
2 => graph.interaction.as_mut().unwrap().2 = false,
|
||||
3 => graph.interaction.as_mut().unwrap().0.epoch += 1,
|
||||
4 => {
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
) else {
|
||||
panic!("keyboard");
|
||||
};
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start);
|
||||
}
|
||||
_ => {
|
||||
graph.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::CursorMoved { position: start }),
|
||||
Rectangle::new(Point::new(1., 0.), bounds().size()),
|
||||
mouse::Cursor::Available(start),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
|
||||
assert!(input.group.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_occurrences_and_visible_preview_links_keep_exact_invocation_paths() {
|
||||
let mut f = peers(Layer::Oa);
|
||||
let source =
|
||||
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
|
||||
let mut leaves = vec![];
|
||||
for name in ["One", "Two"] {
|
||||
let leaf = occurrence(
|
||||
&f.p,
|
||||
source_id,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
leaves.push(leaf.id);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(leaf)]).unwrap();
|
||||
}
|
||||
let link = link(&f.p, leaves[0], leaves[1], "");
|
||||
f.p = execute_batch(&f.p, &[link]).unwrap();
|
||||
let mut references = vec![];
|
||||
for name in ["First", "Second"] {
|
||||
let reference = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
},
|
||||
);
|
||||
references.push(reference.id);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(reference)]).unwrap();
|
||||
}
|
||||
let graph = graph(&f, &[]);
|
||||
let mut targets = vec![];
|
||||
for reference in references {
|
||||
let node = graph.nodes.iter().find(|n| n.id == reference).unwrap();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaves[0],
|
||||
};
|
||||
let tile = node.endpoint_rect(&endpoint).center();
|
||||
assert_eq!(
|
||||
graph.exact_target_at(tile),
|
||||
Some(Target::ProcessOccurrence(endpoint.clone()))
|
||||
);
|
||||
targets.push(Target::ProcessOccurrence(endpoint));
|
||||
let link = &node.preview_links[0];
|
||||
let [a, b] = node.preview_link_visible_segment(link).unwrap();
|
||||
let at = Point::new((a.x + b.x) / 2., (a.y + b.y) / 2.);
|
||||
let expected = Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
};
|
||||
assert_eq!(graph.exact_target_at(at), Some(expected.clone()));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == expected)
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&graph, &mut input, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)), at), Some(Message::Menu(crate::model_menu::Event::Open(request))) if request.selection_target.as_deref() == Some(&expected) && request.process.as_deref().copied() == graph.interaction.map(|(context,_,_)| context))
|
||||
);
|
||||
}
|
||||
assert_ne!(targets[0], targets[1]);
|
||||
assert!(Prepared::new(&f.p, f.owner, &targets).is_err());
|
||||
}
|
||||
|
||||
// The endpoint ring is drawn last, over the underlying contribution card.
|
||||
// Probe two pixels inside that card: both semantic shapes occupy the point,
|
||||
// so these tests establish the visible handle's selection precedence.
|
||||
fn handle_point(graph: &Graph<'_>, link: Id, end: End) -> Point {
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
match end {
|
||||
End::Source => edge.points[0] + iced::Vector::new(-2., 0.),
|
||||
End::Target => *edge.points.last().unwrap() + iced::Vector::new(2., 0.),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_process_link_snapshot_reconnects_both_ends_but_stationary_release_is_inert() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for end in [End::Source, End::Target] {
|
||||
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let original = graph.selection.clone().unwrap();
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_none());
|
||||
assert!(
|
||||
matches!(input.press, Some(Press::Reconnect { link, end: armed, moved: false, .. }) if link == f.link && armed == end)
|
||||
);
|
||||
assert!(release(&graph, &mut input, start).is_none());
|
||||
assert!(input.press.is_none());
|
||||
assert_eq!(graph.selection.as_ref(), Some(&original));
|
||||
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
let target = point(&graph, f.nodes[2], false);
|
||||
assert!(matches!(release(&graph, &mut input, target),
|
||||
Some(Message::TopologyCanvas(CanvasEvent::Reconnect { context, link, end: actual, target }))
|
||||
if context == graph.interaction.unwrap().0 && link == f.link && actual == end && target == f.nodes[2]));
|
||||
assert!(input.group.is_none());
|
||||
assert_eq!(graph.selection.as_ref(), Some(&original));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_on_visible_singleton_process_link_handle_toggles_that_link_not_underlying_card() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
|
||||
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
assert_eq!(graph.node_at(start), Some(underneath));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
start,
|
||||
);
|
||||
assert!(matches!(press(&graph, &mut input, start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Choose(_, Target::ProcessLink(id), Choice::Toggle)))
|
||||
if id == f.link));
|
||||
assert!(input.group.is_none() && input.press.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_handle() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
|
||||
let mut graph = graph(&f, &[local(underneath), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let snapshot = graph.selection.clone().unwrap();
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
// Multiple selections render no reconnect rings. The visible
|
||||
// selected card owns this point, while the mixed set stays whole.
|
||||
assert_eq!(graph.exact_target_at(start), Some(local(underneath)));
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_some() && input.press.is_none());
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], false)),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(captured, _))) if captured == snapshot)
|
||||
);
|
||||
assert_eq!(graph.selection.as_ref(), Some(&snapshot));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,11 +360,12 @@ fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources(
|
||||
assert_eq!(doc.bundle(), &reconnected);
|
||||
doc.undo();
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
let reordered = execute_batch(
|
||||
&f.p,
|
||||
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let group_reorder::Plan::Change(command) =
|
||||
direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()
|
||||
else {
|
||||
panic!("changed order");
|
||||
};
|
||||
let reordered = execute_batch(&f.p, &[*command]).unwrap();
|
||||
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
|
||||
assert_eq!(reordered.relations, f.p.relations);
|
||||
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Immutable, bounded order proposals for local Process/FunctionalChain cards.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Destination {
|
||||
Before(Id),
|
||||
After(Id),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Plan {
|
||||
Noop,
|
||||
Change(Box<Command>),
|
||||
}
|
||||
|
||||
/// Captured semantic facts only: no project clone, projection or routed scene.
|
||||
/// The adapter must check its document/revision/selection lifetime before commit
|
||||
/// and apply the returned command through the ordinary validated transaction.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prepared {
|
||||
owner: Id,
|
||||
steps: Vec<Id>,
|
||||
adjacency: Vec<(Id, Id)>,
|
||||
legacy: bool,
|
||||
ranks: BTreeMap<Id, usize>,
|
||||
selected: BTreeSet<Id>,
|
||||
column: usize,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub(crate) fn new(project: &Project, owner: Id, targets: &[Target]) -> Result<Self, String> {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.ok_or("Choose a Process or Functional Chain.")?;
|
||||
let steps = element
|
||||
.steps()
|
||||
.ok_or("This owner has no contribution order.")?;
|
||||
let mut ids = BTreeSet::new();
|
||||
for id in steps {
|
||||
if !is_contribution(project, owner, *id) || !ids.insert(*id) {
|
||||
return Err(
|
||||
"Every process step must name a unique local contribution before reordering."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut adjacency: Vec<_> = sequence_links(project, owner)
|
||||
.into_iter()
|
||||
.map(|link| (link.source(), link.target()))
|
||||
.collect();
|
||||
// Unlisted local occurrences are legal incomplete source. Rendering can
|
||||
// omit their links, but order editing must not silently ignore them.
|
||||
if adjacency
|
||||
.iter()
|
||||
.any(|(a, b)| !ids.contains(a) || !ids.contains(b))
|
||||
{
|
||||
return Err("A sequence link refers to an unlisted contribution; repair the process order before reordering.".into());
|
||||
}
|
||||
let legacy = !legacy_order(project, owner).is_empty();
|
||||
if legacy {
|
||||
adjacency.extend(steps.windows(2).map(|pair| (pair[0], pair[1])));
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one local contribution.".into());
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
for target in targets {
|
||||
let Target::ProcessOccurrence(endpoint) = target else {
|
||||
return Err(
|
||||
"Reorder requires local contribution cards only; links cannot move with them."
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
if !endpoint.references.is_empty() {
|
||||
return Err("Open the referenced definition to reorder its contents.".into());
|
||||
}
|
||||
if !ids.contains(&endpoint.occurrence) || !selected.insert(endpoint.occurrence) {
|
||||
return Err(
|
||||
"Every selected contribution must occur exactly once in this process.".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let ranks = column_ranks(steps, &adjacency);
|
||||
let first = *selected.first().expect("Nonempty selection admitted");
|
||||
let column = ranks[&first];
|
||||
if selected.iter().any(|id| ranks[id] != column) {
|
||||
return Err("Reorder within one column; links determine the path columns.".into());
|
||||
}
|
||||
Ok(Self {
|
||||
owner,
|
||||
steps: steps.clone(),
|
||||
adjacency,
|
||||
legacy,
|
||||
ranks,
|
||||
selected,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(&self, destination: Destination) -> Result<Plan, String> {
|
||||
let (anchor, after) = match destination {
|
||||
Destination::Before(id) => (id, false),
|
||||
Destination::After(id) => (id, true),
|
||||
};
|
||||
if self.ranks.get(&anchor) != Some(&self.column) {
|
||||
return Err(
|
||||
"Choose a local contribution in the selected column as the destination.".into(),
|
||||
);
|
||||
}
|
||||
// Complete step, adjacency and typed selection admission already ran.
|
||||
if self.selected.contains(&anchor) {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
let indices: Vec<_> = self
|
||||
.steps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, id)| (self.ranks[id] == self.column).then_some(i))
|
||||
.collect();
|
||||
let block: Vec<_> = indices
|
||||
.iter()
|
||||
.map(|&i| self.steps[i])
|
||||
.filter(|id| self.selected.contains(id))
|
||||
.collect();
|
||||
let mut remaining: Vec<_> = indices
|
||||
.iter()
|
||||
.map(|&i| self.steps[i])
|
||||
.filter(|id| !self.selected.contains(id))
|
||||
.collect();
|
||||
let gap = remaining
|
||||
.iter()
|
||||
.position(|id| *id == anchor)
|
||||
.ok_or("The destination slot no longer exists.")?
|
||||
+ usize::from(after);
|
||||
remaining.splice(gap..gap, block);
|
||||
let mut ordered = self.steps.clone();
|
||||
for (index, id) in indices.into_iter().zip(remaining) {
|
||||
ordered[index] = id;
|
||||
}
|
||||
if ordered == self.steps {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
// Legacy adjacency comes from order itself. In valid legacy layouts each
|
||||
// card has its own column; retain the check instead of assuming that.
|
||||
let ranks = if self.legacy {
|
||||
let adjacency: Vec<_> = ordered.windows(2).map(|pair| (pair[0], pair[1])).collect();
|
||||
column_ranks(&ordered, &adjacency)
|
||||
} else {
|
||||
column_ranks(&ordered, &self.adjacency)
|
||||
};
|
||||
if ranks != self.ranks {
|
||||
return Err(
|
||||
"This reorder would change process columns; the whole move was refused.".into(),
|
||||
);
|
||||
}
|
||||
Ok(Plan::Change(Box::new(Command::SetSteps(
|
||||
self.owner, ordered,
|
||||
))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn plan(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
targets: &[Target],
|
||||
destination: Destination,
|
||||
) -> Result<Plan, String> {
|
||||
Prepared::new(project, owner, targets)?.plan(destination)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,407 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
|
||||
fn columns(project: &Project, owner: Id) -> BTreeMap<Id, usize> {
|
||||
let first = project.elements[&owner].steps().unwrap()[0];
|
||||
Prepared::new(project, owner, &[local(first)])
|
||||
.unwrap()
|
||||
.ranks
|
||||
}
|
||||
|
||||
// Literal order [a,u,b,v,c]; a,b,c are peers. u,v are either downstream
|
||||
// contributions or a separate cycle, so their slots must never change.
|
||||
fn fixture(layer: Layer, cycle: bool) -> (Project, Id, [Id; 5]) {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut ids = vec![];
|
||||
for name in ["a", "u", "b", "v", "c"] {
|
||||
let element = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
let ids: [Id; 5] = ids.try_into().unwrap();
|
||||
let [a, u, b, v, _] = ids;
|
||||
let pairs = if cycle {
|
||||
[(u, v), (v, u)]
|
||||
} else {
|
||||
[(a, u), (b, v)]
|
||||
};
|
||||
// fresh_alias must see the first link before generating the second; two
|
||||
// commands built from the same snapshot both choose "sequence_link".
|
||||
for (source, target) in pairs {
|
||||
let command = link(&project, source, target, "");
|
||||
project = execute_batch(&project, &[command]).unwrap();
|
||||
}
|
||||
assert_eq!(project.elements[&f.owner].steps().unwrap(), &ids);
|
||||
(project, f.owner, ids)
|
||||
}
|
||||
|
||||
fn changed(project: &Project, owner: Id, targets: &[Target], drop: Destination) -> Command {
|
||||
let Plan::Change(command) = plan(project, owner, targets, drop).unwrap() else {
|
||||
panic!("Expected an actual order change");
|
||||
};
|
||||
assert!(matches!(command.as_ref(), Command::SetSteps(id, _) if *id == owner));
|
||||
*command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_reverse_noncontiguous_selection_preserves_project_and_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for cycle in [false, true] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, cycle);
|
||||
assert_eq!(
|
||||
columns(&project, owner),
|
||||
BTreeMap::from([
|
||||
(a, 0),
|
||||
(u, 1),
|
||||
(b, 0),
|
||||
(v, if cycle { 2 } else { 1 }),
|
||||
(c, 0)
|
||||
])
|
||||
);
|
||||
// Reverse click order must not reverse the source-ordered a,c block.
|
||||
let command = changed(
|
||||
&project,
|
||||
owner,
|
||||
&[local(c), local(a)],
|
||||
Destination::After(b),
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![b, u, a, v, c];
|
||||
let result = execute_batch(&project, std::slice::from_ref(&command)).unwrap();
|
||||
assert_eq!(result, expected, "Nothing outside owner steps may change");
|
||||
assert_eq!(columns(&result, owner), columns(&project, owner));
|
||||
let mut doc =
|
||||
Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
doc.execute(command).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert_eq!(reopened.bundle(), &after);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), &project);
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_end_gaps_and_selected_anchor_noops() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
for (members, drop, expected) in [
|
||||
(
|
||||
vec![local(c), local(b)],
|
||||
Destination::Before(a),
|
||||
vec![b, u, c, v, a],
|
||||
),
|
||||
(
|
||||
vec![local(b), local(a)],
|
||||
Destination::After(c),
|
||||
vec![c, u, a, v, b],
|
||||
),
|
||||
] {
|
||||
let command = changed(&project, owner, &members, drop);
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[command]).unwrap().elements[&owner]
|
||||
.steps()
|
||||
.unwrap(),
|
||||
&expected
|
||||
);
|
||||
}
|
||||
let doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
for (members, drop) in [
|
||||
(vec![local(b), local(c)], Destination::After(a)),
|
||||
(vec![local(c), local(a)], Destination::Before(c)),
|
||||
(vec![local(a), local(c)], Destination::After(a)),
|
||||
(vec![local(c), local(b), local(a)], Destination::After(b)),
|
||||
] {
|
||||
assert!(matches!(
|
||||
plan(doc.project(), owner, &members, drop),
|
||||
Ok(Plan::Noop)
|
||||
));
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_refuse_entire_invalid_typed_sets_before_noop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, _, _, c]) = fixture(layer, true);
|
||||
let foreign = super::super::direct_tests::fixture(layer).nodes[0];
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: c,
|
||||
});
|
||||
let relation = *project.relations.keys().next().unwrap();
|
||||
for members in [
|
||||
vec![],
|
||||
vec![local(a), local(a)],
|
||||
vec![local(a), local(u)],
|
||||
vec![local(a), local(foreign)],
|
||||
vec![local(a), qualified],
|
||||
vec![local(a), Target::ProcessLink(relation)],
|
||||
vec![local(a), Target::Placement(c)],
|
||||
] {
|
||||
// A selected anchor must not conceal invalid second members.
|
||||
assert!(plan(&project, owner, &members, Destination::Before(a)).is_err());
|
||||
}
|
||||
assert!(plan(&project, owner, &[local(a)], Destination::After(foreign)).is_err());
|
||||
assert!(plan(&project, a, &[local(a)], Destination::Before(a)).is_err());
|
||||
assert!(
|
||||
plan(
|
||||
&project,
|
||||
owner,
|
||||
&[
|
||||
local(u),
|
||||
local(project.elements[&owner].steps().unwrap()[3])
|
||||
],
|
||||
Destination::Before(u)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_legacy_exchange_order_refuses_cross_rank_group() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut ids = vec![];
|
||||
for (name, exchange) in [("first", f.exchange), ("second", next)] {
|
||||
let element = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
assert_eq!(legacy_order(&project, f.owner), ids);
|
||||
assert!(
|
||||
plan(
|
||||
&project,
|
||||
f.owner,
|
||||
&[local(ids[0]), local(ids[1])],
|
||||
Destination::Before(ids[0])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
plan(
|
||||
&project,
|
||||
f.owner,
|
||||
&[local(ids[1])],
|
||||
Destination::After(ids[1])
|
||||
),
|
||||
Ok(Plan::Noop)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_accept_all_local_card_kinds_and_different_heights() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let source = project
|
||||
.make_element(
|
||||
project.elements[&f.owner].kind(),
|
||||
layer,
|
||||
"Reusable definition",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(source)]).unwrap();
|
||||
let bodies = [
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
},
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
];
|
||||
let mut ids = vec![];
|
||||
for (index, body) in bodies.into_iter().enumerate() {
|
||||
let element = occurrence(&project, f.owner, &format!("Card {index}"), body);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
let graph = Graph::new(&project, f.owner, ids[0]);
|
||||
assert_ne!(graph.nodes[0].rect.height, graph.nodes[3].rect.height);
|
||||
let selected = vec![local(ids[3]), local(ids[2]), local(ids[1]), local(ids[0])];
|
||||
let command = changed(&project, f.owner, &selected, Destination::After(ids[4]));
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![ids[4], ids[0], ids[1], ids[2], ids[3]];
|
||||
assert_eq!(execute_batch(&project, &[command]).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_full_order_admission_precedes_selected_anchor_noop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
let other = project
|
||||
.make_element(project.elements[&owner].kind(), layer, "Other", None)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
||||
let foreign = occurrence(
|
||||
&project,
|
||||
other_owner,
|
||||
"Foreign local occurrence",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let foreign_id = foreign.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(foreign)]).unwrap();
|
||||
assert!(Prepared::new(&project, owner, &[local(a), local(foreign_id)]).is_err());
|
||||
let prepared = Prepared::new(&project, owner, &[local(a)]).unwrap();
|
||||
assert!(prepared.plan(Destination::Before(foreign_id)).is_err());
|
||||
|
||||
// Corrupt/incomplete imported orders must fail even when only the valid
|
||||
// card is selected and the requested destination would be a no-op.
|
||||
for order in [
|
||||
vec![a, u, b, v, c, c],
|
||||
vec![a, u, b, v, Id::new_v4()],
|
||||
vec![a, u, b, v, foreign_id],
|
||||
vec![a, b, v, c], // local a -> u remains, but u is unlisted
|
||||
] {
|
||||
let mut incomplete = project.clone();
|
||||
*incomplete
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = order;
|
||||
let original = incomplete.clone();
|
||||
assert!(plan(&incomplete, owner, &[local(a)], Destination::Before(a)).is_err());
|
||||
assert_eq!(incomplete, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_renderer_omits_links_to_unlisted_nodes_without_panicking() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
for (omitted, expected_ids, expected_ranks) in [
|
||||
(
|
||||
u,
|
||||
vec![a, b, v, c],
|
||||
BTreeMap::from([(a, 0), (b, 0), (v, 1), (c, 0)]),
|
||||
),
|
||||
(
|
||||
a,
|
||||
vec![u, b, v, c],
|
||||
BTreeMap::from([(u, 0), (b, 0), (v, 1), (c, 0)]),
|
||||
),
|
||||
] {
|
||||
// This omission is core-valid incomplete source, not a renderer-only
|
||||
// manufactured dangling element. The semantic occurrence remains.
|
||||
let incomplete =
|
||||
execute_batch(&project, &[Command::SetSteps(owner, expected_ids.clone())]).unwrap();
|
||||
assert!(incomplete.elements.contains_key(&omitted));
|
||||
let graph = Graph::new(&incomplete, owner, b);
|
||||
assert_eq!(
|
||||
graph.nodes.iter().map(|node| node.id).collect::<Vec<_>>(),
|
||||
expected_ids
|
||||
);
|
||||
assert_eq!(graph.edges.len(), 1);
|
||||
assert_eq!((graph.edges[0].source, graph.edges[0].target), (b, v));
|
||||
for node in &graph.nodes {
|
||||
assert_eq!(node.rect.x, 24. + expected_ranks[&node.id] as f32 * 420.);
|
||||
}
|
||||
assert!(Prepared::new(&incomplete, owner, &[local(b)]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_parallel_links_keep_exact_ranks_and_relation_records() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
project = execute_batch(&project, &[link(&project, a, u, "alternative")]).unwrap();
|
||||
assert_eq!(
|
||||
columns(&project, owner),
|
||||
BTreeMap::from([(a, 0), (u, 1), (b, 0), (v, 1), (c, 0)])
|
||||
);
|
||||
let prepared = Prepared::new(&project, owner, &[local(c), local(b)]).unwrap();
|
||||
let Plan::Change(command) = prepared.plan(Destination::Before(a)).unwrap() else {
|
||||
panic!("Expected move");
|
||||
};
|
||||
let result = execute_batch(&project, &[*command]).unwrap();
|
||||
assert_eq!(
|
||||
result.elements[&owner].steps().unwrap(),
|
||||
&vec![b, u, c, v, a]
|
||||
);
|
||||
assert_eq!(result.relations, project.relations);
|
||||
assert_eq!(columns(&result, owner), prepared.ranks);
|
||||
assert_eq!(Graph::new(&result, owner, b).edges.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,320 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() {
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use iced::keyboard::{Event as KeyEvent, Modifiers};
|
||||
let mut f = super::super::direct_tests::fixture(Layer::Oa);
|
||||
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1440., 900.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let at = |app: &App, id| {
|
||||
let graph = Graph::new(app.project(), f.owner, id);
|
||||
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
|
||||
bounds.position()
|
||||
+ app.behavior.topology.pan
|
||||
+ iced::Vector::new(rect.x + 60., rect.y + 20.)
|
||||
* app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.))
|
||||
};
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held {
|
||||
Modifiers::SHIFT
|
||||
} else {
|
||||
Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
let first = at(&app, f.nodes[2]);
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(first))
|
||||
.await;
|
||||
mounted.click(&mut app, first).await;
|
||||
let second = at(&app, f.nodes[0]);
|
||||
mounted.click(&mut app, second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
2,
|
||||
"Shift before canvas focus reaches both clicks"
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(false)], mouse::Cursor::Available(second))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let third = at(&app, f.nodes[1]);
|
||||
mounted.click(&mut app, third).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
1,
|
||||
"Shift released during busy is not resurrected"
|
||||
);
|
||||
app.busy = true;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(second))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted.click(&mut app, second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
2,
|
||||
"Shift held through busy survives child lifetime"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, first).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
1,
|
||||
"real window loss clears held modifiers"
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert!(!app.document.can_undo());
|
||||
let pan = app.behavior.topology.pan;
|
||||
let source = app.document.bundle().clone();
|
||||
let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.);
|
||||
let end = blank + iced::Vector::new(-25., -20.);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(blank),
|
||||
)
|
||||
.await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(app.behavior.topology.pan, pan);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
// Each batch rebuilds the production tree and applies all App messages.
|
||||
// Selection Clear must not retire the camera pan started by its press.
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: end,
|
||||
})],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
app.behavior.topology.pan,
|
||||
pan + iced::Vector::new(-25., -20.)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(
|
||||
app.behavior.topology.pan,
|
||||
pan + iced::Vector::new(-25., -20.)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera()
|
||||
{
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = super::super::direct_tests::fixture(layer);
|
||||
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1440., 900.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let graph = Graph::new(app.project(), f.owner, f.owner);
|
||||
let scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let at = |id, after| {
|
||||
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
|
||||
bounds.position()
|
||||
+ iced::Vector::new(
|
||||
rect.x + 60.,
|
||||
rect.y + if after { rect.height - 15. } else { 20. },
|
||||
) * scale
|
||||
};
|
||||
let points = [
|
||||
at(f.nodes[2], false),
|
||||
at(f.nodes[0], false),
|
||||
at(f.nodes[1], true),
|
||||
];
|
||||
drop(graph);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Keyboard(
|
||||
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT),
|
||||
)],
|
||||
mouse::Cursor::Available(points[0]),
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, points[0]).await;
|
||||
mounted.click(&mut app, points[1]).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Keyboard(
|
||||
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()),
|
||||
)],
|
||||
mouse::Cursor::Available(points[1]),
|
||||
)
|
||||
.await;
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
|
||||
// Reuse the existing opt-in raw WGPU evidence convention. These are
|
||||
// actual production-tree pixels, not a reconstructed preview image.
|
||||
let record_feedback = |stage: &str, mounted: &mut Mounted, app: &App, cursor: Point| {
|
||||
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
|
||||
return;
|
||||
};
|
||||
let path = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let name = format!("process-{layer:?}-{stage}");
|
||||
let pixels = mounted.pixels_at(app, mouse::Cursor::Available(cursor));
|
||||
let width = app.window_size.width as u32;
|
||||
let height = app.window_size.height as u32;
|
||||
assert_eq!(pixels.len(), width as usize * height as usize * 4);
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.rgba")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, &pixels).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"width": width, "height": height, "format": "RGBA8",
|
||||
"stage": stage, "layer": format!("{layer:?}"),
|
||||
"canvas": [bounds.x, bounds.y, bounds.width, bounds.height],
|
||||
"cursor": [cursor.x, cursor.y], "scale": scale,
|
||||
"camera": [app.behavior.topology.pan.x, app.behavior.topology.pan.y],
|
||||
"selectionCount": snapshot.targets().len(),
|
||||
"selection": format!("{snapshot:?}"),
|
||||
"source": app.document.bundle(),
|
||||
});
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.json")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
|
||||
assert!(!app.document.can_undo());
|
||||
};
|
||||
record_feedback("before-press", &mut mounted, &app, points[1]);
|
||||
let pressed = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: points[1],
|
||||
}),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(points[1]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!pressed
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramSelection(_)))
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
let moved = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: points[2],
|
||||
})],
|
||||
mouse::Cursor::Available(points[2]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!moved
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramSelection(_)))
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert_eq!(
|
||||
(app.behavior.topology.pan, app.behavior.topology.zoom),
|
||||
camera
|
||||
);
|
||||
record_feedback("after-motion", &mut mounted, &app, points[2]);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(points[2]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _))
|
||||
)),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.nodes[1], f.nodes[0], f.nodes[2]]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert_eq!(
|
||||
(app.behavior.topology.pan, app.behavior.topology.zoom),
|
||||
camera
|
||||
);
|
||||
let changed = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
|
||||
use crate::behavior::canvas_workspace::{self, tests::Mounted};
|
||||
|
||||
@@ -23,6 +23,38 @@ impl UsageSnapshot {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_address(&self) -> Option<oa_core::topology_context::ContextAddress> {
|
||||
self.selected_context.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn selection_targets(&self, p: &Project, owner: Id) -> Vec<Target> {
|
||||
let graph = self.usage_graph(p, owner);
|
||||
let mut targets = vec![];
|
||||
for node in &graph.nodes {
|
||||
targets.push(Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: node.id,
|
||||
}));
|
||||
targets.extend(
|
||||
node.preview
|
||||
.iter()
|
||||
.map(|(endpoint, _)| Target::ProcessOccurrence(endpoint.clone())),
|
||||
);
|
||||
for link in &node.preview_links {
|
||||
let target = Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
};
|
||||
if graph.usage_bounds(&target).is_some() {
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets.extend(graph.edges.iter().map(|edge| Target::ProcessLink(edge.id)));
|
||||
targets
|
||||
}
|
||||
|
||||
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
identity: self.identity,
|
||||
|
||||
@@ -66,7 +66,8 @@ 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 clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let clean =
|
||||
!app.busy && !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 {
|
||||
@@ -183,6 +184,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.selection = app.multiselect_snapshot();
|
||||
graph.selected_preview = state
|
||||
.qualified_selection(p, owner)
|
||||
.filter(|address| {
|
||||
@@ -195,6 +197,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
== app.selected
|
||||
})
|
||||
.map(|address| address.endpoint.clone());
|
||||
if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) =
|
||||
graph.selection.as_ref().and_then(|s| s.primary())
|
||||
{
|
||||
graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone());
|
||||
}
|
||||
graph.selected_preview_link = app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
|
||||
.and_then(|usage| match &usage.target {
|
||||
@@ -255,7 +262,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
),
|
||||
selection_controls(app)
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
@@ -321,7 +329,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
)
|
||||
}),
|
||||
);
|
||||
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)))
|
||||
let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").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) {
|
||||
@@ -551,6 +559,42 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::multiselect::Event as SelectionEvent;
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let enabled = !app.busy
|
||||
&& !app.behavior.has_pending()
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& app.behavior.topology.tool == CanvasTool::Select;
|
||||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut controls = row![label(format!("{count} selected"))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
|
||||
controls = controls.push(button("Clear selection").on_press_maybe(
|
||||
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
|
||||
));
|
||||
controls = controls.push(
|
||||
label(format!(
|
||||
"Properties: {}",
|
||||
short(app.project().named(app.selected), 24)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
// These two rows exist even with an empty set so selection never moves the
|
||||
// canvas viewport or changes the camera's interpretation of the pointer.
|
||||
column![
|
||||
controls,
|
||||
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
session: Id,
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
|
||||
@@ -39,7 +39,7 @@ mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
mod group_gesture;
|
||||
pub(crate) mod group_gesture;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
|
||||
@@ -12,6 +12,15 @@ pub(crate) struct Viewport {
|
||||
pub bounds: Rectangle,
|
||||
}
|
||||
|
||||
/// Node displacement policy; route-bend snapping remains outside this helper.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DisplacementPolicy {
|
||||
/// Preserve the ordinary saved-view ten-unit anchor grid.
|
||||
Grid10,
|
||||
/// Preserve State views' fractional authored placement displacement.
|
||||
Unsnapped,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Gesture {
|
||||
snapshot: Snapshot,
|
||||
@@ -19,6 +28,7 @@ pub(crate) struct Gesture {
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
crossed_threshold: bool,
|
||||
delta: Position,
|
||||
retired: bool,
|
||||
@@ -37,6 +47,27 @@ impl Gesture {
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
) -> Option<Self> {
|
||||
Self::new_with_policy(
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
}
|
||||
|
||||
/// `original` is the authored placement position, not its projected symbol
|
||||
/// position (State entry/exit symbols can be displayed on another boundary).
|
||||
/// Pointer displacement comes from frozen screen geometry and zoom.
|
||||
pub fn new_with_policy(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
) -> Option<Self> {
|
||||
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|
||||
|| !snapshot.targets().contains(&Target::Placement(anchor))
|
||||
@@ -57,6 +88,7 @@ impl Gesture {
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
displacement,
|
||||
crossed_threshold: false,
|
||||
delta: Position { x: 0., y: 0. },
|
||||
retired: false,
|
||||
@@ -82,7 +114,7 @@ impl Gesture {
|
||||
self.crossed_threshold
|
||||
}
|
||||
|
||||
/// Common snapped world delta for preview and release; zero before a drag.
|
||||
/// Common policy-adjusted world delta for preview and release; zero before a drag.
|
||||
pub fn delta(&self) -> Position {
|
||||
self.delta
|
||||
}
|
||||
@@ -102,9 +134,13 @@ impl Gesture {
|
||||
if !self.crossed_threshold {
|
||||
return true;
|
||||
}
|
||||
let unsnapped = Position {
|
||||
x: screen.x / self.viewport.zoom,
|
||||
y: screen.y / self.viewport.zoom,
|
||||
};
|
||||
let desired = Position {
|
||||
x: self.original.x + screen.x / self.viewport.zoom,
|
||||
y: self.original.y + screen.y / self.viewport.zoom,
|
||||
x: self.original.x + unsnapped.x,
|
||||
y: self.original.y + unsnapped.y,
|
||||
};
|
||||
if !finite_position(desired) {
|
||||
self.retire();
|
||||
@@ -112,13 +148,15 @@ impl Gesture {
|
||||
}
|
||||
// Returning to the original position must not align an off-grid anchor
|
||||
// as an accidental edit. It is still a drag, so selection stays grouped.
|
||||
let delta = if desired == self.original {
|
||||
Position { x: 0., y: 0. }
|
||||
} else {
|
||||
Position {
|
||||
let delta = match self.displacement {
|
||||
// Use the pointer delta directly, avoiding precision loss from
|
||||
// subtracting a large authored original from its desired position.
|
||||
DisplacementPolicy::Unsnapped => unsnapped,
|
||||
DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. },
|
||||
DisplacementPolicy::Grid10 => Position {
|
||||
x: (desired.x / 10.).round() * 10. - self.original.x,
|
||||
y: (desired.y / 10.).round() * 10. - self.original.y,
|
||||
}
|
||||
},
|
||||
};
|
||||
if !finite_position(delta) {
|
||||
self.retire();
|
||||
@@ -307,6 +345,133 @@ mod tests {
|
||||
assert!(gesture.crossed_threshold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_fractional_pointer_delta_preserves_authored_offset() {
|
||||
let authored = Position {
|
||||
x: 1024.125,
|
||||
y: -321.5,
|
||||
};
|
||||
let mut gesture = Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
authored,
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap();
|
||||
let captured = gesture.snapshot().clone();
|
||||
let pointer = Point::new(164.5, 114.5);
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 });
|
||||
let translated_authored = Position {
|
||||
x: authored.x + gesture.delta().x,
|
||||
y: authored.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(
|
||||
translated_authored,
|
||||
Position {
|
||||
x: 1031.375,
|
||||
y: -324.25
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
Position { x: 13.25, y: -7.5 },
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let mut click = make();
|
||||
assert!(click.move_to(Point::new(153., 120.)));
|
||||
assert!(!click.crossed_threshold());
|
||||
assert_eq!(click.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
click.release(Point::new(153., 120.)),
|
||||
Some(Outcome::Click(id(10)))
|
||||
);
|
||||
let mut drag = make();
|
||||
let captured = drag.snapshot().clone();
|
||||
assert!(drag.move_to(Point::new(157.25, 117.25)));
|
||||
assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 });
|
||||
assert!(drag.move_to(press()));
|
||||
assert!(drag.crossed_threshold());
|
||||
assert_eq!(drag.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
drag.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_grid_and_default_constructor_keep_existing_quantization() {
|
||||
let original = Position { x: 13., y: -7. };
|
||||
let mut default = gesture(original);
|
||||
let mut explicit = Gesture::new_with_policy(
|
||||
default.snapshot().clone(),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
.unwrap();
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(default.move_to(pointer));
|
||||
assert!(explicit.move_to(pointer));
|
||||
// Existing ordinary behavior: desired (30,6), grid (30,10).
|
||||
assert_eq!(default.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(explicit.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(default.release(pointer), explicit.release(pointer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_release_only_movement_and_invalid_pointer_retirement() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position {
|
||||
x: 20.125,
|
||||
y: 40.25,
|
||||
},
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let release_only = make();
|
||||
let captured = release_only.snapshot().clone();
|
||||
assert_eq!(
|
||||
release_only.release(Point::new(154.25, 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })),
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = make();
|
||||
assert!(gesture.move_to(Point::new(154.25, 120.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_typed_selection_and_saved_surface_are_required() {
|
||||
for targets in [
|
||||
|
||||
@@ -12,11 +12,13 @@ pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub process: Option<Box<crate::behavior::topology::Context>>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
@@ -25,11 +27,13 @@ impl Request {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
route_segment: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -55,9 +59,14 @@ pub struct Opened {
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| !target_matches(app, &request)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
|| request
|
||||
.process
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.topology_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -80,10 +89,16 @@ impl Opened {
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == current_diagram(app)
|
||||
&& app.project().contains(self.request.object)
|
||||
&& target_matches(app, &self.request)
|
||||
&& self
|
||||
.request
|
||||
.canvas
|
||||
.is_none_or(|c| Some(c) == current_canvas(app))
|
||||
&& self
|
||||
.request
|
||||
.process
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.topology_context())
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
@@ -92,6 +107,46 @@ impl Opened {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if (request.canvas.is_none() && request.process.is_none())
|
||||
|| !app.multiselect_target_visible(target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
request.process.is_some()
|
||||
&& request.canvas.is_none()
|
||||
&& request.placement.is_none()
|
||||
&& endpoint.occurrence == request.object
|
||||
}
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => {
|
||||
request.process.is_some()
|
||||
&& request.canvas.is_none()
|
||||
&& request.placement.is_none()
|
||||
&& *id == request.object
|
||||
}
|
||||
Target::Placement(id) => {
|
||||
request.placement == Some(*id)
|
||||
&& request
|
||||
.canvas
|
||||
.and_then(|context| app.project().diagrams.get(&context.diagram))
|
||||
.and_then(|diagram| diagram.nodes.get(id))
|
||||
.is_some_and(|node| node.element == request.object || node.id == request.object)
|
||||
}
|
||||
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
|
||||
Target::Edge(id) => request.placement.is_none() && *id == request.object,
|
||||
Target::StateRow { relation, .. } => {
|
||||
request.placement.is_none() && *relation == request.object
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context()
|
||||
@@ -102,7 +157,9 @@ fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
|
||||
fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context().map(|context| context.diagram)
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| app.state_context().map(|context| context.diagram))
|
||||
} else {
|
||||
app.active_diagram
|
||||
}
|
||||
@@ -126,7 +183,23 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
if let Some(context) = request.canvas
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
@@ -187,24 +260,21 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if app.page == Page::Diagram
|
||||
&& request.canvas.is_some()
|
||||
if (request.canvas.is_some() || request.process.is_some())
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
||||
use crate::diagram_usage::Target;
|
||||
let view = app
|
||||
.active_diagram
|
||||
let view = request
|
||||
.canvas
|
||||
.map(|context| context.diagram)
|
||||
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
|
||||
let target = request
|
||||
.placement
|
||||
.map(Target::Placement)
|
||||
.selection_target
|
||||
.as_deref()
|
||||
.cloned()
|
||||
.or_else(|| request.placement.map(Target::Placement))
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.ports
|
||||
@@ -269,11 +339,20 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
if request.process.is_some() {
|
||||
entries.push(Entry::title(
|
||||
"Drag contributions within one column to reorder",
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Hide unavailable · cards are model contributions",
|
||||
));
|
||||
} else {
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
@@ -285,8 +364,46 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if let Some(context) = request.process.as_deref().copied() {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(endpoint) = endpoint {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint,
|
||||
),
|
||||
),
|
||||
true,
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
if entries.len() > 1 {
|
||||
entries.extend(menu.entries.into_iter().skip(1));
|
||||
menu.entries = entries;
|
||||
}
|
||||
return Some(menu);
|
||||
}
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
@@ -689,6 +689,34 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn inspector(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
if let Some(target) = app.process_preview_primary() {
|
||||
let endpoint = match target {
|
||||
crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(),
|
||||
crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let context = app.topology_context().unwrap();
|
||||
return container(
|
||||
column![
|
||||
text(p.named(app.selected)).size(17),
|
||||
label("Reused contents · read-only here"),
|
||||
label("Open the referenced definition to edit this contribution or connection."),
|
||||
action(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint
|
||||
))
|
||||
),
|
||||
crate::model_menu::trigger(app),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(14)
|
||||
.width(320)
|
||||
.height(Fill)
|
||||
.style(style::panel)
|
||||
.into();
|
||||
}
|
||||
if app.source_dirty() {
|
||||
let mut notice = column![
|
||||
text("Source drafts pending").size(17),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Process column-slot experiment — exploratory evidence
|
||||
|
||||
Question: does simultaneous column-slot permutation preserve presentation boundaries and stable order without hidden no-op mutations? Separately, can object-anchored Before/After retain selected-target no-op while reaching every useful destination?
|
||||
|
||||
Expected: preserve the entire ID set, exact other-column indices, selected source order, unselected within-column order, a contiguous moved block, equivalent normalized-gap results, click-order independence and frozen ID-to-rank map. Identical column order must return unchanged. Cross-column selections must refuse.
|
||||
|
||||
Run from the repository root with `node harness/experiments/process-column-slots-20260923/experiment.cjs` and `node harness/experiments/process-column-slots-20260923/anchor-policies.cjs`. Scripts overwrite only their own JSON results. Node was the existing bundled runtime. The parent inspected and reran both original scripts before copying them here. No Cargo, production edits or GUI were used by the experiment.
|
||||
|
||||
Results: both scripts exited 0. The first exhausted every steps permutation, set partition, nonempty selected subset and column gap through five IDs: 214,990 admitted property cases, 140,024 cross-column refusals and 127,950 unchanged cases. It separately exhausted all 4,096 directed simple graphs on four IDs and their 24 step permutations: 98,304 graph orders, 861,096 admitted plans, zero changed-rank counterexamples. Its longest-path/fallback rank model follows the documented algorithm but does not execute Rust. The no-source-change assertion uses a sentinel JSON envelope and suppresses serialization for unchanged results; it does not prove the application source writer or history implementation.
|
||||
|
||||
Concrete counterexample to global splicing: `[a,u,b,v,c]`, column `[a,b,c]`, selection `{b,c}`, after `a` is unchanged. Column-slot permutation preserves the exact original array; global splicing would write `[a,b,c,u,v]`. Moving that block before `a` produces `[b,u,c,v,a]`: `u` and `v` retain exact indices. Consequently, preserving all globally unselected relative order is intentionally not a requirement (`a` moves relative to `u` and `v`).
|
||||
|
||||
Anchor-policy follow-up: all nonempty subsets for column lengths 1..10 (2,036 cases, 18,324 unselected Before/After anchors) reached every remainder-list gap using an unselected neighbor. Raw normalization differs from selected-anchor no-op in 17,004 selected-anchor cases. Example: `[a,b,c,d]`, selection `{b,d}`, Before `d` normalizes to `[a,c,b,d]`; the object-anchor policy keeps `[a,b,c,d]`. The changed result is still reachable through After `c`. The APIs are distinct: normalization does not automatically supersede selected-target no-op. Prefer retaining the latter for object-anchored UI if that is the chosen interaction; every changed result remains reachable. When all are selected, the sole destination is inherently unchanged.
|
||||
|
||||
The selected-anchor no-op guard belongs before invoking a block planner: noncontiguous selected items can legitimately remain noncontiguous after that no-op. The block-contiguity postcondition applies to planned moves, not every selected-target gesture.
|
||||
|
||||
Legacy exchange-only ordering assigns successive ranks, and cycle-blocked fallback assigns unique ranks. Multi-member sets spanning these ranks refuse. Four-node enumeration cannot exercise a changed multiple-member group beside a cycle: two cycle nodes leave only two peers, whose entire block has nowhere to move. Independent review identified this limit; an added five-node case checks `[a,u,b,v,c]`, cycle `u→v→u`, selection `{b,c}` before `a`, exact result `[b,u,c,v,a]`, and unchanged full ranks. Production should still validate the complete recomputed rank map. The experiment freezes graph edges; it does not rederive legacy OrderedPath adjacency. Valid legacy columns contain one node, so admitted moves are no-op and multiple-member groups refuse under this candidate.
|
||||
|
||||
The same independent reviewer confirmed the rank model agrees with current Rust, found no planner counterexample under its assumptions, and noted that anchor reachability does not execute a selected-target guard or test actual pointer hit regions. Existing singleton reorder globally splices steps, so routing it through this planner would intentionally change invisible cross-column source ordering; the candidate must make that migration explicit.
|
||||
|
||||
Limitations and next Rust checks: these scripts assume valid unique step IDs, valid complete rank maps, visible local occurrence selection, and valid destination bounds. Rust must reject duplicate/missing/wrong-owner IDs, qualified/link mixtures, stale snapshots and dirty drafts before side effects. Explicitly choose raw-gap versus object-anchor input; keep before-first/after-last reachable. Verify production layout with different card heights and expansion, preview/commit plan identity, OA/SA source roundtrip, unchanged semantics outside steps, one Undo/Redo, no history for no-op, source limits/refusal, and native slot feedback. Deletion mode switching and aggregate removal were not investigated by these movement scripts.
|
||||
|
||||
Artifacts are exploratory evidence. They do not establish State acceptance or Process implementation acceptance.
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs=require('fs'), assert=require('assert/strict');
|
||||
let cases=0, anchors=0, divergent=0;
|
||||
for(let n=1;n<=10;n++) {
|
||||
const column=Array.from({length:n},(_,i)=>i);
|
||||
for(let mask=1;mask<(1<<n);mask++) {
|
||||
const selected=new Set(column.filter(id=>mask&(1<<id)));
|
||||
const remainder=column.filter(id=>!selected.has(id));
|
||||
const reached=new Set();
|
||||
for(const anchor of remainder) for(const after of [false,true]) {
|
||||
const rawGap=column.indexOf(anchor)+(after?1:0);
|
||||
const gap=column.slice(0,rawGap).filter(id=>!selected.has(id)).length;
|
||||
reached.add(gap); anchors++;
|
||||
assert.equal(gap,remainder.indexOf(anchor)+(after?1:0));
|
||||
}
|
||||
if(remainder.length) assert.deepEqual([...reached].sort((a,b)=>a-b),Array.from({length:remainder.length+1},(_,i)=>i));
|
||||
else assert.equal(selected.size,n); // Only one mathematical gap, all-selected no-op.
|
||||
for(const anchor of selected) for(const after of [false,true]) {
|
||||
const rawGap=column.indexOf(anchor)+(after?1:0);
|
||||
const gap=column.slice(0,rawGap).filter(id=>!selected.has(id)).length;
|
||||
const block=column.filter(id=>selected.has(id));
|
||||
const normalized=[...remainder.slice(0,gap),...block,...remainder.slice(gap)];
|
||||
if(normalized.some((id,i)=>id!==column[i]))divergent++;
|
||||
// Object policy intentionally preserves original order for selected anchors.
|
||||
// It need not form a contiguous block when this operation is a no-op.
|
||||
}
|
||||
cases++;
|
||||
}
|
||||
}
|
||||
const result={passed:true,method:'All nonempty subsets for column lengths 1..10, all unselected and selected Before/After anchors. Order-label equivariance makes separate ID permutations redundant for this reachability property.',cases,unselectedAnchors:anchors,selectedAnchorRawNormalizationDivergences:divergent,conclusion:'Every remainder gap is reachable via an unselected Before/After anchor. Selected-anchor no-op does not remove any possible changed result. Raw-gap and object-anchor policies remain distinct APIs; neither silently supersedes the other.',counterexample:{column:['a','b','c','d'],selected:['b','d'],drop:'Before d',rawGapResult:['a','c','b','d'],objectAnchorResult:['a','b','c','d'],equivalentUnselectedDrop:'After c'}};
|
||||
fs.writeFileSync(__dirname+'/anchor-results.json',JSON.stringify(result,null,2)+'\n');
|
||||
console.log(JSON.stringify(result,null,2));
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"passed": true,
|
||||
"method": "All nonempty subsets for column lengths 1..10, all unselected and selected Before/After anchors. Order-label equivariance makes separate ID permutations redundant for this reachability property.",
|
||||
"cases": 2036,
|
||||
"unselectedAnchors": 18324,
|
||||
"selectedAnchorRawNormalizationDivergences": 17004,
|
||||
"conclusion": "Every remainder gap is reachable via an unselected Before/After anchor. Selected-anchor no-op does not remove any possible changed result. Raw-gap and object-anchor policies remain distinct APIs; neither silently supersedes the other.",
|
||||
"counterexample": {
|
||||
"column": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
],
|
||||
"selected": [
|
||||
"b",
|
||||
"d"
|
||||
],
|
||||
"drop": "Before d",
|
||||
"rawGapResult": [
|
||||
"a",
|
||||
"c",
|
||||
"b",
|
||||
"d"
|
||||
],
|
||||
"objectAnchorResult": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
],
|
||||
"equivalentUnselectedDrop": "After c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Exploratory model, not production code or a test of the Rust implementation.
|
||||
const fs = require('fs');
|
||||
const assert = require('assert/strict');
|
||||
let count = 0, graphCount = 0, admitted = 0, refused = 0, noops = 0;
|
||||
function* permutations(a) {
|
||||
if (!a.length) { yield []; return; }
|
||||
for (let i=0;i<a.length;i++) for (const tail of permutations(a.filter((_,j)=>i!==j))) yield [a[i],...tail];
|
||||
}
|
||||
function* partitions(n, prefix=[]) {
|
||||
if(prefix.length===n) { yield prefix; return; }
|
||||
const max=prefix.length?Math.max(...prefix)+1:0;
|
||||
for(let x=0;x<=max;x++) yield* partitions(n,[...prefix,x]);
|
||||
}
|
||||
function plan(steps, ranks, selected, rawGap) {
|
||||
const rank=ranks[selected[0]];
|
||||
if(!selected.length || selected.some(id=>ranks[id]!==rank)) return null;
|
||||
const indices=steps.flatMap((id,i)=>ranks[id]===rank?[i]:[]);
|
||||
const column=indices.map(i=>steps[i]), set=new Set(selected);
|
||||
assert(rawGap>=0 && rawGap<=column.length);
|
||||
const block=column.filter(id=>set.has(id));
|
||||
const remainder=column.filter(id=>!set.has(id));
|
||||
const gap=column.slice(0,rawGap).filter(id=>!set.has(id)).length;
|
||||
const order=[...remainder.slice(0,gap),...block,...remainder.slice(gap)];
|
||||
const result=steps.slice();
|
||||
indices.forEach((index,i)=>result[index]=order[i]);
|
||||
return {result, order, gap, changed:result.some((id,i)=>id!==steps[i])};
|
||||
}
|
||||
function verify(steps,ranks,selected,rawGap,p) {
|
||||
const set=new Set(selected), rank=ranks[selected[0]];
|
||||
const old=steps.filter(id=>ranks[id]===rank), got=p.result.filter(id=>ranks[id]===rank);
|
||||
assert.deepEqual([...p.result].sort(),[...steps].sort());
|
||||
steps.forEach((id,i)=>{if(ranks[id]!==rank) assert.equal(p.result[i],id);});
|
||||
assert.deepEqual(got.filter(id=>set.has(id)),old.filter(id=>set.has(id)));
|
||||
assert.deepEqual(got.filter(id=>!set.has(id)),old.filter(id=>!set.has(id)));
|
||||
const first=got.findIndex(id=>set.has(id));
|
||||
assert.equal(got.slice(first,first+set.size).every(id=>set.has(id)),true);
|
||||
// Independent gap oracle: every original unselected item before boundary
|
||||
// precedes every selected item; all remaining unselected items follow them.
|
||||
old.forEach((id,i)=>{if(!set.has(id)) selected.forEach(s=>assert.equal(got.indexOf(id)<got.indexOf(s),i<rawGap));});
|
||||
const stableAlready=old.every((id,i)=>id===got[i]);
|
||||
assert.equal(p.changed,!stableAlready);
|
||||
const source=JSON.stringify({steps,untouched:'sentinel'});
|
||||
const after=p.changed?JSON.stringify({steps:p.result,untouched:'sentinel'}):source;
|
||||
if(stableAlready) {assert.equal(after,source); noops++;}
|
||||
count++;
|
||||
}
|
||||
for(let n=1;n<=5;n++) {
|
||||
const ids=Array.from({length:n},(_,i)=>i);
|
||||
for(const ranks of partitions(n)) for(const steps of permutations(ids)) {
|
||||
for(let mask=1;mask<(1<<n);mask++) {
|
||||
const selected=ids.filter(id=>mask&(1<<id));
|
||||
if(selected.some(id=>ranks[id]!==ranks[selected[0]])) {
|
||||
assert.equal(plan(steps,ranks,selected,0),null); refused++; continue;
|
||||
}
|
||||
const column=steps.filter(id=>ranks[id]===ranks[selected[0]]);
|
||||
const equivalents=new Map();
|
||||
for(let gap=0;gap<=column.length;gap++) {
|
||||
const p=plan(steps,ranks,selected,gap);
|
||||
verify(steps,ranks,selected,gap,p);
|
||||
assert.deepEqual(p,plan(steps,ranks,[...selected].reverse(),gap));
|
||||
if(equivalents.has(p.gap)) assert.deepEqual(p.result,equivalents.get(p.gap));
|
||||
equivalents.set(p.gap,p.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Semantic column model: longest-path Kahn ranks and deterministic residual
|
||||
// fallback, matching the documented algorithm, independently of planner.
|
||||
function ranksOf(steps,edges) {
|
||||
const indegree=Object.fromEntries(steps.map(id=>[id,0]));
|
||||
const ranks=Object.fromEntries(steps.map(id=>[id,0]));
|
||||
edges.forEach(([,b])=>indegree[b]++);
|
||||
const queue=steps.filter(id=>indegree[id]===0), done=new Set();
|
||||
for(let i=0;i<queue.length;i++) {
|
||||
const id=queue[i]; if(done.has(id))continue; done.add(id);
|
||||
for(const [a,b] of edges) if(a===id) {
|
||||
ranks[b]=Math.max(ranks[b],ranks[a]+1);
|
||||
if(--indegree[b]===0)queue.push(b);
|
||||
}
|
||||
}
|
||||
let fallback=Math.max(...Object.values(ranks))+1;
|
||||
steps.forEach(id=>{if(!done.has(id))ranks[id]=fallback++;});
|
||||
return ranks;
|
||||
}
|
||||
const ids=[0,1,2,3], possible=ids.flatMap(a=>ids.filter(b=>a!==b).map(b=>[a,b]));
|
||||
for(let mask=0;mask<(1<<possible.length);mask++) {
|
||||
const edges=possible.filter((_,i)=>mask&(1<<i));
|
||||
for(const steps of permutations(ids)) {
|
||||
const ranks=ranksOf(steps,edges); graphCount++;
|
||||
for(let subset=1;subset<16;subset++) {
|
||||
const selected=ids.filter(id=>subset&(1<<id));
|
||||
if(selected.some(id=>ranks[id]!==ranks[selected[0]]))continue;
|
||||
const column=steps.filter(id=>ranks[id]===ranks[selected[0]]);
|
||||
for(let gap=0;gap<=column.length;gap++) {
|
||||
const p=plan(steps,ranks,selected,gap);
|
||||
assert.deepEqual(ranksOf(p.result,edges),ranks); admitted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const concrete=[];
|
||||
function example(name,steps,ranks,selected,gap,expected) {
|
||||
const p=plan(steps,ranks,selected,gap); assert.deepEqual(p?.result??null,expected);
|
||||
concrete.push({name,steps,ranks,selected,rawGap:gap,result:p?.result??null,changed:p?.changed??false});
|
||||
}
|
||||
example('interleaved visual no-op',['a','u','b','v','c'],{a:0,b:0,c:0,u:1,v:1},['c','b'],1,['a','u','b','v','c']);
|
||||
example('column slot move preserves other indices',['a','u','b','v','c'],{a:0,b:0,c:0,u:1,v:1},['c','b'],0,['b','u','c','v','a']);
|
||||
example('selected-anchor normalization is not always no-op',['a','b','c','d'],{a:0,b:0,c:0,d:0},['b','d'],3,['a','c','b','d']);
|
||||
example('legacy successive-rank refusal',['a','b','c'],{a:0,b:1,c:2},['a','c'],0,null);
|
||||
const cyclicRanks=ranksOf([0,1,2,3],[[2,3],[3,2]]);
|
||||
example('cycle fallback group refusal',[0,1,2,3],cyclicRanks,[2,3],0,null);
|
||||
example('acyclic peers beside cycle',[0,1,2,3],cyclicRanks,[1],0,[1,0,2,3]);
|
||||
const besideCycleSteps=['a','u','b','v','c'], besideCycleEdges=[['u','v'],['v','u']];
|
||||
const besideCycleRanks=ranksOf(besideCycleSteps,besideCycleEdges);
|
||||
example('changed multiple-member group beside cycle',besideCycleSteps,besideCycleRanks,['c','b'],0,['b','u','c','v','a']);
|
||||
assert.deepEqual(ranksOf(['b','u','c','v','a'],besideCycleEdges),besideCycleRanks);
|
||||
const result={expectation:'Column-slot permutation satisfies stable-block/gap/index invariants; recomputed ranks remain exact.',method:'All permutations and set partitions through n=5; every nonempty subset and raw column gap. Every directed simple graph on 4 IDs, all 24 step permutations, all admitted subsets/gaps. No Rust, source serializer, or GUI exercised.',passed:true,counts:{propertyCases:count,crossColumnRefusals:refused,noops,graphOrders:graphCount,rankCheckedPlans:admitted},concrete};
|
||||
fs.writeFileSync(__dirname+'/results.json',JSON.stringify(result,null,2)+'\n');
|
||||
console.log(JSON.stringify(result,null,2));
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"expectation": "Column-slot permutation satisfies stable-block/gap/index invariants; recomputed ranks remain exact.",
|
||||
"method": "All permutations and set partitions through n=5; every nonempty subset and raw column gap. Every directed simple graph on 4 IDs, all 24 step permutations, all admitted subsets/gaps. No Rust, source serializer, or GUI exercised.",
|
||||
"passed": true,
|
||||
"counts": {
|
||||
"propertyCases": 214990,
|
||||
"crossColumnRefusals": 140024,
|
||||
"noops": 127950,
|
||||
"graphOrders": 98304,
|
||||
"rankCheckedPlans": 861096
|
||||
},
|
||||
"concrete": [
|
||||
{
|
||||
"name": "interleaved visual no-op",
|
||||
"steps": [
|
||||
"a",
|
||||
"u",
|
||||
"b",
|
||||
"v",
|
||||
"c"
|
||||
],
|
||||
"ranks": {
|
||||
"a": 0,
|
||||
"b": 0,
|
||||
"c": 0,
|
||||
"u": 1,
|
||||
"v": 1
|
||||
},
|
||||
"selected": [
|
||||
"c",
|
||||
"b"
|
||||
],
|
||||
"rawGap": 1,
|
||||
"result": [
|
||||
"a",
|
||||
"u",
|
||||
"b",
|
||||
"v",
|
||||
"c"
|
||||
],
|
||||
"changed": false
|
||||
},
|
||||
{
|
||||
"name": "column slot move preserves other indices",
|
||||
"steps": [
|
||||
"a",
|
||||
"u",
|
||||
"b",
|
||||
"v",
|
||||
"c"
|
||||
],
|
||||
"ranks": {
|
||||
"a": 0,
|
||||
"b": 0,
|
||||
"c": 0,
|
||||
"u": 1,
|
||||
"v": 1
|
||||
},
|
||||
"selected": [
|
||||
"c",
|
||||
"b"
|
||||
],
|
||||
"rawGap": 0,
|
||||
"result": [
|
||||
"b",
|
||||
"u",
|
||||
"c",
|
||||
"v",
|
||||
"a"
|
||||
],
|
||||
"changed": true
|
||||
},
|
||||
{
|
||||
"name": "selected-anchor normalization is not always no-op",
|
||||
"steps": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
],
|
||||
"ranks": {
|
||||
"a": 0,
|
||||
"b": 0,
|
||||
"c": 0,
|
||||
"d": 0
|
||||
},
|
||||
"selected": [
|
||||
"b",
|
||||
"d"
|
||||
],
|
||||
"rawGap": 3,
|
||||
"result": [
|
||||
"a",
|
||||
"c",
|
||||
"b",
|
||||
"d"
|
||||
],
|
||||
"changed": true
|
||||
},
|
||||
{
|
||||
"name": "legacy successive-rank refusal",
|
||||
"steps": [
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
],
|
||||
"ranks": {
|
||||
"a": 0,
|
||||
"b": 1,
|
||||
"c": 2
|
||||
},
|
||||
"selected": [
|
||||
"a",
|
||||
"c"
|
||||
],
|
||||
"rawGap": 0,
|
||||
"result": null,
|
||||
"changed": false
|
||||
},
|
||||
{
|
||||
"name": "cycle fallback group refusal",
|
||||
"steps": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"ranks": {
|
||||
"0": 0,
|
||||
"1": 0,
|
||||
"2": 1,
|
||||
"3": 2
|
||||
},
|
||||
"selected": [
|
||||
2,
|
||||
3
|
||||
],
|
||||
"rawGap": 0,
|
||||
"result": null,
|
||||
"changed": false
|
||||
},
|
||||
{
|
||||
"name": "acyclic peers beside cycle",
|
||||
"steps": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"ranks": {
|
||||
"0": 0,
|
||||
"1": 0,
|
||||
"2": 1,
|
||||
"3": 2
|
||||
},
|
||||
"selected": [
|
||||
1
|
||||
],
|
||||
"rawGap": 0,
|
||||
"result": [
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"changed": true
|
||||
},
|
||||
{
|
||||
"name": "changed multiple-member group beside cycle",
|
||||
"steps": [
|
||||
"a",
|
||||
"u",
|
||||
"b",
|
||||
"v",
|
||||
"c"
|
||||
],
|
||||
"ranks": {
|
||||
"a": 0,
|
||||
"u": 1,
|
||||
"b": 0,
|
||||
"v": 2,
|
||||
"c": 0
|
||||
},
|
||||
"selected": [
|
||||
"c",
|
||||
"b"
|
||||
],
|
||||
"rawGap": 0,
|
||||
"result": [
|
||||
"b",
|
||||
"u",
|
||||
"c",
|
||||
"v",
|
||||
"a"
|
||||
],
|
||||
"changed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Preserve exact pinned captures and verification tools through Git round trips.
|
||||
* -text whitespace=cr-at-eol
|
||||
incidents/core-1/core-1/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
@@ -0,0 +1,35 @@
|
||||
# State multiselect closed gate evidence tools
|
||||
|
||||
Preparation requires an explicit closed run list from `.local/state-group-gates`.
|
||||
Preserve failed `desktop-1` alongside accepted unchanged runs. Exclude `core-1`
|
||||
from this package: its false unchanged-input flag and one map/archive mismatch
|
||||
belong in the separate pinned incident. Neither failures nor incidents are passes.
|
||||
|
||||
Example check-only invocation (extend with every subsequent closed run):
|
||||
|
||||
```
|
||||
python -B prepare.py --root CHECKOUT --runs desktop-1 core-2 desktop-2 --check-only
|
||||
```
|
||||
|
||||
Only after all selected captures close, omit `--check-only` and provide
|
||||
`--output CHECKOUT/harness/experiments/state-multiselect-20260923/checks`.
|
||||
Historical helper copies may be passed via repeatable `--helper PATH`.
|
||||
No existing output is overwritten. Run `python -B verify.py PACKAGE` for readback.
|
||||
|
||||
The strict verifier is unchanged from the ordinary multiselect package: exact
|
||||
seven raw files, helper bytes, safe archive inventories, all input-map/archive
|
||||
bytes equal, receipt relationships and inputsUnchanged exactly true are required.
|
||||
Failures retain their actual exits and streams. Source capsules preserve local
|
||||
historical bytes; recorded HEAD alone does not identify those bytes.
|
||||
|
||||
The earlier 733-input helper omitted the core compile-time integration fixture
|
||||
`harness/experiments/edge-attachments-20260922/native-fixture.arcadia` and the CLI
|
||||
fixture `harness/experiments/textual-authoring-20260911/alarm-response.arcadia`.
|
||||
Lint-1 failed, and its all-target capture additionally has a source-capsule
|
||||
scope limitation for the core fixture. Later 735-input captures add both explicit
|
||||
inputs. Desktop/core
|
||||
example captures retain their actual tested scope; do not infer that one map
|
||||
establishes a later map or complete all-target source identity.
|
||||
|
||||
Offline pin/receipt consistency is not test execution, authentication, native
|
||||
acceptance, or proof of usability. Executables and toolchains are not distributed.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 4802,
|
||||
"sha256": "24f59b1e61a1e9c93d4aa9a47a998cbf9c2058e966cc5a7beceba73e2138943c"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 8873,
|
||||
"sha256": "c03513407073bef092d57b968f982b8ffc0c58e095f834fe7464c6a0e8454a9a"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 1984,
|
||||
"sha256": "157584ed470fb8320d5a03b79388c6e1b7818b25fee1dee913083a41ff847f60"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 17487,
|
||||
"sha256": "d5d71a7352fe8ab8cd63d3ce907c875d98ab048080c28868571819e9afa61e4f"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 8859169,
|
||||
"sha256": "48a47389ffdba1180b1340c3471f3d8615790e709fe563c39941d95f3f242f77"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kind": "multiselect-cargo-gates",
|
||||
"runs": {
|
||||
"desktop-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"state_multiselect",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 101,
|
||||
"inputCount": 733,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"core-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"state_multiselect_native",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 0,
|
||||
"inputCount": 733,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 0,
|
||||
"inputCount": 733,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"lint-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 101,
|
||||
"inputCount": 733,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"lint-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 0,
|
||||
"inputCount": 735,
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
},
|
||||
"format-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--",
|
||||
"--check"
|
||||
],
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"exitCode": 0,
|
||||
"inputCount": 735,
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
},
|
||||
"desktop-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"state_",
|
||||
"model_menu",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "3480b54201a5f361cca0bcd0d45dd6546a7a7bc9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 735,
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
},
|
||||
"native-build-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"state_multiselect_native",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "3480b54201a5f361cca0bcd0d45dd6546a7a7bc9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 735,
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
},
|
||||
"windows-build-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "3480b54201a5f361cca0bcd0d45dd6546a7a7bc9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 735,
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
}
|
||||
},
|
||||
"logicalFiles": {
|
||||
"cargo/core-2/inputs.json": "capsules/9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a.json",
|
||||
"cargo/core-2/inputs.zip": "capsules/3419218f9917de0ac689a32dd622ebe55487fa5a37ffaba4b8fa662bc15a5cb9.zip",
|
||||
"cargo/core-2/process.json": "cargo/core-2/process.json",
|
||||
"cargo/core-2/result.json": "cargo/core-2/result.json",
|
||||
"cargo/core-2/started.json": "cargo/core-2/started.json",
|
||||
"cargo/core-2/stderr.log": "cargo/core-2/stderr.log",
|
||||
"cargo/core-2/stdout.log": "cargo/core-2/stdout.log",
|
||||
"cargo/desktop-1/inputs.json": "capsules/d8a64b62b68ac1c37bc3fe9309abc4dc314e343ce546bba4b9445f68dddeda5d.json",
|
||||
"cargo/desktop-1/inputs.zip": "capsules/cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e.zip",
|
||||
"cargo/desktop-1/process.json": "cargo/desktop-1/process.json",
|
||||
"cargo/desktop-1/result.json": "cargo/desktop-1/result.json",
|
||||
"cargo/desktop-1/started.json": "cargo/desktop-1/started.json",
|
||||
"cargo/desktop-1/stderr.log": "cargo/desktop-1/stderr.log",
|
||||
"cargo/desktop-1/stdout.log": "cargo/desktop-1/stdout.log",
|
||||
"cargo/desktop-2/inputs.json": "capsules/9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a.json",
|
||||
"cargo/desktop-2/inputs.zip": "capsules/3419218f9917de0ac689a32dd622ebe55487fa5a37ffaba4b8fa662bc15a5cb9.zip",
|
||||
"cargo/desktop-2/process.json": "cargo/desktop-2/process.json",
|
||||
"cargo/desktop-2/result.json": "cargo/desktop-2/result.json",
|
||||
"cargo/desktop-2/started.json": "cargo/desktop-2/started.json",
|
||||
"cargo/desktop-2/stderr.log": "cargo/desktop-2/stderr.log",
|
||||
"cargo/desktop-2/stdout.log": "cargo/desktop-2/stdout.log",
|
||||
"cargo/desktop-3/inputs.json": "capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json",
|
||||
"cargo/desktop-3/inputs.zip": "capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip",
|
||||
"cargo/desktop-3/process.json": "cargo/desktop-3/process.json",
|
||||
"cargo/desktop-3/result.json": "cargo/desktop-3/result.json",
|
||||
"cargo/desktop-3/started.json": "cargo/desktop-3/started.json",
|
||||
"cargo/desktop-3/stderr.log": "cargo/desktop-3/stderr.log",
|
||||
"cargo/desktop-3/stdout.log": "cargo/desktop-3/stdout.log",
|
||||
"cargo/format-1/inputs.json": "capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json",
|
||||
"cargo/format-1/inputs.zip": "capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip",
|
||||
"cargo/format-1/process.json": "cargo/format-1/process.json",
|
||||
"cargo/format-1/result.json": "cargo/format-1/result.json",
|
||||
"cargo/format-1/started.json": "cargo/format-1/started.json",
|
||||
"cargo/format-1/stderr.log": "cargo/format-1/stderr.log",
|
||||
"cargo/format-1/stdout.log": "cargo/format-1/stdout.log",
|
||||
"cargo/lint-1/inputs.json": "capsules/9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a.json",
|
||||
"cargo/lint-1/inputs.zip": "capsules/3419218f9917de0ac689a32dd622ebe55487fa5a37ffaba4b8fa662bc15a5cb9.zip",
|
||||
"cargo/lint-1/process.json": "cargo/lint-1/process.json",
|
||||
"cargo/lint-1/result.json": "cargo/lint-1/result.json",
|
||||
"cargo/lint-1/started.json": "cargo/lint-1/started.json",
|
||||
"cargo/lint-1/stderr.log": "cargo/lint-1/stderr.log",
|
||||
"cargo/lint-1/stdout.log": "cargo/lint-1/stdout.log",
|
||||
"cargo/lint-2/inputs.json": "capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json",
|
||||
"cargo/lint-2/inputs.zip": "capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip",
|
||||
"cargo/lint-2/process.json": "cargo/lint-2/process.json",
|
||||
"cargo/lint-2/result.json": "cargo/lint-2/result.json",
|
||||
"cargo/lint-2/started.json": "cargo/lint-2/started.json",
|
||||
"cargo/lint-2/stderr.log": "cargo/lint-2/stderr.log",
|
||||
"cargo/lint-2/stdout.log": "cargo/lint-2/stdout.log",
|
||||
"cargo/native-build-1/inputs.json": "capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json",
|
||||
"cargo/native-build-1/inputs.zip": "capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip",
|
||||
"cargo/native-build-1/process.json": "cargo/native-build-1/process.json",
|
||||
"cargo/native-build-1/result.json": "cargo/native-build-1/result.json",
|
||||
"cargo/native-build-1/started.json": "cargo/native-build-1/started.json",
|
||||
"cargo/native-build-1/stderr.log": "cargo/native-build-1/stderr.log",
|
||||
"cargo/native-build-1/stdout.log": "cargo/native-build-1/stdout.log",
|
||||
"cargo/windows-build-1/inputs.json": "capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json",
|
||||
"cargo/windows-build-1/inputs.zip": "capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip",
|
||||
"cargo/windows-build-1/process.json": "cargo/windows-build-1/process.json",
|
||||
"cargo/windows-build-1/result.json": "cargo/windows-build-1/result.json",
|
||||
"cargo/windows-build-1/started.json": "cargo/windows-build-1/started.json",
|
||||
"cargo/windows-build-1/stderr.log": "cargo/windows-build-1/stderr.log",
|
||||
"cargo/windows-build-1/stdout.log": "cargo/windows-build-1/stdout.log",
|
||||
"helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py",
|
||||
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"members": {
|
||||
"capsules/1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c.zip": {
|
||||
"bytes": 2952517,
|
||||
"sha256": "1e7f68e8d6156ec608b58ff70f7ddcaf690d38da1d5066092c218bce190d3a4c"
|
||||
},
|
||||
"capsules/3419218f9917de0ac689a32dd622ebe55487fa5a37ffaba4b8fa662bc15a5cb9.zip": {
|
||||
"bytes": 2948937,
|
||||
"sha256": "3419218f9917de0ac689a32dd622ebe55487fa5a37ffaba4b8fa662bc15a5cb9"
|
||||
},
|
||||
"capsules/4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf.json": {
|
||||
"bytes": 123053,
|
||||
"sha256": "4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf"
|
||||
},
|
||||
"capsules/9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a.json": {
|
||||
"bytes": 122682,
|
||||
"sha256": "9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a"
|
||||
},
|
||||
"capsules/cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e.zip": {
|
||||
"bytes": 2947000,
|
||||
"sha256": "cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e"
|
||||
},
|
||||
"capsules/d8a64b62b68ac1c37bc3fe9309abc4dc314e343ce546bba4b9445f68dddeda5d.json": {
|
||||
"bytes": 122682,
|
||||
"sha256": "d8a64b62b68ac1c37bc3fe9309abc4dc314e343ce546bba4b9445f68dddeda5d"
|
||||
},
|
||||
"cargo/core-2/process.json": {
|
||||
"bytes": 200,
|
||||
"sha256": "744543a3f69d993977f0d15bce6e1ab3f64329eda4bb2d80375a8c8794ee83c0"
|
||||
},
|
||||
"cargo/core-2/result.json": {
|
||||
"bytes": 3196,
|
||||
"sha256": "4625b4558263524424fe6465aea20d1e9831f4c1cb6298124fa3683957f5880e"
|
||||
},
|
||||
"cargo/core-2/started.json": {
|
||||
"bytes": 1403,
|
||||
"sha256": "965d45f368d29ecbea6a7aa9b8881fe18a652928bad8f3e7665443acee52e19b"
|
||||
},
|
||||
"cargo/core-2/stderr.log": {
|
||||
"bytes": 320,
|
||||
"sha256": "42a28434514f50c42782a469c68e6aad8644592b9a09c4b50cc43d987bd2fd5d"
|
||||
},
|
||||
"cargo/core-2/stdout.log": {
|
||||
"bytes": 42721,
|
||||
"sha256": "862bd3e57a3454d29cb6d9ad7673e5bc4b25ab2a288e15d1a7d8dfb912a1457f"
|
||||
},
|
||||
"cargo/desktop-1/process.json": {
|
||||
"bytes": 183,
|
||||
"sha256": "bc57e00984823c7f417c8455f0db3da10f8e453c7db7a808f115b818525227ac"
|
||||
},
|
||||
"cargo/desktop-1/result.json": {
|
||||
"bytes": 1778,
|
||||
"sha256": "25f62fde1985dafcf3467895c6a2f389c886e92ceaa9d63ff513f3d4365ffe79"
|
||||
},
|
||||
"cargo/desktop-1/started.json": {
|
||||
"bytes": 1381,
|
||||
"sha256": "fd570a96b6dc5fc9e750634ef23093a7205cd953be5a7782485e8dbe509098a6"
|
||||
},
|
||||
"cargo/desktop-1/stderr.log": {
|
||||
"bytes": 166,
|
||||
"sha256": "ad39fc56f685f7f3c6a9b4a342abff1f19f9daad28c394673be0e3a2a1cf2523"
|
||||
},
|
||||
"cargo/desktop-1/stdout.log": {
|
||||
"bytes": 297269,
|
||||
"sha256": "d1cdbd43e8fe41e4af6fe2d99d665700a78c1d38185d3a373a33895f296e7b93"
|
||||
},
|
||||
"cargo/desktop-2/process.json": {
|
||||
"bytes": 162,
|
||||
"sha256": "86efe98400b268723e0c3b38b4bcd270f888215060548181b6f4695774a9b640"
|
||||
},
|
||||
"cargo/desktop-2/result.json": {
|
||||
"bytes": 3090,
|
||||
"sha256": "1ae8d0229e429001c2e6214e94e9c4156eb5db75ad1907f421ec87bdeac22f2e"
|
||||
},
|
||||
"cargo/desktop-2/started.json": {
|
||||
"bytes": 1355,
|
||||
"sha256": "71356cc6cdcd01d3620a4bd73c8eee2ccbcb870466b7b0419be8e76cd95e4c02"
|
||||
},
|
||||
"cargo/desktop-2/stderr.log": {
|
||||
"bytes": 288,
|
||||
"sha256": "5e3681e5c50d0d6b25d9594f87a7767456cefefb35ed0575b5d035202d00070f"
|
||||
},
|
||||
"cargo/desktop-2/stdout.log": {
|
||||
"bytes": 441409,
|
||||
"sha256": "f301281be9caa78720540f89548fc1bb80f9b28cda18e4f6425178d4995ed1ec"
|
||||
},
|
||||
"cargo/desktop-3/process.json": {
|
||||
"bytes": 186,
|
||||
"sha256": "963dea7f8d8663873ca087b025e53b6555af28a20cce4d5cbdc740c00c4c874d"
|
||||
},
|
||||
"cargo/desktop-3/result.json": {
|
||||
"bytes": 3307,
|
||||
"sha256": "ecdb5366bb2ca064924e8e967491a7b3db4adffec5cc8cb3370b7afaf739546b"
|
||||
},
|
||||
"cargo/desktop-3/started.json": {
|
||||
"bytes": 1573,
|
||||
"sha256": "5ddbc4ca4954ca732248437cf774cb7803ddec06bef42c11c63b27295be744b2"
|
||||
},
|
||||
"cargo/desktop-3/stderr.log": {
|
||||
"bytes": 287,
|
||||
"sha256": "e0d9090ebcc83418a064f8876ec587e4e741a65e380490999c169fa69628fe3b"
|
||||
},
|
||||
"cargo/desktop-3/stdout.log": {
|
||||
"bytes": 307286,
|
||||
"sha256": "c91fae7883b55901a2c3ab249650c2bb12a991e744a09c18159ffb3bf0079650"
|
||||
},
|
||||
"cargo/format-1/process.json": {
|
||||
"bytes": 116,
|
||||
"sha256": "d316037b78abff45a450b1c116d0c214d1d3831b6c375538779959e1dec652fb"
|
||||
},
|
||||
"cargo/format-1/result.json": {
|
||||
"bytes": 1871,
|
||||
"sha256": "2216bb25ce81504c61353456823ec38d77703dd6747f80f2d53b4267799ce21c"
|
||||
},
|
||||
"cargo/format-1/started.json": {
|
||||
"bytes": 1483,
|
||||
"sha256": "415e0776ec74ee486f2895e3581a8f78308dda9142d4e542442db9979408788b"
|
||||
},
|
||||
"cargo/format-1/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/format-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/lint-1/process.json": {
|
||||
"bytes": 195,
|
||||
"sha256": "286ba22c1994dfd70834adf811c103057724942f85fa5fbfa22e75e563ac376d"
|
||||
},
|
||||
"cargo/lint-1/result.json": {
|
||||
"bytes": 1806,
|
||||
"sha256": "7be8d8e2add4ceb69d5814326e4db91c33e804608e0998323dd72d388cf88def"
|
||||
},
|
||||
"cargo/lint-1/started.json": {
|
||||
"bytes": 1409,
|
||||
"sha256": "2fba3385528cf09df0382cbf4ec3867cdd109daad6de3fbfbc3663dbde7a1610"
|
||||
},
|
||||
"cargo/lint-1/stderr.log": {
|
||||
"bytes": 166,
|
||||
"sha256": "649dcf5fdf4a79dc2f3b315ef9e6735af44b50c40526c8593d2ce54a834a809d"
|
||||
},
|
||||
"cargo/lint-1/stdout.log": {
|
||||
"bytes": 271410,
|
||||
"sha256": "c7085d514e4dbdfc7c3b633911793071e13c27775edfe7ca8cc8f02b70219804"
|
||||
},
|
||||
"cargo/lint-2/process.json": {
|
||||
"bytes": 196,
|
||||
"sha256": "d6370e0e1aede61d5cae46028a6a7bab00e25429d1539e0989ef369d318d06c7"
|
||||
},
|
||||
"cargo/lint-2/result.json": {
|
||||
"bytes": 1988,
|
||||
"sha256": "0b5458c8567fa43a40c2fcf810999c0bc565d56635ff6fdb9484d79d88c69108"
|
||||
},
|
||||
"cargo/lint-2/started.json": {
|
||||
"bytes": 1593,
|
||||
"sha256": "4923e64c03112589eea0c15e3d67f66a94e4fdc1012356f24d67aad94142f386"
|
||||
},
|
||||
"cargo/lint-2/stderr.log": {
|
||||
"bytes": 207,
|
||||
"sha256": "d48449d9fa8226c728f9c979d930e91ebd55b84266edd3f7fb308b88a6451009"
|
||||
},
|
||||
"cargo/lint-2/stdout.log": {
|
||||
"bytes": 309558,
|
||||
"sha256": "de51e727c694ce4f35e4e732ab820488da053470538f20bdbffd55970eb2caad"
|
||||
},
|
||||
"cargo/native-build-1/process.json": {
|
||||
"bytes": 175,
|
||||
"sha256": "30f54028726b906c20cc9b7b0c7c965e3f1e54e6d4d54d99088da6b1c65c2f5e"
|
||||
},
|
||||
"cargo/native-build-1/result.json": {
|
||||
"bytes": 3311,
|
||||
"sha256": "03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955"
|
||||
},
|
||||
"cargo/native-build-1/started.json": {
|
||||
"bytes": 1552,
|
||||
"sha256": "340989981b2db65bb194883c1a608f60cacf9a31d36a930dc39da28f5224cb88"
|
||||
},
|
||||
"cargo/native-build-1/stderr.log": {
|
||||
"bytes": 131,
|
||||
"sha256": "781b5778b0e781b52c25f5342c9c25d3fc45fe7c0da5bf5c94c86ba80ba7e2d2"
|
||||
},
|
||||
"cargo/native-build-1/stdout.log": {
|
||||
"bytes": 41724,
|
||||
"sha256": "5da6c22e3eef843f8be4539a7692e212c0c74461aaf4bf72ce2dd651abe165ac"
|
||||
},
|
||||
"cargo/windows-build-1/process.json": {
|
||||
"bytes": 137,
|
||||
"sha256": "2414e8af081cb683edbee3d6b89e5a323b08cce449058d453bffbc9736c1ea12"
|
||||
},
|
||||
"cargo/windows-build-1/result.json": {
|
||||
"bytes": 3193,
|
||||
"sha256": "172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb"
|
||||
},
|
||||
"cargo/windows-build-1/started.json": {
|
||||
"bytes": 1504,
|
||||
"sha256": "fefb683ef0e99046f5a18cbad56b8bb8bddc45b8abd322cb2c1d83e2acb26565"
|
||||
},
|
||||
"cargo/windows-build-1/stderr.log": {
|
||||
"bytes": 137,
|
||||
"sha256": "f75aa57fe559820a680c771701ea5737995528f29d7dae20e2ae265c07c0b8bb"
|
||||
},
|
||||
"cargo/windows-build-1/stdout.log": {
|
||||
"bytes": 284179,
|
||||
"sha256": "04422dbc4d720707d951d0654da39921c0a526b0eb0c33088a3a7e0d85686c31"
|
||||
},
|
||||
"helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": {
|
||||
"bytes": 3827,
|
||||
"sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20"
|
||||
},
|
||||
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Package explicitly selected closed gate captures without running Cargo."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
from verify import RAW_FILES, pin, parsed, need, run_name, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs, helpers):
|
||||
gates = root / ".local/state-group-gates"
|
||||
candidates = sorted(gates.glob("*.py")) + helpers
|
||||
helper_bytes = [path.read_bytes() for path in candidates]
|
||||
payload, logical, specs = {}, {}, {}
|
||||
def add(name, data):
|
||||
stored = ("capsules/" + pin(data)["sha256"] + Path(name).suffix
|
||||
if name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"} else name)
|
||||
need(stored not in payload or payload[stored] == data, "deduplication collision")
|
||||
payload[stored] = data
|
||||
logical[name] = stored
|
||||
for run in runs:
|
||||
folder = gates / run
|
||||
need(folder.is_dir(), "run directory missing: " + run)
|
||||
paths = list(folder.iterdir())
|
||||
need(all(path.is_file() and not path.is_symlink() for path in paths)
|
||||
and {path.name for path in paths} == RAW_FILES, "exact closed raw files required: " + run)
|
||||
raw = {path.name: path.read_bytes() for path in paths}
|
||||
result = parsed(raw["result.json"])
|
||||
need("finish" in result and result["inputsUnchanged"] is True, "unfinished/changed run refused: " + run)
|
||||
matches = [data for data in helper_bytes if pin(data) == result["helper"]]
|
||||
need(bool(matches), "exact historical helper unavailable for " + run + "; supply --helper PATH")
|
||||
helper = "helpers/" + result["helper"]["sha256"] + ".py"
|
||||
add(helper, matches[0])
|
||||
for name, data in sorted(raw.items()):
|
||||
add("cargo/" + run + "/" + name, data)
|
||||
specs[run] = {key: result[key] for key in ("command", "head", "exitCode", "inputCount")}
|
||||
specs[run]["helper"] = helper
|
||||
manifest = {"schema": 1, "kind": "multiselect-cargo-gates", "runs": specs,
|
||||
"logicalFiles": dict(sorted(logical.items())),
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as output:
|
||||
output.write((json.dumps(value, indent=2) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True, help="checkout holding .local/state-group-gates")
|
||||
parser.add_argument("--runs", nargs="+", required=True, help="explicit run names, including failures to retain")
|
||||
parser.add_argument("--helper", type=Path, action="append", default=[], help="additional exact historical helper (repeatable)")
|
||||
parser.add_argument("--output", type=Path, default=HERE, help="package directory; existing evidence is never overwritten")
|
||||
parser.add_argument("--check-only", action="store_true", help="validate raw captures in memory without creating an archive")
|
||||
args = parser.parse_args()
|
||||
need(all(run_name(run) for run in args.runs) and len(args.runs) == len(set(args.runs)), "unique safe run names required")
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no package written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing package output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
destination = out / name
|
||||
if destination.exists():
|
||||
need(destination.read_bytes() == data, "different existing package tool: " + name)
|
||||
else:
|
||||
with destination.open("xb") as output:
|
||||
output.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated captures")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Offline consistency checks only; never execute or extract archived inputs."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
RAW_FILES = {"started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"}
|
||||
OUTER_FILES = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def valid_pin(value):
|
||||
return (isinstance(value, dict) and set(value) == {"bytes", "sha256"}
|
||||
and type(value["bytes"]) is int and value["bytes"] >= 0
|
||||
and isinstance(value["sha256"], str)
|
||||
and re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is not None)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and all(ord(char) >= 32 for char in name)
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def run_name(name):
|
||||
return isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) is not None
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(value):
|
||||
raise ValueError("nonfinite JSON constant: " + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
|
||||
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP membership")
|
||||
for entry in archive.infolist():
|
||||
need(safe(entry.filename) and not entry.is_dir(), "unsafe ZIP member: " + entry.filename)
|
||||
need(not stat.S_ISLNK(entry.external_attr >> 16), "ZIP symlink: " + entry.filename)
|
||||
need(valid_pin(expected[entry.filename]), "invalid member pin: " + entry.filename)
|
||||
need(entry.file_size == expected[entry.filename]["bytes"], "ZIP recorded size: " + entry.filename)
|
||||
|
||||
|
||||
def ordered_times(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(value.utcoffset() is not None for value in times) and times == sorted(times)
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
"""Shared read-only verifier for packaged bytes or prepare's in-memory payload."""
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "manifest schema")
|
||||
runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"]
|
||||
need(isinstance(runs, dict) and bool(runs) and all(run_name(run) for run in runs), "explicit run inventory")
|
||||
need(all(safe(name) for name in logical) and all(safe(name) for name in stored), "unsafe manifest path")
|
||||
need(set(logical.values()) == set(stored), "unreferenced or absent stored members")
|
||||
for name, expected in stored.items():
|
||||
need(valid_pin(expected) and pin(read(name)) == expected, "stored member hash: " + name)
|
||||
expected_logical = {"cargo/" + run + "/" + name for run in runs for name in RAW_FILES}
|
||||
expected_logical.update(spec["helper"] for spec in runs.values())
|
||||
need(set(logical) == expected_logical, "exact logical raw/helper inventory")
|
||||
for name, destination in logical.items():
|
||||
if name.startswith("cargo/") and name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"}:
|
||||
expected = "capsules/" + stored[destination]["sha256"] + PurePosixPath(name).suffix
|
||||
need(destination == expected, "input capsule mapping: " + name)
|
||||
else:
|
||||
need(destination == name, "only input maps/archives may be deduplicated: " + name)
|
||||
def raw(name):
|
||||
return read(logical[name])
|
||||
seen_capsules = set()
|
||||
for run, spec in runs.items():
|
||||
prefix = "cargo/" + run + "/"
|
||||
start = parsed(raw(prefix + "started.json"))
|
||||
process = parsed(raw(prefix + "process.json"))
|
||||
result = parsed(raw(prefix + "result.json"))
|
||||
required_start = {"start", "command", "cwd", "head", "scope", "inputs", "archive",
|
||||
"inputCount", "environment", "helper", "cargo", "rustc"}
|
||||
need(required_start <= start.keys(), run + ": incomplete start receipt")
|
||||
need(all(result.get(key) == value for key, value in start.items()), run + ": start/result relationship")
|
||||
command = result["command"]
|
||||
need(isinstance(command, list) and command and command[0] == "cargo"
|
||||
and all(isinstance(arg, str) for arg in command), run + ": Cargo command")
|
||||
need(command == process["command"] == spec["command"], run + ": command relationship")
|
||||
need(result["head"] == spec["head"] and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", result["head"]), run + ": HEAD")
|
||||
need(type(result["exitCode"]) is int and result["exitCode"] == spec["exitCode"], run + ": recorded exit")
|
||||
need(result["inputsUnchanged"] is True, run + ": changed inputs refused")
|
||||
need(type(result["seconds"]) in (int, float) and math.isfinite(result["seconds"])
|
||||
and result["seconds"] >= 0, run + ": duration")
|
||||
need(type(process["pid"]) is int and process["pid"] > 0, run + ": process identity")
|
||||
need(ordered_times(start["start"], process["observed"], result["finish"]), run + ": completed timestamp order")
|
||||
for field, filename in [("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log")]:
|
||||
need(result[field] == pin(raw(prefix + filename)), run + ": " + field + " pin")
|
||||
need(valid_pin(result["helper"]) and result["helper"] == pin(raw(spec["helper"])), run + ": helper pin")
|
||||
need(spec["helper"] == "helpers/" + result["helper"]["sha256"] + ".py", run + ": helper mapping")
|
||||
inputs = parsed(raw(prefix + "inputs.json"))
|
||||
need(isinstance(inputs, dict) and all(safe(name) for name in inputs), run + ": safe input inventory")
|
||||
need(type(result["inputCount"]) is int and len(inputs) == result["inputCount"] == spec["inputCount"], run + ": input counts")
|
||||
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
|
||||
capsule = (logical[prefix + "inputs.json"], logical[prefix + "inputs.zip"])
|
||||
if capsule not in seen_capsules:
|
||||
with zipfile.ZipFile(io.BytesIO(raw(prefix + "inputs.zip"))) as source:
|
||||
inventory(source, inputs)
|
||||
for name, expected in inputs.items():
|
||||
need(pin(source.read(name)) == expected, run + ": source hash " + name)
|
||||
seen_capsules.add(capsule)
|
||||
artifacts = []
|
||||
for line in raw(prefix + "stdout.log").decode("utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("reason") == "compiler-artifact" and value.get("executable"):
|
||||
artifacts.append(value)
|
||||
need(isinstance(result["artifacts"], list), run + ": artifact records")
|
||||
need(artifacts == [record["cargo"] for record in result["artifacts"]], run + ": Cargo JSON artifact relationship")
|
||||
for record in result["artifacts"]:
|
||||
need(set(record) == {"cargo", "readback"}
|
||||
and (record["readback"] is None or valid_pin(record["readback"])), run + ": artifact readback pin")
|
||||
print(run, "recorded exit", result["exitCode"], "inputs", len(inputs), "executable records", len(artifacts))
|
||||
return {"runs": len(runs), "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules)}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER_FILES, "outer file inventory")
|
||||
for name, expected in files.items():
|
||||
need(valid_pin(expected) and pin((root / name).read_bytes()) == expected, "outer hash: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS offline consistency:", verify_package(args.package))
|
||||
print("Historical receipts only; no source/binary execution or native acceptance claim.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
# Core-1 capture incident
|
||||
|
||||
The original Cargo command recorded exit 0 and eleven tests passing, but
|
||||
inputsUnchanged is false. Its source archive differs from its earlier input map
|
||||
only in crates/oa-desktop/src/model_menu.rs (44572 versus 44757 bytes).
|
||||
This is an invalid capture for normal gate evidence and is never counted as one.
|
||||
A later unchanged capture must supply the accepted core gate.
|
||||
|
||||
The fixed identity file pins the exact original seven raw files and gate helper,
|
||||
and the exact two byte hashes for the sole map/archive mismatch. The incident
|
||||
verifier requires that known mismatch and rejects any other mismatch. It does not
|
||||
repair or rewrite raw receipts, source bytes, or the false unchanged-input flag.
|
||||
|
||||
Use `python -B prepare.py --root CHECKOUT --check-only` to verify originals.
|
||||
After review, omit --check-only and supply --output NEW_DIRECTORY. This copies
|
||||
seven raw files, helper, identity and tools without wrapping a new ZIP. The raw
|
||||
inputs.zip remains byte-identical. Verify with `python -B verify.py PACKAGE`.
|
||||
Pin consistency records an incident; it does not prove the cause or execution.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"pid": 772, "observed": "2026-09-23T09:44:19.087478+00:00", "command": ["cargo", "test", "-p", "oa-core", "--example", "state_multiselect_native", "--message-format=json", "--", "--test-threads=1"]}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"start": "2026-09-23T09:44:18.183228+00:00",
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"state_multiselect_native",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"cwd": "C:\\ar-multiselect-9e8r6t",
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"scope": "All Git-visible Cargo manifests/lock, crates (including patched dependency), build, .cargo, docs, examples, harness fixtures, compiled prototypes, and packaging scripts; all 71 tracked vendored files included",
|
||||
"inputs": {
|
||||
"bytes": 122682,
|
||||
"sha256": "81318b52a4f2659f0687bfa459c7e3a24f39996dd5a8b6d7f21c3c78fa16c3b4"
|
||||
},
|
||||
"archive": {
|
||||
"bytes": 2947000,
|
||||
"sha256": "cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e"
|
||||
},
|
||||
"inputCount": 733,
|
||||
"environment": {
|
||||
"RUSTUP_TOOLCHAIN": "stable-x86_64-pc-windows-gnullvm",
|
||||
"CARGO_HOME": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo",
|
||||
"CARGO_TARGET_DIR": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm",
|
||||
"CARGO_BUILD_JOBS": "1",
|
||||
"CARGO_INCREMENTAL": "0"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
},
|
||||
"cargo": "cargo 1.98.1 (797e8a9bc 2026-08-05)",
|
||||
"rustc": "rustc 1.98.1 (48a229cea 2026-09-01)",
|
||||
"finish": "2026-09-23T09:44:55.179378+00:00",
|
||||
"seconds": 36.09660960000474,
|
||||
"exitCode": 0,
|
||||
"inputsUnchanged": false,
|
||||
"stdout": {
|
||||
"bytes": 42721,
|
||||
"sha256": "93c11a194e1c97abcdbce56c9b45be479bd1e60cfa5ecd873773246a696d146d"
|
||||
},
|
||||
"stderr": {
|
||||
"bytes": 320,
|
||||
"sha256": "1b7e7597c4c20f47faf6695a257cc32bfce21b7fedcbf3a57cac6ea40114cd22"
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"cargo": {
|
||||
"reason": "compiler-artifact",
|
||||
"package_id": "path+file:///C:/ar-multiselect-9e8r6t/crates/oa-core#0.2.0",
|
||||
"manifest_path": "C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\Cargo.toml",
|
||||
"target": {
|
||||
"kind": [
|
||||
"example"
|
||||
],
|
||||
"crate_types": [
|
||||
"bin"
|
||||
],
|
||||
"name": "state_multiselect_native",
|
||||
"src_path": "C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\examples\\state_multiselect_native.rs",
|
||||
"edition": "2024",
|
||||
"doc": false,
|
||||
"doctest": false,
|
||||
"test": false
|
||||
},
|
||||
"profile": {
|
||||
"opt_level": "0",
|
||||
"debuginfo": 0,
|
||||
"debug_assertions": true,
|
||||
"overflow_checks": true,
|
||||
"test": true
|
||||
},
|
||||
"features": [],
|
||||
"filenames": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\state_multiselect_native-d2e54d8aa041abf6.exe"
|
||||
],
|
||||
"executable": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\state_multiselect_native-d2e54d8aa041abf6.exe",
|
||||
"fresh": false
|
||||
},
|
||||
"readback": {
|
||||
"bytes": 25410048,
|
||||
"sha256": "1c77465fc9c95e0a7079e23fa534c6f8e469b8b8b3a258fc8cdd47e02ff183c7"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"start": "2026-09-23T09:44:18.183228+00:00",
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"state_multiselect_native",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"cwd": "C:\\ar-multiselect-9e8r6t",
|
||||
"head": "7e348d967d1787c052a145b7448636613a6a7054",
|
||||
"scope": "All Git-visible Cargo manifests/lock, crates (including patched dependency), build, .cargo, docs, examples, harness fixtures, compiled prototypes, and packaging scripts; all 71 tracked vendored files included",
|
||||
"inputs": {
|
||||
"bytes": 122682,
|
||||
"sha256": "81318b52a4f2659f0687bfa459c7e3a24f39996dd5a8b6d7f21c3c78fa16c3b4"
|
||||
},
|
||||
"archive": {
|
||||
"bytes": 2947000,
|
||||
"sha256": "cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e"
|
||||
},
|
||||
"inputCount": 733,
|
||||
"environment": {
|
||||
"RUSTUP_TOOLCHAIN": "stable-x86_64-pc-windows-gnullvm",
|
||||
"CARGO_HOME": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo",
|
||||
"CARGO_TARGET_DIR": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm",
|
||||
"CARGO_BUILD_JOBS": "1",
|
||||
"CARGO_INCREMENTAL": "0"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
},
|
||||
"cargo": "cargo 1.98.1 (797e8a9bc 2026-08-05)",
|
||||
"rustc": "rustc 1.98.1 (48a229cea 2026-09-01)"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Compiling oa-core v0.2.0 (C:\ar-multiselect-9e8r6t\crates\oa-core)
|
||||
Finished `test` profile [unoptimized] target(s) in 28.51s
|
||||
Running unittests examples\state_multiselect_native.rs (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\target-gnullvm\debug\examples\state_multiselect_native-d2e54d8aa041abf6.exe)
|
||||
@@ -0,0 +1,67 @@
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.107","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\proc-macro2-1.0.107\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\proc-macro2-1.0.107\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","proc-macro"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\proc-macro2-8bd275bc14efcc6d\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.107","linked_libs":[],"linked_paths":[],"cfgs":["wrap_proc_macro","proc_macro_span_location","proc_macro_span_file"],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\proc-macro2-678fa29727a3d951\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.24","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\unicode-ident-1.0.24\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"unicode_ident","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\unicode-ident-1.0.24\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libunicode_ident-37cacd323a49429c.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libunicode_ident-37cacd323a49429c.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#quote@1.0.47","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\quote-1.0.47\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\quote-1.0.47\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","proc-macro"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\quote-78b7b00c15767e2b\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#quote@1.0.47","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\quote-db749d68bbb2dbfe\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.107","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\proc-macro2-1.0.107\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"proc_macro2","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\proc-macro2-1.0.107\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","proc-macro"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libproc_macro2-80e9e45547beebf5.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libproc_macro2-80e9e45547beebf5.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#quote@1.0.47","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\quote-1.0.47\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"quote","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\quote-1.0.47\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","proc-macro"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libquote-b79aec363b458000.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libquote-b79aec363b458000.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#autocfg@1.5.1","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\autocfg-1.5.1\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"autocfg","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\autocfg-1.5.1\\src\\lib.rs","edition":"2015","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libautocfg-58f70baf3de29c77.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libautocfg-58f70baf3de29c77.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.229","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_core-1.0.229\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_core-1.0.229\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["result","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde_core-161b3ae2683262f1\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.229","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde_core-a1da37301c02ee2a\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-traits-0.2.19\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-traits-0.2.19\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","i128","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\num-traits-34ffb4c64e9b5e22\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#syn@3.0.5","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\syn-3.0.5\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"syn","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\syn-3.0.5\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["clone-impls","default","derive","parsing","printing","proc-macro"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsyn-2232b8c89352da95.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsyn-2232b8c89352da95.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.3","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\getrandom-0.4.3\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\getrandom-0.4.3\\build.rs","edition":"2024","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\getrandom-168cb7f91c8645ca\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#windows-link@0.2.1","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\windows-link-0.2.1\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"windows_link","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\windows-link-0.2.1\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwindows_link-23f115c823619f71.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwindows_link-23f115c823619f71.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.61.2","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\windows-sys-0.61.2\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"windows_sys","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\windows-sys-0.61.2\\src\\lib.rs","edition":"2021","doc":true,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["Win32","Win32_Foundation","Win32_Storage","Win32_Storage_FileSystem","Win32_System","Win32_System_Console","Win32_System_SystemInformation","default"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwindows_sys-43b3d9a205b229b7.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwindows_sys-43b3d9a205b229b7.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.3","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\getrandom-4967b63cf7b288b8\\out"}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19","linked_libs":[],"linked_paths":[],"cfgs":["has_total_cmp"],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\num-traits-418230edfe1b6b90\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.229","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_core-1.0.229\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"serde_core","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_core-1.0.229\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["result","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde_core-f52ac0796dd4fc39.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde_core-f52ac0796dd4fc39.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.23","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\zmij-1.0.23\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\zmij-1.0.23\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\zmij-e8f67a20243f3063\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\cfg-if-1.0.4\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"cfg_if","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\cfg-if-1.0.4\\src\\lib.rs","edition":"2018","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libcfg_if-805b2ebc84d544f1.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libcfg_if-805b2ebc84d544f1.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.3","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\getrandom-0.4.3\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"getrandom","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\getrandom-0.4.3\\src\\lib.rs","edition":"2024","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libgetrandom-6e8efb5a01ff0cbb.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libgetrandom-6e8efb5a01ff0cbb.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.23","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\zmij-c8f8f3187a72be14\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-traits-0.2.19\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"num_traits","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-traits-0.2.19\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","i128","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_traits-49c57447e2766d62.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_traits-49c57447e2766d62.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.20","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-2.0.20\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-2.0.20\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\thiserror-d9d1cecb0b9dfa9d\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.229","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde-1.0.229\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde-1.0.229\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","derive","serde_derive","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde-c9911adbe87e85d2\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.151","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_json-1.0.151\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_json-1.0.151\\build.rs","edition":"2021","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde_json-28521d5277e5c15b\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.151","linked_libs":[],"linked_paths":[],"cfgs":["fast_arithmetic=\"64\""],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde_json-908788f6700df5fb\\out"}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.229","linked_libs":[],"linked_paths":[],"cfgs":["if_docsrs_then_no_serde_core"],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\serde-963672309bf9e4ba\\out"}
|
||||
{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.20","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\thiserror-2e28320913ae6f8a\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#num-integer@0.1.47","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-integer-0.1.47\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"num_integer","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-integer-0.1.47\\src\\lib.rs","edition":"2018","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","i128","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_integer-cf43e8f5b615e1d9.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_integer-cf43e8f5b615e1d9.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.23","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\zmij-1.0.23\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"zmij","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\zmij-1.0.23\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libzmij-9f8770986f00ef15.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libzmij-9f8770986f00ef15.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#winapi-util@0.1.11","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\winapi-util-0.1.11\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"winapi_util","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\winapi-util-0.1.11\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwinapi_util-be9ab0847edf5b05.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libwinapi_util-be9ab0847edf5b05.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@2.0.20","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-impl-2.0.20\\Cargo.toml","target":{"kind":["proc-macro"],"crate_types":["proc-macro"],"name":"thiserror_impl","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-impl-2.0.20\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\thiserror_impl-774eb7636a800724.dll","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libthiserror_impl-774eb7636a800724.dll.a"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.229","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_derive-1.0.229\\Cargo.toml","target":{"kind":["proc-macro"],"crate_types":["proc-macro"],"name":"serde_derive","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_derive-1.0.229\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\serde_derive-c07a6353fdd1d8a1.dll","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde_derive-c07a6353fdd1d8a1.dll.a"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.4","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\once_cell-1.21.4\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"once_cell","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\once_cell-1.21.4\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["alloc","race","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libonce_cell-f7e62c44737ecbb9.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libonce_cell-f7e62c44737ecbb9.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#sha1_smol@1.0.1","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\sha1_smol-1.0.1\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"sha1_smol","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\sha1_smol-1.0.1\\src\\lib.rs","edition":"2018","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsha1_smol-c4b7e5ec8b41792e.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsha1_smol-c4b7e5ec8b41792e.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#memchr@2.8.3","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\memchr-2.8.3\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"memchr","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\memchr-2.8.3\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["alloc","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libmemchr-088af8780d4df76d.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libmemchr-088af8780d4df76d.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.18","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\itoa-1.0.18\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"itoa","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\itoa-1.0.18\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libitoa-971c3e13d32ffcf2.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libitoa-971c3e13d32ffcf2.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"path+file:///C:/ar-multiselect-9e8r6t/crates/oa-core#0.2.0","manifest_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\Cargo.toml","target":{"kind":["custom-build"],"crate_types":["bin"],"name":"build-script-build","src_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\build.rs","edition":"2024","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\oa-core-1fac039e071a74d1\\build-script-build.exe"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#fastrand@2.5.0","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\fastrand-2.5.0\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"fastrand","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\fastrand-2.5.0\\src\\lib.rs","edition":"2018","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["alloc","default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libfastrand-3df248e815d242e1.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libfastrand-3df248e815d242e1.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#tempfile@3.27.0","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\tempfile-3.27.0\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"tempfile","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\tempfile-3.27.0\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","getrandom"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libtempfile-20c336358d7a22b5.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libtempfile-20c336358d7a22b5.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"build-script-executed","package_id":"path+file:///C:/ar-multiselect-9e8r6t/crates/oa-core#0.2.0","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\build\\oa-core-70aec2be9e005f38\\out"}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.151","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_json-1.0.151\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"serde_json","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde_json-1.0.151\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde_json-b48005beba7e4267.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde_json-b48005beba7e4267.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#uuid@1.26.0","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\uuid-1.26.0\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"uuid","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\uuid-1.26.0\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","rng","serde","sha1","std","v4","v5"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libuuid-f7851e8936dae115.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libuuid-f7851e8936dae115.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.229","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde-1.0.229\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"serde","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\serde-1.0.229\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","derive","serde_derive","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde-968c91fb77bb5d38.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libserde-968c91fb77bb5d38.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.20","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-2.0.20\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"thiserror","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\thiserror-2.0.20\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libthiserror-add096f46a35d6d2.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libthiserror-add096f46a35d6d2.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#same-file@1.0.6","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\same-file-1.0.6\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"same_file","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\same-file-1.0.6\\src\\lib.rs","edition":"2018","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsame_file-a21d6395ce590b18.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libsame_file-a21d6395ce590b18.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"registry+https://github.com/rust-lang/crates.io-index#num-bigint@0.5.1","manifest_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-bigint-0.5.1\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"num_bigint","src_path":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\toolchain\\cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\num-bigint-0.5.1\\src\\lib.rs","edition":"2021","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":["default","std"],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_bigint-7e87a41b738c784b.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\libnum_bigint-7e87a41b738c784b.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"path+file:///C:/ar-multiselect-9e8r6t/crates/oa-core#0.2.0","manifest_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"oa_core","src_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\src\\lib.rs","edition":"2024","doc":true,"doctest":true,"test":true},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\liboa_core-b1d9f219f5e0886b.rlib","C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\deps\\liboa_core-b1d9f219f5e0886b.rmeta"],"executable":null,"fresh":true}
|
||||
{"reason":"compiler-artifact","package_id":"path+file:///C:/ar-multiselect-9e8r6t/crates/oa-core#0.2.0","manifest_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\Cargo.toml","target":{"kind":["example"],"crate_types":["bin"],"name":"state_multiselect_native","src_path":"C:\\ar-multiselect-9e8r6t\\crates\\oa-core\\examples\\state_multiselect_native.rs","edition":"2024","doc":false,"doctest":false,"test":false},"profile":{"opt_level":"0","debuginfo":0,"debug_assertions":true,"overflow_checks":true,"test":true},"features":[],"filenames":["C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\state_multiselect_native-d2e54d8aa041abf6.exe"],"executable":"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\state_multiselect_native-d2e54d8aa041abf6.exe","fresh":false}
|
||||
{"reason":"build-finished","success":true}
|
||||
|
||||
running 11 tests
|
||||
test tests::generated_json_and_real_source_bundle_are_accepted_unchanged ... ok
|
||||
test tests::hide_must_preserve_child_routes_and_semantic_objects ... ok
|
||||
test tests::internal_pins_left_behind_and_external_pins_moved_are_rejected ... ok
|
||||
test tests::manual_attachments_and_label_data_must_remain_exact ... ok
|
||||
test tests::moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects ... ok
|
||||
test tests::omitted_and_double_moved_child_are_rejected ... ok
|
||||
test tests::real_group_command_outputs_pass_and_are_not_unchanged ... ok
|
||||
test tests::real_hide_command_outputs_pass_before_and_after_movement ... ok
|
||||
test tests::semantic_other_view_fixed_node_and_trace_drift_are_rejected ... ok
|
||||
test tests::unchanged_rejects_valid_semantic_drift_without_geometry_changes ... ok
|
||||
test tests::zero_and_excessive_moves_cannot_satisfy_move_evidence ... ok
|
||||
|
||||
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.25s
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"README.md": {
|
||||
"bytes": 1113,
|
||||
"sha256": "23b6099c058997f1ed12c20b0dd89d124d2e6915eccf02bdbe1068544256a748"
|
||||
},
|
||||
"core-1/inputs.json": {
|
||||
"bytes": 122682,
|
||||
"sha256": "81318b52a4f2659f0687bfa459c7e3a24f39996dd5a8b6d7f21c3c78fa16c3b4"
|
||||
},
|
||||
"core-1/inputs.zip": {
|
||||
"bytes": 2947000,
|
||||
"sha256": "cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e"
|
||||
},
|
||||
"core-1/process.json": {
|
||||
"bytes": 199,
|
||||
"sha256": "c59cb33a7a7339a9b876b87e377ef57a8840bf76a411dc80d6cf5e32d365a9f8"
|
||||
},
|
||||
"core-1/result.json": {
|
||||
"bytes": 3196,
|
||||
"sha256": "ca61228c182b4824492a6248e2c37dc4519e35b5b077212e72a4283bf3b0ffab"
|
||||
},
|
||||
"core-1/started.json": {
|
||||
"bytes": 1403,
|
||||
"sha256": "2aa37908fb7eb8f49819fe8e96c59e6254f560b5c7058f7491c6c38d4b304487"
|
||||
},
|
||||
"core-1/stderr.log": {
|
||||
"bytes": 320,
|
||||
"sha256": "1b7e7597c4c20f47faf6695a257cc32bfce21b7fedcbf3a57cac6ea40114cd22"
|
||||
},
|
||||
"core-1/stdout.log": {
|
||||
"bytes": 42721,
|
||||
"sha256": "93c11a194e1c97abcdbce56c9b45be479bd1e60cfa5ecd873773246a696d146d"
|
||||
},
|
||||
"gate-helper.py": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
},
|
||||
"identity.json": {
|
||||
"bytes": 1455,
|
||||
"sha256": "b065e20e1130fbd1caff052e71e01da083c14eaf785748b3d32c3a691f7146a9"
|
||||
},
|
||||
"prepare.py": {
|
||||
"bytes": 1866,
|
||||
"sha256": "0550fb880635e27a193d2b5166f1dbbe1edb2091f3bf5d23be5fc3fa3b4ae63b"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 3726,
|
||||
"sha256": "3a33fb46f3b9b8a30a35dbc6469d404cca94fa2ed40804101719f2d2e686d6d0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
import datetime, hashlib, json, os, subprocess, sys, time, zipfile
|
||||
|
||||
# Process-local disk recovery: do not write another Rust incremental cache.
|
||||
os.environ['CARGO_INCREMENTAL'] = '0'
|
||||
|
||||
root = Path('C:/ar-multiselect-9e8r6t')
|
||||
out = Path(__file__).resolve().parent / sys.argv[1]
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
def digest(path):
|
||||
data = path.read_bytes()
|
||||
return {'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
|
||||
def inputs():
|
||||
paths = subprocess.check_output(['git','ls-files','--cached','--others','--exclude-standard','-z'],cwd=root).decode().split('\0')
|
||||
return {p:digest(root/p) for p in sorted(set(paths)) if p and (p in ('Cargo.toml','Cargo.lock','rust-toolchain.toml') or p.startswith(('crates/','build/','.cargo/','scripts/','docs/','examples/','harness/fixtures/','harness/prototypes/')))}
|
||||
before=inputs()
|
||||
assert len([p for p in before if p.startswith('crates/vendor/iced_widget/')]) == 71, 'Vendored input inventory changed'
|
||||
assert all(before[p] == digest(root/p) for p in before)
|
||||
(out/'inputs.json').write_text(json.dumps(before,indent=2),encoding='utf-8')
|
||||
with zipfile.ZipFile(out/'inputs.zip','x',compression=zipfile.ZIP_DEFLATED) as z:
|
||||
for p in before: z.write(root/p,p)
|
||||
command=['cargo',*sys.argv[2:]]
|
||||
start={'start':now(),'command':command,'cwd':str(root),'head':subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip(),
|
||||
'scope':'All Git-visible Cargo manifests/lock, crates (including patched dependency), build, .cargo, docs, examples, harness fixtures, compiled prototypes, and packaging scripts; all 71 tracked vendored files included',
|
||||
'inputs':digest(out/'inputs.json'),'archive':digest(out/'inputs.zip'),'inputCount':len(before),
|
||||
'environment':{k:os.environ.get(k) for k in ('RUSTUP_TOOLCHAIN','CARGO_HOME','CARGO_TARGET_DIR','CARGO_BUILD_JOBS','CARGO_INCREMENTAL')},
|
||||
'helper':digest(Path(__file__).resolve()),
|
||||
'cargo':subprocess.check_output(['cargo','--version'],text=True).strip(),'rustc':subprocess.check_output(['rustc','--version'],text=True).strip()}
|
||||
(out/'started.json').write_text(json.dumps(start,indent=2),encoding='utf-8')
|
||||
t=time.perf_counter()
|
||||
with (out/'stdout.log').open('xb') as stdout,(out/'stderr.log').open('xb') as stderr:
|
||||
r=subprocess.Popen(command,cwd=root,stdout=stdout,stderr=stderr)
|
||||
(out/'process.json').write_text(json.dumps({'pid':r.pid,'observed':now(),'command':command}),encoding='utf-8')
|
||||
r.wait()
|
||||
artifacts=[]
|
||||
for line in (out/'stdout.log').read_text(encoding='utf-8',errors='replace').splitlines():
|
||||
try: item=json.loads(line)
|
||||
except json.JSONDecodeError: continue
|
||||
if isinstance(item,dict) and item.get('reason')=='compiler-artifact' and item.get('executable'):
|
||||
path=Path(item['executable'])
|
||||
artifacts.append({'cargo':item,'readback':digest(path) if path.exists() else None})
|
||||
result={**start,'finish':now(),'seconds':time.perf_counter()-t,'exitCode':r.returncode,'inputsUnchanged':before==inputs(),
|
||||
'stdout':digest(out/'stdout.log'),'stderr':digest(out/'stderr.log'),'artifacts':artifacts}
|
||||
(out/'result.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
|
||||
print(json.dumps({k:v for k,v in result.items() if k!='artifacts'},indent=2))
|
||||
print('Executable artifacts:',len(artifacts))
|
||||
sys.exit(0 if r.returncode==0 and result['inputsUnchanged'] else 1)
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"raw": {
|
||||
"inputs.json": {
|
||||
"bytes": 122682,
|
||||
"sha256": "81318b52a4f2659f0687bfa459c7e3a24f39996dd5a8b6d7f21c3c78fa16c3b4"
|
||||
},
|
||||
"inputs.zip": {
|
||||
"bytes": 2947000,
|
||||
"sha256": "cecd6ae0ba45add8e913d9913cd4960c01928db0a4bc795902b7954ffb6f692e"
|
||||
},
|
||||
"process.json": {
|
||||
"bytes": 199,
|
||||
"sha256": "c59cb33a7a7339a9b876b87e377ef57a8840bf76a411dc80d6cf5e32d365a9f8"
|
||||
},
|
||||
"result.json": {
|
||||
"bytes": 3196,
|
||||
"sha256": "ca61228c182b4824492a6248e2c37dc4519e35b5b077212e72a4283bf3b0ffab"
|
||||
},
|
||||
"started.json": {
|
||||
"bytes": 1403,
|
||||
"sha256": "2aa37908fb7eb8f49819fe8e96c59e6254f560b5c7058f7491c6c38d4b304487"
|
||||
},
|
||||
"stderr.log": {
|
||||
"bytes": 320,
|
||||
"sha256": "1b7e7597c4c20f47faf6695a257cc32bfce21b7fedcbf3a57cac6ea40114cd22"
|
||||
},
|
||||
"stdout.log": {
|
||||
"bytes": 42721,
|
||||
"sha256": "93c11a194e1c97abcdbce56c9b45be479bd1e60cfa5ecd873773246a696d146d"
|
||||
}
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
},
|
||||
"mismatches": {
|
||||
"crates/oa-desktop/src/model_menu.rs": {
|
||||
"map": {
|
||||
"bytes": 44572,
|
||||
"sha256": "3844be8ec15fe2c5614ff5625ec7b63234fbf5a6d126de5a85c8f5c51dfa7101"
|
||||
},
|
||||
"archive": {
|
||||
"bytes": 44757,
|
||||
"sha256": "82998c97e8d706b1ce1c1fb7058eaeabfceae3004e38f60d6076194968dc5272"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Preserve the raw core-1 capture incident separately from strict gate packages."""
|
||||
from pathlib import Path
|
||||
import argparse, json
|
||||
from verify import HERE, RAW, need, pin, parsed, verify
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root',type=Path,required=True)
|
||||
parser.add_argument('--helper',type=Path,action='append',default=[])
|
||||
parser.add_argument('--output',type=Path)
|
||||
parser.add_argument('--check-only',action='store_true')
|
||||
args=parser.parse_args()
|
||||
gates=args.root/'.local/state-group-gates'; folder=gates/'core-1'
|
||||
need(all(p.is_file() and not p.is_symlink() for p in folder.iterdir()) and {p.name for p in folder.iterdir()}==RAW,'exact closed raw inventory')
|
||||
raw={n:(folder/n).read_bytes() for n in RAW}
|
||||
identity=parsed((HERE/'identity.json').read_bytes())
|
||||
matches=[p.read_bytes() for p in list(gates.glob('*.py'))+args.helper if pin(p.read_bytes())==identity['helper']]
|
||||
need(bool(matches),'exact historical helper required')
|
||||
summary=verify(raw,matches[0],identity)
|
||||
if args.check_only:
|
||||
print('PASS incident check-only, no package written:',summary); return
|
||||
need(args.output is not None,'explicit --output required')
|
||||
out=args.output.resolve(); need(not out.exists(),'existing output refused')
|
||||
out.mkdir(parents=True); (out/'core-1').mkdir()
|
||||
payload={'core-1/'+n:b for n,b in raw.items()}
|
||||
payload['gate-helper.py']=matches[0]
|
||||
for n in ('prepare.py','verify.py','README.md','identity.json'): payload[n]=(HERE/n).read_bytes()
|
||||
for n,b in payload.items():
|
||||
with (out/n).open('xb') as f: f.write(b)
|
||||
with (out/'files.json').open('x',encoding='utf-8') as f: json.dump({n:pin(b) for n,b in sorted(payload.items())},f,indent=2)
|
||||
print('Preserved incident, not a gate pass:',out,summary)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Verify one pinned capture incident; never count it as a normal gate pass."""
|
||||
from pathlib import Path
|
||||
import argparse, hashlib, io, json, stat, zipfile
|
||||
HERE = Path(__file__).resolve().parent
|
||||
RAW = {'started.json','process.json','result.json','stdout.log','stderr.log','inputs.json','inputs.zip'}
|
||||
COMMAND = ['cargo','test','-p','oa-core','--example','state_multiselect_native','--message-format=json','--','--test-threads=1']
|
||||
CHANGED = 'crates/oa-desktop/src/model_menu.rs'
|
||||
def need(ok, message):
|
||||
if not ok: raise ValueError(message)
|
||||
def pin(data): return {'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()}
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
out={}
|
||||
for k,v in items:
|
||||
need(k not in out, 'duplicate JSON key')
|
||||
out[k]=v
|
||||
return out
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
def verify(raw, helper, identity):
|
||||
need(set(raw)==set(identity['raw'])==RAW, 'exact seven raw files')
|
||||
need({n:pin(b) for n,b in raw.items()}==identity['raw'], 'exact incident raw pins')
|
||||
start,process,result=[parsed(raw[n+'.json']) for n in ('started','process','result')]
|
||||
need(all(result.get(k)==v for k,v in start.items()), 'start/result relationship')
|
||||
need(result['command']==process['command']==COMMAND, 'specific core example test command')
|
||||
need(result['exitCode']==0 and result['inputsUnchanged'] is False, 'incident status, not accepted gate')
|
||||
need(pin(helper)==identity['helper']==result['helper'], 'historical helper pin')
|
||||
for field,name in [('inputs','inputs.json'),('archive','inputs.zip'),('stdout','stdout.log'),('stderr','stderr.log')]:
|
||||
need(result[field]==pin(raw[name]), field+' pin')
|
||||
inputs=parsed(raw['inputs.json'])
|
||||
need(len(inputs)==result['inputCount']==733, 'historical input count')
|
||||
actual={}
|
||||
with zipfile.ZipFile(io.BytesIO(raw['inputs.zip'])) as z:
|
||||
need(len(z.namelist())==len(set(z.namelist())) and set(z.namelist())==set(inputs), 'exact source inventory')
|
||||
for item in z.infolist():
|
||||
name=item.filename
|
||||
need(not item.is_dir() and not stat.S_ISLNK(item.external_attr>>16) and '\\' not in name and ':' not in name and not name.startswith('/') and all(p not in ('','.','..') for p in name.split('/')), 'safe source name')
|
||||
actual[name]=pin(z.read(name))
|
||||
mismatch={n:{'map':inputs[n],'archive':actual[n]} for n in inputs if inputs[n]!=actual[n]}
|
||||
need(set(mismatch)=={CHANGED} and mismatch==identity['mismatches'], 'exact sole known map/archive mismatch')
|
||||
need(mismatch[CHANGED]['map']['bytes']==44572 and mismatch[CHANGED]['archive']['bytes']==44757, 'known mismatch sizes')
|
||||
need(b'test result: ok. 11 passed; 0 failed;' in raw['stdout.log'], 'recorded eleven-test result')
|
||||
return {'incident':'core-1','acceptedGate':False,'rawFiles':7,'mismatchedInputs':list(mismatch)}
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('package',nargs='?',type=Path,default=HERE)
|
||||
args=parser.parse_args(); root=args.package
|
||||
files=parsed((root/'files.json').read_bytes())
|
||||
expected={'prepare.py','verify.py','README.md','identity.json','gate-helper.py'}|{'core-1/'+n for n in RAW}
|
||||
need(set(files)==expected, 'package file inventory')
|
||||
need({p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()}==expected|{'files.json'}, 'exact on-disk package files')
|
||||
for n,p in files.items(): need(pin((root/n).read_bytes())==p, 'package pin '+n)
|
||||
print('PASS incident consistency; not a gate pass:', verify({n:(root/'core-1'/n).read_bytes() for n in RAW},(root/'gate-helper.py').read_bytes(),parsed((root/'identity.json').read_bytes())))
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,27 @@
|
||||
# State multiselect native evidence tools
|
||||
|
||||
Candidate tools; no native run or acceptance is asserted by their presence.
|
||||
Prepare only a closed `.local/state-multiselect-native/run-1`:
|
||||
|
||||
```
|
||||
python -B prepare.py --root CHECKOUT --runs run-1 --check-only
|
||||
```
|
||||
|
||||
Declare any additional unchanged snapshot labels with `--extra-unchanged LABEL ...`.
|
||||
Nine original history snapshots remain mandatory. Each additional snapshot must
|
||||
pass every checker/provenance/time/source check and equal baseline bytes exactly.
|
||||
All three distinct history byte groups, complete-Project comparisons, input-map
|
||||
and executable relationships, unchanged flags, captured-window relationships,
|
||||
and exact archive inventories remain required. Source count must match the
|
||||
fixture and history record. Only `views/diagram_state_view.arcadia` may change.
|
||||
|
||||
After completed native observation and final source verification, omit
|
||||
`--check-only` and provide
|
||||
`--output CHECKOUT/harness/experiments/state-multiselect-20260923/native`.
|
||||
Run `python -B verify.py PACKAGE` to check a written package. Existing outputs
|
||||
are refused. Helpers are preserved as pinned data; they are never executed.
|
||||
Referenced full build capsules/logs belong in the sibling checks package.
|
||||
|
||||
Offline byte/receipt consistency does not authenticate actions, establish
|
||||
usability, rerun the checker, or prove native acceptance. Native action
|
||||
observations and limitations require a separate reviewed record.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 7494,
|
||||
"sha256": "0ba52dc39d08888e34d2b84e25f40acbcfda97aa74c2fd21319967867e0008ae"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 15255,
|
||||
"sha256": "26e08398561c1753926cb994d674d311307970f06f0f8ae68ca6dec4f5e9a8e4"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 1457,
|
||||
"sha256": "dc13ee4603368a86b49cd54d5c3b19c7759e81a16ccc3c782882285db0758d26"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 42972,
|
||||
"sha256": "088fd71a7e41536287aeeed32ab6da41130a0f3b3621dd752b88290a435bff50"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 3384779,
|
||||
"sha256": "1d08c1bc6af4856a569ddd320ddf5403a302b8d9437750663a498541e489d663"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kind": "multiselect-native",
|
||||
"runs": {
|
||||
"run-1": {
|
||||
"originalRun": "C:/ar-multiselect-9e8r6t/.local/state-multiselect-native/run-1",
|
||||
"originalFixture": "C:/ar-multiselect-9e8r6t/.local/state-multiselect-native/fixture-1",
|
||||
"helpers": {
|
||||
"helper": "helpers/68c17b874f25c2b65a0aa81b6db6de9a28184388fee04185e47615b022cfbdf3.py",
|
||||
"capture": "helpers/c11446a694a6357c77e2cba405c5a078b29eef5cdd2342579d234b4e20270eb1.js",
|
||||
"snapshotHelper": "helpers/4301a93f23db7d43399ed87c6eede2196d38911aba09a391fae5bcd4b691fa7b.py",
|
||||
"history": "helpers/dc3ad2c3491acda3290099e4c3af0ef6d5c2362ca1df9c2ee3b8f113b2f5e723.py"
|
||||
},
|
||||
"builds": {
|
||||
"gui": {
|
||||
"receipt": "builds/172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb/result.json",
|
||||
"inputs": "builds/172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb/inputs.json",
|
||||
"originalRelative": ".local/state-group-gates/windows-build-1/result.json",
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
},
|
||||
"checker": {
|
||||
"receipt": "builds/03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955/result.json",
|
||||
"inputs": "builds/03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955/inputs.json",
|
||||
"originalRelative": ".local/state-group-gates/native-build-1/result.json",
|
||||
"helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py"
|
||||
}
|
||||
},
|
||||
"extraUnchangedSnapshots": [
|
||||
"row-mixed-unchanged"
|
||||
],
|
||||
"checkerSource": {
|
||||
"inputPath": "crates/oa-core/examples/state_multiselect_native.rs",
|
||||
"member": "checker-source/5db7a00b044c27b851731b3d8cb16cc3f638a5242bf53bd46b3a5e1df4de0460.rs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"builds/03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955/inputs.json": {
|
||||
"bytes": 123053,
|
||||
"sha256": "4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf"
|
||||
},
|
||||
"builds/03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955/result.json": {
|
||||
"bytes": 3311,
|
||||
"sha256": "03c22f6269b023f77d0fb3e713d5bbab8d14cf6e67ab4dfc613b4691fe4a6955"
|
||||
},
|
||||
"builds/172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb/inputs.json": {
|
||||
"bytes": 123053,
|
||||
"sha256": "4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf"
|
||||
},
|
||||
"builds/172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb/result.json": {
|
||||
"bytes": 3193,
|
||||
"sha256": "172faec4d7708e4539468c3a9062d424f0e00cb80e827cd579274942c757eccb"
|
||||
},
|
||||
"checker-source/5db7a00b044c27b851731b3d8cb16cc3f638a5242bf53bd46b3a5e1df4de0460.rs": {
|
||||
"bytes": 31343,
|
||||
"sha256": "5db7a00b044c27b851731b3d8cb16cc3f638a5242bf53bd46b3a5e1df4de0460"
|
||||
},
|
||||
"helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": {
|
||||
"bytes": 3827,
|
||||
"sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20"
|
||||
},
|
||||
"helpers/4301a93f23db7d43399ed87c6eede2196d38911aba09a391fae5bcd4b691fa7b.py": {
|
||||
"bytes": 2503,
|
||||
"sha256": "4301a93f23db7d43399ed87c6eede2196d38911aba09a391fae5bcd4b691fa7b"
|
||||
},
|
||||
"helpers/68c17b874f25c2b65a0aa81b6db6de9a28184388fee04185e47615b022cfbdf3.py": {
|
||||
"bytes": 3411,
|
||||
"sha256": "68c17b874f25c2b65a0aa81b6db6de9a28184388fee04185e47615b022cfbdf3"
|
||||
},
|
||||
"helpers/c11446a694a6357c77e2cba405c5a078b29eef5cdd2342579d234b4e20270eb1.js": {
|
||||
"bytes": 1405,
|
||||
"sha256": "c11446a694a6357c77e2cba405c5a078b29eef5cdd2342579d234b4e20270eb1"
|
||||
},
|
||||
"helpers/dc3ad2c3491acda3290099e4c3af0ef6d5c2362ca1df9c2ee3b8f113b2f5e723.py": {
|
||||
"bytes": 2192,
|
||||
"sha256": "dc3ad2c3491acda3290099e4c3af0ef6d5c2362ca1df9c2ee3b8f113b2f5e723"
|
||||
},
|
||||
"runs/run-1/fixture/baseline.json": {
|
||||
"bytes": 18490,
|
||||
"sha256": "07432f30fecbf0710ab3c01dbfa760df18e781ac81713ed86fa2ea232eff0d07"
|
||||
},
|
||||
"runs/run-1/fixture/manifest.json": {
|
||||
"bytes": 2897,
|
||||
"sha256": "2d4a067b45181b9b66b84598464b20045f13ce23890d3a7c5b195e2f7f9f7b64"
|
||||
},
|
||||
"runs/run-1/fixture/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/fixture/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/fixture/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/fixture/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/fixture/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/fixture/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/fixture/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/fixture/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/fixture/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/01-start-0.jpg": {
|
||||
"bytes": 116250,
|
||||
"sha256": "dcaf44d81bf7d9bccabe1aab8e6d1143e29c3561b56bda56da5fbcfe5ce7094f"
|
||||
},
|
||||
"runs/run-1/raw/01-start.json": {
|
||||
"bytes": 919,
|
||||
"sha256": "5422b74fa139899ab9f355c787aed483bd7a8e40a8796aa291e3bf8a884947d3"
|
||||
},
|
||||
"runs/run-1/raw/02-open-dialog-0.jpg": {
|
||||
"bytes": 114527,
|
||||
"sha256": "a2622f57aa5e83353244216c177110d7627605cbef02cfde3ea4ceb5d543d72c"
|
||||
},
|
||||
"runs/run-1/raw/02-open-dialog.json": {
|
||||
"bytes": 925,
|
||||
"sha256": "300dfa12f3c5f170130bc4b2a6ef0eb1d75ecddc7497f3b325e174165d6e389d"
|
||||
},
|
||||
"runs/run-1/raw/03-project-open-0.jpg": {
|
||||
"bytes": 133077,
|
||||
"sha256": "d08a5d7a3c65b079de4d83af523d8398732e976fc4cd7c1672e5e1231ee8fd36"
|
||||
},
|
||||
"runs/run-1/raw/03-project-open.json": {
|
||||
"bytes": 934,
|
||||
"sha256": "1cf0f6b55cfc904c0e023e463d844aa3929c6728da6b826a8c1b3a87ccf371c9"
|
||||
},
|
||||
"runs/run-1/raw/04-baseline-saved-0.jpg": {
|
||||
"bytes": 131419,
|
||||
"sha256": "64e2fdea1f0372024bf377493416f75fce34f3ae6d6cdf1870b40c8ab7ae0e0b"
|
||||
},
|
||||
"runs/run-1/raw/04-baseline-saved.json": {
|
||||
"bytes": 938,
|
||||
"sha256": "2cd7ccc03073bda0c2468437dcd90cf00e06354cac8cafba0cfd73aae944d640"
|
||||
},
|
||||
"runs/run-1/raw/05-a-selected-0.jpg": {
|
||||
"bytes": 146454,
|
||||
"sha256": "a1b3da460a3e3aeaf611321bc2436dd0551463dce0b03a28ef60d509daec671c"
|
||||
},
|
||||
"runs/run-1/raw/05-a-selected.json": {
|
||||
"bytes": 934,
|
||||
"sha256": "74d3eda5e9b8512e8a842cc302183ac97b64cdcf46602519dfa2ac624542b3db"
|
||||
},
|
||||
"runs/run-1/raw/06-row-context-0.jpg": {
|
||||
"bytes": 148643,
|
||||
"sha256": "97c37986756b9a64d3995ea824b5d6c5124345bda0c309e1da09f7aaf1554491"
|
||||
},
|
||||
"runs/run-1/raw/06-row-context.json": {
|
||||
"bytes": 935,
|
||||
"sha256": "6edea69009268837c510666a7697700a081fd8685c12aef0b34485965225a4a7"
|
||||
},
|
||||
"runs/run-1/raw/07-row-added-0.jpg": {
|
||||
"bytes": 145885,
|
||||
"sha256": "ea7a830f2ed4b35b4173dc828de2184e65162c39c73b19cc1ea0c90c5cfa0ab4"
|
||||
},
|
||||
"runs/run-1/raw/07-row-added.json": {
|
||||
"bytes": 933,
|
||||
"sha256": "d087cebd389786ba05a6dc355410fa371f7e369b692d93a6805684091b7fb11e"
|
||||
},
|
||||
"runs/run-1/raw/08-mixed-drag-refused-0.jpg": {
|
||||
"bytes": 151098,
|
||||
"sha256": "3ad911c0f3a44d3078411570bb87ddecb7d5e27661b1d0421275210290668201"
|
||||
},
|
||||
"runs/run-1/raw/08-mixed-drag-refused.json": {
|
||||
"bytes": 942,
|
||||
"sha256": "eb4c94795c8ed43e492e9748ba2fb95d8f56020e91d9cd93ccafd777c8f61331"
|
||||
},
|
||||
"runs/run-1/raw/09-mixed-refusal-saved-0.jpg": {
|
||||
"bytes": 148444,
|
||||
"sha256": "d268d68462744bc54cdc2f333ce1d119178d17b6746dc5aede2cae226fe2f89a"
|
||||
},
|
||||
"runs/run-1/raw/09-mixed-refusal-saved.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "1b971ab580bd8a27c74ae29f71a9c6a94022fb025b6c739923954a64efaa014b"
|
||||
},
|
||||
"runs/run-1/raw/10-stationary-collapse-0.jpg": {
|
||||
"bytes": 146515,
|
||||
"sha256": "430f5c79dd891d88c4a8e25f02d644d9e23e709bbdd9229c1668e486a65dffad"
|
||||
},
|
||||
"runs/run-1/raw/10-stationary-collapse.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "037cc2690a0334b7f0032e4fdffe9e81793267ca47184d0b78fc10a2d13c1c7e"
|
||||
},
|
||||
"runs/run-1/raw/11-b-context-0.jpg": {
|
||||
"bytes": 155065,
|
||||
"sha256": "4321ffcff5b002ada96714d7e5d8c6ac55db76e1fdd539ab3f3a053ac9b43ac6"
|
||||
},
|
||||
"runs/run-1/raw/11-b-context.json": {
|
||||
"bytes": 933,
|
||||
"sha256": "f9cdd902dda8e296a733e2d8728b22f3908da6be97d7e0c3c297bb05ec7c529b"
|
||||
},
|
||||
"runs/run-1/raw/12-placement-group-0.jpg": {
|
||||
"bytes": 147223,
|
||||
"sha256": "ffdd50679ebf1f4ef581e943e4c8edd4e0170d721a38e1b2e4f9090c570d58a4"
|
||||
},
|
||||
"runs/run-1/raw/12-placement-group.json": {
|
||||
"bytes": 939,
|
||||
"sha256": "cf462a9354aa79af5e0975162d530bb4adf1e89f7d52492b62ae7da7034928b1"
|
||||
},
|
||||
"runs/run-1/raw/13-group-moved-0.jpg": {
|
||||
"bytes": 147935,
|
||||
"sha256": "fa65ffefd695b4b5aef67c22bf23148906182c769e0e512e98a1f7c32435035f"
|
||||
},
|
||||
"runs/run-1/raw/13-group-moved.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "9261f3bb668dd6660c2a0355adb982a0f00f6961159d8706bdc1c982a0b7a816"
|
||||
},
|
||||
"runs/run-1/raw/14-move-saved-0.jpg": {
|
||||
"bytes": 146770,
|
||||
"sha256": "f055b46341c302063cb702dc6619f2509f5c4a80adc5eb6bcf48d77098c0026f"
|
||||
},
|
||||
"runs/run-1/raw/14-move-saved.json": {
|
||||
"bytes": 934,
|
||||
"sha256": "306876d9b6de633d623227a16a84f9fcc066b37008c436e670bfd3df9d816cc8"
|
||||
},
|
||||
"runs/run-1/raw/15-move-undo-0.jpg": {
|
||||
"bytes": 148428,
|
||||
"sha256": "268fdaaa4d4832cf3bdcbd69d6ac062ece52301b7cd0ae38cd1edd38fa157fe3"
|
||||
},
|
||||
"runs/run-1/raw/15-move-undo.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "300a234ed16f53fce88f7f8eaf9d252af8e98122f5c1f8aa13f6dbad8d56012c"
|
||||
},
|
||||
"runs/run-1/raw/16-move-undo-saved-0.jpg": {
|
||||
"bytes": 148316,
|
||||
"sha256": "c4e986553c2938bffca8ee1f7e6ee7c4a3aa077e6ff6bd9da50d35e10684a9f3"
|
||||
},
|
||||
"runs/run-1/raw/16-move-undo-saved.json": {
|
||||
"bytes": 941,
|
||||
"sha256": "fef07b40f46a6138623de1f10d5e74c81f87f18a35059b132fe2af192e6d17dc"
|
||||
},
|
||||
"runs/run-1/raw/17-move-redo-0.jpg": {
|
||||
"bytes": 146795,
|
||||
"sha256": "37476c5a02d9ca0db214e244040b112dc50fd4d1418ec3af96f0a4f4776e4dde"
|
||||
},
|
||||
"runs/run-1/raw/17-move-redo.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "751ccfe6dc9acecdc0e7225aedd17be44ea374f368cba5ab2191c8c659a55af2"
|
||||
},
|
||||
"runs/run-1/raw/18-move-redo-saved-0.jpg": {
|
||||
"bytes": 146823,
|
||||
"sha256": "c248b70901e52881bce338b6fe0f7fdcaf19df8fad349f24b2c64be54a187069"
|
||||
},
|
||||
"runs/run-1/raw/18-move-redo-saved.json": {
|
||||
"bytes": 939,
|
||||
"sha256": "cfb609930813f6b8cd051c7daf538f8feb04be374be28c65fbd498dd937d82d9"
|
||||
},
|
||||
"runs/run-1/raw/19-before-hide-undo-0.jpg": {
|
||||
"bytes": 148446,
|
||||
"sha256": "a2870258332cc555c9b8abfc3db070ff1582ea449ed6d1d7eedd19e977ea8021"
|
||||
},
|
||||
"runs/run-1/raw/19-before-hide-undo.json": {
|
||||
"bytes": 950,
|
||||
"sha256": "4336190711b265ab5bf508a48528fd0e4b448b48cca86d9907d4704a05087de7"
|
||||
},
|
||||
"runs/run-1/raw/20-before-hide-saved-0.jpg": {
|
||||
"bytes": 148362,
|
||||
"sha256": "6cf90eb40ba2d70dd66da6fd5eb4ef07a787a11a05841d5fd2abe04630b34e02"
|
||||
},
|
||||
"runs/run-1/raw/20-before-hide-saved.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "1c74bc7db156e7aa9a79dea803b77d991a5ae242fbfc82884fcea2235714d6d7"
|
||||
},
|
||||
"runs/run-1/raw/21-group-menu-0.jpg": {
|
||||
"bytes": 153978,
|
||||
"sha256": "472f92a8a5aaa9ca983e5ad9262414e3abe82b0618996206fdd28199d543bcdd"
|
||||
},
|
||||
"runs/run-1/raw/21-group-menu.json": {
|
||||
"bytes": 934,
|
||||
"sha256": "cfbba47a72d05700bd6c159175250bb3dcb7a4277527df4c0873853fb87661df"
|
||||
},
|
||||
"runs/run-1/raw/22-group-hidden-0.jpg": {
|
||||
"bytes": 137291,
|
||||
"sha256": "30112dfcead6736041c506fbe6f9f4abd67808bcd59ec96fb684839ed9fef071"
|
||||
},
|
||||
"runs/run-1/raw/22-group-hidden.json": {
|
||||
"bytes": 944,
|
||||
"sha256": "0985c92ce81de4240451e074c361929a9ae90525d83feeba94c6a7022268683d"
|
||||
},
|
||||
"runs/run-1/raw/23-hidden-saved-0.jpg": {
|
||||
"bytes": 136419,
|
||||
"sha256": "59e0770669d39cacc14ac5f958739d06cf0b647b02f096ff97ab7f65c3448758"
|
||||
},
|
||||
"runs/run-1/raw/23-hidden-saved.json": {
|
||||
"bytes": 938,
|
||||
"sha256": "3f5e4122c005b0af25b54f91fa75640c1379afbcc2b5a26c36b89df91c354418"
|
||||
},
|
||||
"runs/run-1/raw/24-hidden-placement-picker-0.jpg": {
|
||||
"bytes": 137734,
|
||||
"sha256": "f4fd098b9d64194846bfc3912a11ee20a09abd042bb8a33e8bc5a52e0b393a5f"
|
||||
},
|
||||
"runs/run-1/raw/24-hidden-placement-picker.json": {
|
||||
"bytes": 947,
|
||||
"sha256": "f345669592b3efa382a9f688d0e97291c045c2734dba11ad0262ba1be3178273"
|
||||
},
|
||||
"runs/run-1/raw/25-picker-dismissed-0.jpg": {
|
||||
"bytes": 137694,
|
||||
"sha256": "e5af5ad56705204d79352869f362ab430d1c5cbafce5ae1dba5093a8311728a8"
|
||||
},
|
||||
"runs/run-1/raw/25-picker-dismissed.json": {
|
||||
"bytes": 940,
|
||||
"sha256": "317c8c7e5e1f9a0bb7ac1de51b8a75318ac29d67f81142d46214a013f53def18"
|
||||
},
|
||||
"runs/run-1/raw/26-picker-click-dismissed-0.jpg": {
|
||||
"bytes": 136300,
|
||||
"sha256": "c5e1c4f8ed3fbb68b67429220d3a5db6a846f6753c297d194fcdeaba0ef268a0"
|
||||
},
|
||||
"runs/run-1/raw/26-picker-click-dismissed.json": {
|
||||
"bytes": 946,
|
||||
"sha256": "938fe444bbff489676da4e36b8b9272f78739b520aca23264b7a2001c352f283"
|
||||
},
|
||||
"runs/run-1/raw/27-hide-undo-0.jpg": {
|
||||
"bytes": 145506,
|
||||
"sha256": "712d6b3863bef24458e31be2b86fcc2b4e1f08a340ed78dda0c27f2d20992275"
|
||||
},
|
||||
"runs/run-1/raw/27-hide-undo.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "4b7c9088978c6bef6c60cfb9401f39b6c1460f92382a54db60c1e30bac97c4e6"
|
||||
},
|
||||
"runs/run-1/raw/28-hide-undo-saved-0.jpg": {
|
||||
"bytes": 145429,
|
||||
"sha256": "848082bd3d40431dbd559896cf203c7d4a58385518b3ca02559a4b66633b8ccb"
|
||||
},
|
||||
"runs/run-1/raw/28-hide-undo-saved.json": {
|
||||
"bytes": 939,
|
||||
"sha256": "8311eeb6bce344a989ffd7441ccfe75fd248d57a3e7013c8d7fd770850485d72"
|
||||
},
|
||||
"runs/run-1/raw/29-hide-redo-0.jpg": {
|
||||
"bytes": 136428,
|
||||
"sha256": "280ccb922882c3419c84d7cae928d62c0cc1cd7a8b0b6a5a1245be0046895ae0"
|
||||
},
|
||||
"runs/run-1/raw/29-hide-redo.json": {
|
||||
"bytes": 943,
|
||||
"sha256": "e238877bc772c25f2f05a2a4e75d2d78c349c2d1c27ab7fe49f05cc73599f435"
|
||||
},
|
||||
"runs/run-1/raw/30-hide-redo-saved-0.jpg": {
|
||||
"bytes": 136444,
|
||||
"sha256": "5a81ae63d47528fdab27b987f1b9d996cc188f96b26c4d6b3009b4f637562ae4"
|
||||
},
|
||||
"runs/run-1/raw/30-hide-redo-saved.json": {
|
||||
"bytes": 941,
|
||||
"sha256": "932f541a8e9364a50c714401a915717d69035cd9e15c415454d169749c0bc16f"
|
||||
},
|
||||
"runs/run-1/raw/31-reopen-request-0.jpg": {
|
||||
"bytes": 107440,
|
||||
"sha256": "88b53c5f37b0107a1842c3b79b6258b255ef6bf41f6fd5df76fd8cc1d4cdd48f"
|
||||
},
|
||||
"runs/run-1/raw/31-reopen-request.json": {
|
||||
"bytes": 938,
|
||||
"sha256": "c65e7668bad5c08953e6bf03ada8f304d9b5512751b8cb19657d84cf7d6f869c"
|
||||
},
|
||||
"runs/run-1/raw/32-reopened-hidden-0.jpg": {
|
||||
"bytes": 104737,
|
||||
"sha256": "ef52c777acfc3acf9914e3703601d96e9fcc20095870852d02b696a50317c8ad"
|
||||
},
|
||||
"runs/run-1/raw/32-reopened-hidden-1.jpg": {
|
||||
"bytes": 4207,
|
||||
"sha256": "147948d69961c42f1feea9449df477b8436250388a1ae070e744ed4709a49bf3"
|
||||
},
|
||||
"runs/run-1/raw/32-reopened-hidden-2.jpg": {
|
||||
"bytes": 4000,
|
||||
"sha256": "9110bb88fff054f17fa7f2574a409f27755a72ffe6c14b42097db201a80d2b3f"
|
||||
},
|
||||
"runs/run-1/raw/32-reopened-hidden.json": {
|
||||
"bytes": 983,
|
||||
"sha256": "e265715f1893419799423d265cb1d07d0554fa03fe25346cf5377669448aceea"
|
||||
},
|
||||
"runs/run-1/raw/33-reopen-settled-0.jpg": {
|
||||
"bytes": 104737,
|
||||
"sha256": "ef52c777acfc3acf9914e3703601d96e9fcc20095870852d02b696a50317c8ad"
|
||||
},
|
||||
"runs/run-1/raw/33-reopen-settled-1.jpg": {
|
||||
"bytes": 4207,
|
||||
"sha256": "147948d69961c42f1feea9449df477b8436250388a1ae070e744ed4709a49bf3"
|
||||
},
|
||||
"runs/run-1/raw/33-reopen-settled-2.jpg": {
|
||||
"bytes": 4000,
|
||||
"sha256": "9110bb88fff054f17fa7f2574a409f27755a72ffe6c14b42097db201a80d2b3f"
|
||||
},
|
||||
"runs/run-1/raw/33-reopen-settled.json": {
|
||||
"bytes": 660,
|
||||
"sha256": "9c254237908dbfa081000f6c44adc013120975388dc8902414caeb1028d1e23b"
|
||||
},
|
||||
"runs/run-1/raw/34-reopened-source-0.jpg": {
|
||||
"bytes": 123459,
|
||||
"sha256": "dc824c52b07a8505c82420c581e87cfc6e0759428bceaf8cf84d7fcc981cfefc"
|
||||
},
|
||||
"runs/run-1/raw/34-reopened-source.json": {
|
||||
"bytes": 939,
|
||||
"sha256": "b0fb2a67daa2eb4beef93ba181d5c6a73a734695a218f57725962d428ffe6bc2"
|
||||
},
|
||||
"runs/run-1/raw/35-reopened-saved-0.jpg": {
|
||||
"bytes": 121731,
|
||||
"sha256": "0019c2970ab87c54760fd1ba922aa4471a598888635e835a3a56cc2ce966ba08"
|
||||
},
|
||||
"runs/run-1/raw/35-reopened-saved.json": {
|
||||
"bytes": 938,
|
||||
"sha256": "e8382c2a32bae5febc5f90c0b09fe9353990c7d8b131d038bb0fc556c84b7ca3"
|
||||
},
|
||||
"runs/run-1/raw/app.stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/app.stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/finished.json": {
|
||||
"bytes": 393,
|
||||
"sha256": "c5d9ee9618bab59c55cafa20ba8568b53efe3246d8d7eb2432fd3e2a95a0da03"
|
||||
},
|
||||
"runs/run-1/raw/generation.json": {
|
||||
"bytes": 1263,
|
||||
"sha256": "e112c1aea1fe70f03a239597a494170b9f292bf57f1decf870c768d76a9eeb30"
|
||||
},
|
||||
"runs/run-1/raw/generation.stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/generation.stdout.log": {
|
||||
"bytes": 106,
|
||||
"sha256": "461a61cfb7c5b139a790db5fba4c9fb98dd4951308c63365ef339a388d7e7b2c"
|
||||
},
|
||||
"runs/run-1/raw/history-bytes.json": {
|
||||
"bytes": 786,
|
||||
"sha256": "a8948f0172aa550a5fb63a66396f771e1ba36f437fe76f5775bb35829f70219c"
|
||||
},
|
||||
"runs/run-1/raw/processes.json": {
|
||||
"bytes": 201,
|
||||
"sha256": "4626c05bb8eb58e8d3acd1dc4157299d2c6c5eb0e0200e259a117a2517414cc0"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/result.json": {
|
||||
"bytes": 2767,
|
||||
"sha256": "779cfef18da513f0aa699975be2487435182bf063ad6c1635d92f557e23eeee4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/started.json": {
|
||||
"bytes": 2365,
|
||||
"sha256": "2f5c987ed6c8114d99751d6dd2cfa38c1b26496aa91043c06da3c0ae0d35ae82"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/baseline/stdout.log": {
|
||||
"bytes": 453,
|
||||
"sha256": "7e3c8332c974e194a96381c3e3b7118fd9f7436405688c068b00186d46449a1b"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/result.json": {
|
||||
"bytes": 2770,
|
||||
"sha256": "a43fc74c00e4a47550aee83d7d5d67ab899d5e148e7bbc91ad3a0083b8eec0d7"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/started.json": {
|
||||
"bytes": 2368,
|
||||
"sha256": "64a60222db7778d75e913869de0d727623caf91aff1f356c5056b9fb98355bf0"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/before-hide/stdout.log": {
|
||||
"bytes": 456,
|
||||
"sha256": "357e77e5bea40d18e1927e4d1195dbd9dffac5374f286125535495a635e00dea"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/result.json": {
|
||||
"bytes": 2760,
|
||||
"sha256": "f428ba297e8a029722ac742e311f4517409e8e38467574bc81c9525ff275dcf0"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3531,
|
||||
"sha256": "e0e79284e24387d257f7d72937ff849da16730f9d06a2ce1c2f43c73b1c29548"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/started.json": {
|
||||
"bytes": 2358,
|
||||
"sha256": "a8031748a9c2374d36f90dcfdc639eec6e4407f7f89a43357252500ac6a5aafa"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hidden/stdout.log": {
|
||||
"bytes": 446,
|
||||
"sha256": "e04ab66dd7715906946086d4507987858a4d960327ceb9f4987c6e3629dadc00"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/result.json": {
|
||||
"bytes": 2763,
|
||||
"sha256": "4b8a0060987ea8fff904b244d5e28792382a223bc7473cf13e0679eae6f7c528"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3531,
|
||||
"sha256": "e0e79284e24387d257f7d72937ff849da16730f9d06a2ce1c2f43c73b1c29548"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/started.json": {
|
||||
"bytes": 2361,
|
||||
"sha256": "a25b92331de234fc267412e5450e0c566f74b811304a6e43a7fd410527181c0a"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-redo/stdout.log": {
|
||||
"bytes": 449,
|
||||
"sha256": "a2f840d03f9813fb0c2f52db73ece05a62e7176da9ec29687e9e8cfe98ea28d1"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/result.json": {
|
||||
"bytes": 2768,
|
||||
"sha256": "d7d71c33427319b1711b2136d1db4aef5718ce1a2790b04eed9c641e9011b1c3"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/started.json": {
|
||||
"bytes": 2366,
|
||||
"sha256": "8be41b35f8fbd9eecda396d30779b9050f91bcf99e2d8b642456f1020e95de8c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/hide-undo/stdout.log": {
|
||||
"bytes": 454,
|
||||
"sha256": "6613b0a845fa99299f8654c0060d169cefaf16c50962a877e70ac6de61bd4e10"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/result.json": {
|
||||
"bytes": 2764,
|
||||
"sha256": "c90801f95afa47a513ed387b97a22a813c87c97f1dcfbe98dd19d7b842ddb9b3"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 4133,
|
||||
"sha256": "d68617ffcf8a5421f432a225f171df0debf84d4b7d925ed4bd3c6e772bf2f138"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/started.json": {
|
||||
"bytes": 2362,
|
||||
"sha256": "563ac107a3d36ef76841a50b46d55706f3735b0d90fcba996b9d8c8330bdb239"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-redo/stdout.log": {
|
||||
"bytes": 506,
|
||||
"sha256": "12d6629adf26066d1bd57c79081e236fd0c2aa6b2f91fd8b31166ac3c498012b"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/result.json": {
|
||||
"bytes": 2768,
|
||||
"sha256": "629bfb2ccda6bbf45b4601bb80fb5cd0d4de3ae7b393c75270af1f9254b2c6b0"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/started.json": {
|
||||
"bytes": 2366,
|
||||
"sha256": "4c0951301a0925ecd6910b4267d718212dc985f5d517d702b84a3408549a641e"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move-undo/stdout.log": {
|
||||
"bytes": 454,
|
||||
"sha256": "3eaa06dfb71a848b41ff52ba07b70b0c67d4ea6585a90410e6a5944d46b766ff"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/result.json": {
|
||||
"bytes": 2759,
|
||||
"sha256": "b6475c4f754aed0a551c0e6a474e8d1d62d479b40d997c9ce23bd95b3a4ff018"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 4133,
|
||||
"sha256": "d68617ffcf8a5421f432a225f171df0debf84d4b7d925ed4bd3c6e772bf2f138"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/started.json": {
|
||||
"bytes": 2357,
|
||||
"sha256": "7f51cd0a4dc037b47ed110546898f0cf3d7495332416607db9c2a739928e3a69"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/move/stdout.log": {
|
||||
"bytes": 501,
|
||||
"sha256": "ec0470cbcaf044dcbd76018b77ce00d6479f9e07d0165a09264465bb50c6996e"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/result.json": {
|
||||
"bytes": 2769,
|
||||
"sha256": "51b2a225dc10fa0d2c5481568ef272fb87bf0bef87a583d363badcf185651590"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3531,
|
||||
"sha256": "e0e79284e24387d257f7d72937ff849da16730f9d06a2ce1c2f43c73b1c29548"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/started.json": {
|
||||
"bytes": 2367,
|
||||
"sha256": "93733f75546fa2fafcef90bb9b968e0ba96b5c521ed1035ef260e0df10a70efa"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/reopened-hidden/stdout.log": {
|
||||
"bytes": 455,
|
||||
"sha256": "1e3fed0f40c77e81b7aa443787f12c132519efaff128cf6cb3e0222575d932fd"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/result.json": {
|
||||
"bytes": 2778,
|
||||
"sha256": "e174aa5452873191caf62f18e01d3db9e13cc3cd4720cee1ea193ee6c67f52e1"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/oa/activities/activity_shared_work.arcadia": {
|
||||
"bytes": 148,
|
||||
"sha256": "bea5df0da731e2277ccb26f7949b43f5393d309d92364cc49746052bf5ff9881"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/oa/behavior/state_machine_machine.arcadia": {
|
||||
"bytes": 2820,
|
||||
"sha256": "268098238fd3ae9da7b29e4583b11947501a64628622a0ee89b792cabf0a67ed"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/oa/participants.arcadia": {
|
||||
"bytes": 133,
|
||||
"sha256": "0f7db5cb2f8ba9642a93182e53f30bd6c593099d1f82ce1f2c503bb617cf917c"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/project.arcadia": {
|
||||
"bytes": 618,
|
||||
"sha256": "f6612ff6fe68e2eea2609a9e5168f6295a4e817792f41755ea7e16c8f7b970c2"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 158,
|
||||
"sha256": "9d88c6450101fb66a6b52da4b0a154c272c168ae76a40ed15ebed4631740f607"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/traceability.arcadia": {
|
||||
"bytes": 534,
|
||||
"sha256": "8346e4a434095e423c1409f4291fff213d354ddb4a6ae09283dba2bc31391368"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "1f0e7af9ebb2889bf43f7ea0e57f7cf47ef1f0ee661056dc71716a1b50bd5145"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/views/diagram_state_other.arcadia": {
|
||||
"bytes": 633,
|
||||
"sha256": "b8014eadf8166f4f9ff6e5f215e7660b2652c27f4f80cc02a2fabfcdee8a4aa4"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/source/views/diagram_state_view.arcadia": {
|
||||
"bytes": 3781,
|
||||
"sha256": "feda42f7f4329b925b7f87289b384f075705616a09a310a99c96e4177f6fee85"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/started.json": {
|
||||
"bytes": 2376,
|
||||
"sha256": "1c0c2799b5d579e5ae07da8dbb589985cca9cfb6f8b880ab5bf0c4b0da306814"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"runs/run-1/raw/snapshots/row-mixed-unchanged/stdout.log": {
|
||||
"bytes": 464,
|
||||
"sha256": "26b9c11ecda895b50cdcdcac984d9792e6149df95db7a5def42207bb73c367ce"
|
||||
},
|
||||
"runs/run-1/raw/started.json": {
|
||||
"bytes": 4006,
|
||||
"sha256": "ae508e19b35b2a399c19099882cf01b8c511fcf6dfa3cebd8578477136d3831d"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Package explicit completed native runs; helpers are pinned data, never executed."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from verify import pin, parsed, need, norm, relative, safe, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs, extra_unchanged=()):
|
||||
native = root / ".local/state-multiselect-native"
|
||||
candidates = [path for path in native.iterdir() if path.is_file() and path.suffix in (".py", ".js")]
|
||||
candidates.extend(sorted((root / ".local/state-group-gates").glob("*.py")))
|
||||
helpers = [(path.suffix, path.read_bytes()) for path in candidates]
|
||||
payload, specs = {}, {}
|
||||
def add(name, data):
|
||||
need(safe(name) and (name not in payload or payload[name] == data), "member collision: " + name)
|
||||
payload[name] = data
|
||||
def tree(folder, prefix):
|
||||
need(folder.is_dir(), "missing directory: " + str(folder))
|
||||
for path in sorted(folder.rglob("*")):
|
||||
need(not path.is_symlink(), "symlink evidence input")
|
||||
if path.is_file():
|
||||
add(prefix + path.relative_to(folder).as_posix(), path.read_bytes())
|
||||
for run in runs:
|
||||
folder = native / run
|
||||
start = parsed((folder / "started.json").read_bytes())
|
||||
end = parsed((folder / "finished.json").read_bytes())
|
||||
need(end["exitCode"] == 0 and all(end[k] is True for k in
|
||||
("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), "unfinished/changed native run: " + run)
|
||||
generation = parsed((folder / "generation.json").read_bytes())
|
||||
history = parsed((folder / "history-bytes.json").read_bytes())
|
||||
tree(folder, "runs/" + run + "/raw/")
|
||||
fixture_relative = relative(generation["command"][2], start["cwd"])
|
||||
need(fixture_relative.startswith(".local/state-multiselect-native/"), "fixture outside native root")
|
||||
tree(root / fixture_relative, "runs/" + run + "/fixture/")
|
||||
spec = {"originalRun": norm(start["cwd"]) + "/.local/state-multiselect-native/" + run,
|
||||
"originalFixture": norm(generation["command"][2]), "helpers": {}, "builds": {},
|
||||
"extraUnchangedSnapshots": list(extra_unchanged)}
|
||||
for key, expected in [("helper", start["helper"]), ("capture", start["capture"]),
|
||||
("snapshotHelper", start["snapshotHelper"]), ("history", history["helper"])]:
|
||||
matches = [(suffix, data) for suffix, data in helpers if pin(data) == expected]
|
||||
need(bool(matches), "exact historical helper unavailable: " + key)
|
||||
suffix, data = matches[0]
|
||||
member = "helpers/" + expected["sha256"] + suffix
|
||||
add(member, data)
|
||||
spec["helpers"][key] = member
|
||||
for kind in ("gui", "checker"):
|
||||
recorded = start[kind]
|
||||
rel = relative(recorded["receipt"], start["cwd"])
|
||||
need(rel.startswith(".local/state-group-gates/") and rel.endswith("/result.json"), "unexpected build receipt location")
|
||||
receipt = root / rel
|
||||
data = receipt.read_bytes()
|
||||
need(pin(data) == recorded["receiptPin"], "historical build receipt changed")
|
||||
prefix = "builds/" + pin(data)["sha256"] + "/"
|
||||
add(prefix + "result.json", data)
|
||||
inputs_data = (receipt.parent / "inputs.json").read_bytes()
|
||||
need(pin(inputs_data) == recorded["inputs"], "historical build input map changed")
|
||||
add(prefix + "inputs.json", inputs_data)
|
||||
spec["builds"][kind] = {"receipt": prefix + "result.json", "inputs": prefix + "inputs.json", "originalRelative": rel}
|
||||
build_helper = parsed(data)["helper"]
|
||||
matching_helpers = [(suffix, value) for suffix, value in helpers if pin(value) == build_helper]
|
||||
need(bool(matching_helpers), "exact historical build helper unavailable")
|
||||
suffix, helper_data = matching_helpers[0]
|
||||
helper_member = "helpers/" + build_helper["sha256"] + suffix
|
||||
add(helper_member, helper_data)
|
||||
spec["builds"][kind]["helper"] = helper_member
|
||||
if kind == "checker":
|
||||
# Read only the pinned historical example, not today's checkout.
|
||||
archive_path = receipt.parent / "inputs.zip"
|
||||
need(pin(archive_path.read_bytes()) == parsed(data)["archive"], "checker input archive pin")
|
||||
source_path = "crates/oa-core/examples/state_multiselect_native.rs"
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
source = archive.read(source_path)
|
||||
need(pin(source) == parsed(inputs_data)[source_path], "historical checker source pin")
|
||||
member = "checker-source/" + pin(source)["sha256"] + ".rs"
|
||||
add(member, source)
|
||||
spec["checkerSource"] = {"inputPath": source_path, "member": member}
|
||||
specs[run] = spec
|
||||
manifest = {"schema": 1, "kind": "multiselect-native", "runs": specs,
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as stream:
|
||||
stream.write((json.dumps(value, indent=2) + "\n").encode())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--runs", nargs="+", required=True)
|
||||
parser.add_argument("--output", type=Path, default=HERE)
|
||||
parser.add_argument("--check-only", action="store_true")
|
||||
parser.add_argument("--extra-unchanged", nargs="*", default=[], help="explicit additional unchanged snapshot labels, same for all runs")
|
||||
args = parser.parse_args()
|
||||
need(len(args.runs) == len(set(args.runs)) and all(re.fullmatch(r"run-[0-9]+", name) for name in args.runs), "explicit unique run-N names required")
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.extra_unchanged)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no archive written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
if (out / name).exists():
|
||||
need((out / name).read_bytes() == data, "different existing package tool")
|
||||
else:
|
||||
with (out / name).open("xb") as stream:
|
||||
stream.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated evidence")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Verify archived native receipts and bytes; do not rerun the GUI or checker."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
BASE = {"started.json", "finished.json", "generation.json", "generation.stdout.log",
|
||||
"generation.stderr.log", "app.stdout.log", "app.stderr.log", "processes.json", "history-bytes.json"}
|
||||
GROUPS = [["baseline", "move-undo", "before-hide", "hide-undo"], ["move", "move-redo"],
|
||||
["hidden", "hide-redo", "reopened-hidden"]]
|
||||
MODES = {name: mode for group, mode in zip(GROUPS, ["unchanged", "moved", "hide"]) for name in group}
|
||||
OUTER = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute() and all(ord(c) >= 32 for c in name)
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def norm(path):
|
||||
return path.replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def relative(path, root):
|
||||
prefix = norm(root) + "/"
|
||||
need(norm(path).startswith(prefix), "recorded path outside owner: " + path)
|
||||
value = norm(path)[len(prefix):]
|
||||
need(safe(value), "unsafe recorded relative path")
|
||||
return value
|
||||
|
||||
|
||||
def ordered(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(t.utcoffset() is not None for t in times) and times == sorted(times)
|
||||
|
||||
|
||||
def zip_inventory(archive, members):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(members), "exact archive members")
|
||||
for info in archive.infolist():
|
||||
need(safe(info.filename) and not info.is_dir() and not stat.S_ISLNK(info.external_attr >> 16), "unsafe archive member")
|
||||
need(info.file_size == members[info.filename]["bytes"], "archive member size")
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-native", "manifest kind")
|
||||
members, runs = manifest["members"], manifest["runs"]
|
||||
need(bool(runs) and all(safe(run) and "/" not in run for run in runs), "run names")
|
||||
need(all(safe(name) for name in members), "safe member names")
|
||||
for name, expected in members.items():
|
||||
need(pin(read(name)) == expected, "member pin: " + name)
|
||||
used = set()
|
||||
def raw(name):
|
||||
need(name in members, "missing member: " + name)
|
||||
used.add(name)
|
||||
return read(name)
|
||||
def obj(name):
|
||||
return parsed(raw(name))
|
||||
def tree(prefix):
|
||||
return {name[len(prefix):]: raw(name) for name in members if name.startswith(prefix)}
|
||||
def pinned_tree(prefix, expected):
|
||||
actual = tree(prefix)
|
||||
need({name: pin(data) for name, data in actual.items()} == expected, "exact pinned tree: " + prefix)
|
||||
return actual
|
||||
for run, spec in runs.items():
|
||||
prefix = "runs/" + run + "/raw/"
|
||||
start, end = obj(prefix + "started.json"), obj(prefix + "finished.json")
|
||||
generation, process = obj(prefix + "generation.json"), obj(prefix + "processes.json")
|
||||
history = obj(prefix + "history-bytes.json")
|
||||
need(end["exitCode"] == 0 and all(end[key] is True for key in
|
||||
("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), run + ": clean unchanged closure")
|
||||
need(ordered(generation["start"], generation["finish"], process["CreationDate"], start["start"], end["finish"], history["verified"]), run + ": process/generation times")
|
||||
need(process["ProcessId"] == start["pid"] and type(start["pid"]) is int and start["pid"] > 0, run + ": process identity")
|
||||
need(norm(process["ExecutablePath"]) == norm(start["gui"]["binary"])
|
||||
and start["command"] == [start["gui"]["binary"]], run + ": GUI command")
|
||||
need(spec["originalRun"] == norm(start["cwd"]) + "/.local/state-multiselect-native/" + run, run + ": original run mapping")
|
||||
need(generation["exitCode"] == 0 and generation["checker"] == start["checker"], run + ": generation checker")
|
||||
need(generation["command"][:2] == [start["checker"]["binary"], "generate"]
|
||||
and len(generation["command"]) == 3 and norm(generation["command"][2]) == spec["originalFixture"], run + ": generation command")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(end[stream] == pin(raw(prefix + "app." + stream + ".log")), run + ": app stream")
|
||||
need(generation[stream] == pin(raw(prefix + "generation." + stream + ".log")), run + ": generation stream")
|
||||
fixture = pinned_tree("runs/" + run + "/fixture/", start["fixture"])
|
||||
need(set(fixture) == {"baseline.json", "manifest.json"} | {"source/" + name for name in
|
||||
obj(prefix + "snapshots/baseline/result.json")["source"]}, run + ": fixture membership")
|
||||
fixture_manifest = parsed(fixture["manifest.json"])
|
||||
need(fixture_manifest["fixture"] == "state-multiselect-native-20260923"
|
||||
and fixture_manifest["source_entry"] == "project.arcadia", run + ": fixture identity")
|
||||
need(parsed(fixture["baseline.json"])["id"] == fixture_manifest["ids"]["project"], run + ": baseline identity")
|
||||
for key, expected in [("helper", start["helper"]), ("capture", start["capture"]),
|
||||
("snapshotHelper", start["snapshotHelper"]), ("history", history["helper"])]:
|
||||
need(pin(raw(spec["helpers"][key])) == expected, run + ": helper " + key)
|
||||
builds = {}
|
||||
for kind, target in [("gui", "arcadia-studio"), ("checker", "state_multiselect_native")]:
|
||||
recorded = start[kind]
|
||||
build = obj(spec["builds"][kind]["receipt"])
|
||||
need(pin(raw(spec["builds"][kind]["receipt"])) == recorded["receiptPin"], run + ": build receipt pin")
|
||||
need(relative(recorded["receipt"], start["cwd"]) == spec["builds"][kind]["originalRelative"], run + ": build path mapping")
|
||||
need(build["exitCode"] == 0 and build["inputsUnchanged"] is True
|
||||
and ordered(build["start"], build["finish"], generation["start"]), run + ": accepted preceding build")
|
||||
need(build["inputs"] == recorded["inputs"] == pin(raw(spec["builds"][kind]["inputs"])), run + ": build input map")
|
||||
need(pin(raw(spec["builds"][kind]["helper"])) == build["helper"], run + ": build helper pin")
|
||||
inputs = obj(spec["builds"][kind]["inputs"])
|
||||
need(len(inputs) == build["inputCount"] and sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": build input count")
|
||||
artifacts = [item for item in build["artifacts"] if item["cargo"]["target"]["name"] == target]
|
||||
need(len(artifacts) == 1 and artifacts[0]["readback"] == recorded["pin"]
|
||||
and artifacts[0]["cargo"]["executable"] == recorded["binary"], run + ": executable relationship")
|
||||
builds[kind] = inputs
|
||||
need(start["gui"]["inputs"] == start["checker"]["inputs"], run + ": matching GUI/checker inputs")
|
||||
source = spec["checkerSource"]
|
||||
need(source["inputPath"] == "crates/oa-core/examples/state_multiselect_native.rs"
|
||||
and pin(raw(source["member"])) == builds["checker"][source["inputPath"]], run + ": archived exact checker source")
|
||||
|
||||
top = {name[len(prefix):] for name in members if name.startswith(prefix) and "/" not in name[len(prefix):]}
|
||||
captures = sorted(name for name in top - BASE if name.endswith(".json"))
|
||||
need(bool(captures), run + ": missing captures")
|
||||
expected_top = set(BASE) | set(captures)
|
||||
captured_window = None
|
||||
for name in captures:
|
||||
capture = obj(prefix + name)
|
||||
need(ordered(start["start"], capture["time"], end["finish"]), run + ": capture lifetime")
|
||||
window = capture["window"]
|
||||
app = window["app"]
|
||||
need(isinstance(app, str) and app.startswith("process:")
|
||||
and norm(app[len("process:"):]) == norm(start["gui"]["binary"]), run + ": captured executable relationship")
|
||||
need(type(window["id"]) is int and window["id"] > 0, run + ": captured window identity")
|
||||
if captured_window is None:
|
||||
captured_window = window["id"]
|
||||
need(window["id"] == captured_window, run + ": inconsistent captured window identity")
|
||||
need(isinstance(capture["images"], list), run + ": capture images")
|
||||
for image in capture["images"]:
|
||||
filename = image["file"]
|
||||
need(safe(filename) and "/" not in filename and filename.startswith(name[:-5] + "-")
|
||||
and filename.endswith((".jpg", ".png")), run + ": capture image mapping")
|
||||
need(type(image["width"]) is int and image["width"] > 0
|
||||
and type(image["height"]) is int and image["height"] > 0, run + ": image dimensions")
|
||||
need(filename not in expected_top, run + ": duplicate image reference")
|
||||
expected_top.add(filename)
|
||||
raw(prefix + filename)
|
||||
need(top == expected_top, run + ": extra/missing top-level run files")
|
||||
|
||||
snapshots = {name[len(prefix + "snapshots/"):].split("/")[0] for name in members if name.startswith(prefix + "snapshots/")}
|
||||
extras = spec["extraUnchangedSnapshots"]
|
||||
need(isinstance(extras, list) and len(extras) == len(set(extras))
|
||||
and all(safe(label) and "/" not in label and label not in MODES for label in extras), run + ": explicit extra unchanged labels")
|
||||
modes = dict(MODES, **{label: "unchanged" for label in extras})
|
||||
need(snapshots == set(modes), run + ": exact nine history plus declared unchanged snapshot labels")
|
||||
states = {}
|
||||
for label, mode in modes.items():
|
||||
folder = prefix + "snapshots/" + label + "/"
|
||||
began, result = obj(folder + "started.json"), obj(folder + "result.json")
|
||||
need(all(result.get(key) == value for key, value in began.items()), label + ": start/result")
|
||||
need(result["exitCode"] == 0 and all(result[key] is True for key in
|
||||
("binaryUnchanged", "workingSourceUnchanged", "baselineUnchanged")), label + ": unchanged successful checker")
|
||||
need(ordered(start["start"], result["start"], result["finish"], end["finish"]), label + ": checker lifetime")
|
||||
need(result["buildReceipt"] == start["checker"]["receiptPin"] and result["binary"] == start["checker"]["pin"]
|
||||
and result["helper"] == start["snapshotHelper"], label + ": checker provenance")
|
||||
need(result["baseline"] == pin(fixture["baseline.json"]), label + ": baseline pin")
|
||||
command = result["command"]
|
||||
need(len(command) == 5 and command[:3] == [start["checker"]["binary"], "check", mode]
|
||||
and norm(command[3]) == spec["originalFixture"] + "/baseline.json"
|
||||
and norm(command[4]) == spec["originalRun"] + "/snapshots/" + label + "/source/project.arcadia", label + ": exact checker command")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(result[stream] == pin(raw(folder + stream + ".log")), label + ": stream pin")
|
||||
need(not raw(folder + "stderr.log"), label + ": checker stderr")
|
||||
report = obj(folder + "stdout.log")
|
||||
need(report["result"] == "PASS" and report["comparison"] == "complete Project equality"
|
||||
and report["mode"] == mode and report["baseline"] == command[3] and report["saved"] == command[4], label + ": positive checker report")
|
||||
delta = report["inferred_common_delta"]
|
||||
if mode == "moved":
|
||||
need(isinstance(delta, dict) and set(delta) == {"x", "y"}
|
||||
and all(type(v) in (int, float) and math.isfinite(v) and abs(v) <= 300 for v in delta.values())
|
||||
and math.hypot(delta["x"], delta["y"]) >= 0.01, label + ": inferred State movement bounds")
|
||||
else:
|
||||
need(delta is None, label + ": unexpected inferred delta")
|
||||
states[label] = pinned_tree(folder + "source/", result["source"])
|
||||
need(len(states[label]) == history["sourceFilesPerSnapshot"] == len(fixture) - 2, label + ": source count")
|
||||
expected = {folder + name for name in ("started.json", "result.json", "stdout.log", "stderr.log")}
|
||||
expected.update(folder + "source/" + name for name in states[label])
|
||||
need({name for name in members if name.startswith(folder)} == expected, label + ": extra snapshot files")
|
||||
need(all(states[label] == states["baseline"] for label in extras), run + ": extra snapshots equal baseline bytes")
|
||||
need(history["status"] == "PASS" and history["equalByteGroups"] == GROUPS, run + ": history groups")
|
||||
for group in GROUPS:
|
||||
need(all(states[label] == states[group[0]] for label in group), run + ": actual history bytes " + str(group))
|
||||
need(all(states[a[0]] != states[b[0]] for i, a in enumerate(GROUPS) for b in GROUPS[i + 1:]), run + ": distinct history states")
|
||||
need(states["baseline"] == {name[7:]: data for name, data in fixture.items() if name.startswith("source/")}, run + ": baseline source exact")
|
||||
changed = {label: sorted(name for name in set(states["baseline"]) | set(states[label])
|
||||
if states["baseline"].get(name) != states[label].get(name)) for label in ("move", "hidden")}
|
||||
need(changed == history["changedFilesFromBaseline"]
|
||||
== {label: ["views/diagram_state_view.arcadia"] for label in ("move", "hidden")}, run + ": changed source files")
|
||||
print(run, "closed exit 0;", len(captures), "captures;", len(modes), "positive snapshots; exact three-group source history")
|
||||
need(used == set(members), "unreferenced/extra package members")
|
||||
return {"runs": len(runs), "members": len(members)}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER, "outer files")
|
||||
for name, expected in files.items():
|
||||
need(pin((root / name).read_bytes()) == expected, "outer pin: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
zip_inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS native evidence byte/receipt consistency:", verify_package(args.package))
|
||||
print("No GUI/checker execution; actions and native acceptance require the separate parent review.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
* -text
|
||||
@@ -0,0 +1,58 @@
|
||||
# State multiselect source identity evidence
|
||||
|
||||
The source receipts bind two historical captured input maps to exact Git trees:
|
||||
|
||||
| Gate | Commit | Inputs | Input-map SHA-256 |
|
||||
| --- | --- | ---: | --- |
|
||||
| desktop-2 | 2a3b9ba822a49ead233c4b5f98412a07c1c1da12 | 733 | 9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a |
|
||||
| lint-2 | 3480b54201a5f361cca0bcd0d45dd6546a7a7bc9 | 735 | 4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf |
|
||||
|
||||
The original receipts are preserved without rewriting. The verifier fixes their
|
||||
exact byte pins, commit IDs, map pins/counts and explicitCompileTimeFixtures.
|
||||
The historical 733-input scope excludes two compile-time fixtures; the final
|
||||
735-input scope explicitly includes both:
|
||||
|
||||
- harness/experiments/edge-attachments-20260922/native-fixture.arcadia
|
||||
- harness/experiments/textual-authoring-20260911/alarm-response.arcadia
|
||||
|
||||
Matching an old Git tree does not correct those historical scope omissions or
|
||||
establish the final source's behavior. A gate applies to its captured input map.
|
||||
Later native build maps must independently match the final 735-input map before
|
||||
using this source identity evidence for those builds.
|
||||
|
||||
From this tool directory, prepare against completed raw captures without writing:
|
||||
|
||||
```
|
||||
python -B prepare.py --raw-gates CHECKOUT/.local/state-group-gates --repo CHECKOUT --check-only
|
||||
```
|
||||
|
||||
After the strict sibling checks package is complete, add --checks CHECKS_PACKAGE,
|
||||
omit --check-only, and provide --output NEW_SOURCE_PACKAGE. Existing output is
|
||||
refused. Preparation preserves the source receipts, tools and README, writes
|
||||
files.json pins, and performs readback. No ZIP is created by the source preparer.
|
||||
|
||||
A completed portable package can be verified without the original checkout:
|
||||
|
||||
```
|
||||
python -B SOURCE_PACKAGE/verify.py
|
||||
```
|
||||
|
||||
By default it reads the sibling ../checks package; override with --checks PATH.
|
||||
It verifies checks outer/member pins, capsule relationships and both selected
|
||||
nested source ZIPs against their exact maps. It checks source receipt identity,
|
||||
accepted unchanged gate flags, scope/counts and all source package pins. Run the
|
||||
sibling checks verifier separately for full process/helper/stream relationships.
|
||||
|
||||
Add --repo CHECKOUT to independently repeat the complete filtered Git inventory
|
||||
and exact blob-byte comparison against both pinned commits already present there.
|
||||
Without --repo, verification checks historical receipt/capsule consistency and
|
||||
explicitly reports that Git comparison was not rerun. No archived helpers or
|
||||
source executables are run; no build, checkout, extraction or network is needed.
|
||||
|
||||
verify-committed-inputs.py remains the generic comparator used to create source
|
||||
receipts. Its --include-fixtures option includes both explicit fixtures; omission
|
||||
keeps the historical scope. It accepts explicit gate/full commit IDs, defaults to
|
||||
read-only comparison, and writes only an optional new --output receipt.
|
||||
|
||||
Receipts and pins establish internal byte consistency, not external authentication,
|
||||
hermetic toolchains, test execution, native usability or complete issue acceptance.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
".gitattributes": {
|
||||
"bytes": 9,
|
||||
"sha256": "8b9c3028d6467ff3be7c793141927841d70f47f52d0590677438f488ca154992"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 3092,
|
||||
"sha256": "b122e41bb9c52b4384e09cd62ee4115a68ff16d5aefee2f92fe632b07276d017"
|
||||
},
|
||||
"prepare.py": {
|
||||
"bytes": 2100,
|
||||
"sha256": "1be2f2a402b493a1c9700044f1119ed8ccddaa02baec2ecd25a185aba6253112"
|
||||
},
|
||||
"source-desktop-2.json": {
|
||||
"bytes": 428,
|
||||
"sha256": "6012bdc52a3843c9cfd5f3c80ec4270a7c0f1e13ef4738b0e3f4ed048ff8d885"
|
||||
},
|
||||
"source-lint-2.json": {
|
||||
"bytes": 583,
|
||||
"sha256": "e0a6deab68683278965643f6f34a1038fcea25c1f242b6f75f5559b7697ec7d6"
|
||||
},
|
||||
"verify-committed-inputs.py": {
|
||||
"bytes": 5712,
|
||||
"sha256": "3cd4f0155cd29c6786f467562d458a380084b2a510eac6454a29388f8f048772"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 10126,
|
||||
"sha256": "e670dc47e89a6ad5cdf7f9adfb6a50797270e475d6b312f159f7aef941ae723b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Prepare pinned source receipts after raw/sibling capture validation; never build."""
|
||||
from pathlib import Path
|
||||
import argparse, json
|
||||
from verify import HERE, SPECS, TOOLS, FILES, need, pin, verify_data, raw_captures, packaged, verify_package
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--raw-gates',type=Path,required=True,help='original raw gates and source-RUN.json receipts')
|
||||
parser.add_argument('--checks',type=Path,help='completed sibling checks package; required when writing')
|
||||
parser.add_argument('--repo',type=Path,help='optionally rerun exact committed Git-blob comparison')
|
||||
parser.add_argument('--output',type=Path,help='new source package directory')
|
||||
parser.add_argument('--check-only',action='store_true')
|
||||
args=parser.parse_args()
|
||||
receipts={run:(args.raw_gates/('source-'+run+'.json')).read_bytes() for run in SPECS}
|
||||
captured=packaged(args.checks) if args.checks else raw_captures(args.raw_gates)
|
||||
summary=verify_data(receipts,captured,args.repo)
|
||||
if args.check_only:
|
||||
print('PASS source check-only; no package written:',summary); return
|
||||
need(args.checks is not None,'written portable source package requires completed --checks package')
|
||||
need(args.output is not None,'explicit new --output required')
|
||||
out=args.output.resolve(); need(not out.exists(),'existing package refused')
|
||||
payload={name:(HERE/name).read_bytes() for name in TOOLS}
|
||||
payload.update({'source-'+run+'.json':data for run,data in receipts.items()})
|
||||
need(set(payload)==FILES,'exact source files')
|
||||
out.mkdir(parents=True)
|
||||
for name,data in payload.items():
|
||||
with (out/name).open('xb') as stream: stream.write(data)
|
||||
with (out/'files.json').open('x',encoding='utf-8') as stream:
|
||||
json.dump({name:pin(data) for name,data in sorted(payload.items())},stream,indent=2)
|
||||
# Git comparison was already performed above when requested; readback is byte-only.
|
||||
verify_package(out,args.checks)
|
||||
print('PASS written source package readback:',out,summary)
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"checked": "2026-09-23T10:37:38.419535+00:00",
|
||||
"commit": "2a3b9ba822a49ead233c4b5f98412a07c1c1da12",
|
||||
"gate": "desktop-2",
|
||||
"inputCount": 733,
|
||||
"inputMap": {
|
||||
"bytes": 122682,
|
||||
"sha256": "9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a"
|
||||
},
|
||||
"comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory",
|
||||
"explicitCompileTimeFixtures": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"checked": "2026-09-23T10:37:40.478617+00:00",
|
||||
"commit": "3480b54201a5f361cca0bcd0d45dd6546a7a7bc9",
|
||||
"gate": "lint-2",
|
||||
"inputCount": 735,
|
||||
"inputMap": {
|
||||
"bytes": 123053,
|
||||
"sha256": "4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf"
|
||||
},
|
||||
"comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory",
|
||||
"explicitCompileTimeFixtures": [
|
||||
"harness/experiments/edge-attachments-20260922/native-fixture.arcadia",
|
||||
"harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Compare captured input maps to exact named Git trees; no builds or checkouts."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
COMPARISON = "Exact committed Git blobs equal every captured input; same complete filtered inventory"
|
||||
EXPLICIT_FIXTURES = {
|
||||
"harness/experiments/edge-attachments-20260922/native-fixture.arcadia",
|
||||
"harness/experiments/textual-authoring-20260911/alarm-response.arcadia",
|
||||
}
|
||||
INCLUDE_FIXTURES = False
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def relevant(path):
|
||||
return (INCLUDE_FIXTURES and path in EXPLICIT_FIXTURES) or path in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml") or path.startswith(
|
||||
("crates/", "build/", ".cargo/", "scripts/", "docs/", "examples/", "harness/fixtures/", "harness/prototypes/"))
|
||||
|
||||
|
||||
def git(repo, *arguments, data=None):
|
||||
environment = dict(os.environ, GIT_NO_LAZY_FETCH="1")
|
||||
return subprocess.run(["git", *arguments], cwd=repo, input=data, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, check=True, env=environment).stdout
|
||||
|
||||
|
||||
def compare_commit(repo, commit, inputs):
|
||||
need(git(repo, "rev-parse", "--verify", commit + "^{commit}").decode().strip() == commit, "named commit unavailable")
|
||||
entries = git(repo, "ls-tree", "-r", "--full-tree", "-z", commit).split(b"\0")
|
||||
blobs = {}
|
||||
for entry in filter(None, entries):
|
||||
header, path_bytes = entry.split(b"\t", 1)
|
||||
path = path_bytes.decode("utf-8")
|
||||
if relevant(path):
|
||||
_mode, kind, oid = header.split()
|
||||
need(kind == b"blob", "relevant non-blob tree entry: " + path)
|
||||
need(path not in blobs, "duplicate tree path")
|
||||
blobs[path] = oid
|
||||
need(set(blobs) == set(inputs), "complete filtered Git inventory differs; missing="
|
||||
+ repr(sorted(set(inputs) - set(blobs))) + "; additional=" + repr(sorted(set(blobs) - set(inputs))))
|
||||
# Object IDs avoid path quoting and commit:path interpretation in batch input.
|
||||
paths = sorted(blobs)
|
||||
data = git(repo, "cat-file", "--batch", data=b"".join(blobs[path] + b"\n" for path in paths))
|
||||
offset, actual = 0, {}
|
||||
for path in paths:
|
||||
end = data.index(b"\n", offset)
|
||||
oid, kind, size_bytes = data[offset:end].split()
|
||||
size = int(size_bytes)
|
||||
need(oid == blobs[path] and kind == b"blob" and size >= 0, "Git batch object relationship")
|
||||
content = data[end + 1:end + 1 + size]
|
||||
need(len(content) == size and data[end + 1 + size:end + 2 + size] == b"\n", "Git batch framing")
|
||||
actual[path] = pin(content)
|
||||
offset = end + 2 + size
|
||||
need(offset == len(data), "trailing Git batch bytes")
|
||||
different = [path for path in paths if actual[path] != inputs[path]]
|
||||
need(not different, "captured bytes differ from named commit: " + repr(different))
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
import datetime
|
||||
global INCLUDE_FIXTURES
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--raw-gates", type=Path, required=True)
|
||||
parser.add_argument("--gate", required=True)
|
||||
parser.add_argument("--commit", required=True, help="exact full commit object ID, not a branch")
|
||||
parser.add_argument("--include-fixtures", action="store_true", help="include both compile-time fixtures for the revised 735-input scope; default preserves historical 733-input scope")
|
||||
parser.add_argument("--output", type=Path, help="optional new receipt file; otherwise read-only check")
|
||||
args = parser.parse_args()
|
||||
INCLUDE_FIXTURES = args.include_fixtures
|
||||
need(safe(args.gate) and "/" not in args.gate, "safe gate name")
|
||||
folder = args.raw_gates / args.gate
|
||||
map_bytes = (folder / "inputs.json").read_bytes()
|
||||
result = parsed((folder / "result.json").read_bytes())
|
||||
inputs = parsed(map_bytes)
|
||||
need(result["exitCode"] == 0 and result["inputsUnchanged"] is True, "accepted unchanged gate required")
|
||||
need(result["inputs"] == pin(map_bytes) and result["inputCount"] == len(inputs), "map relationship")
|
||||
need(all(safe(path) and relevant(path) for path in inputs), "input scope")
|
||||
need(sum(path.startswith("crates/vendor/iced_widget/") for path in inputs) == 71, "vendor inventory")
|
||||
compare_commit(args.repo, args.commit, inputs)
|
||||
report = {"checked": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"commit": args.commit, "gate": args.gate, "inputCount": len(inputs),
|
||||
"inputMap": pin(map_bytes), "comparison": COMPARISON,
|
||||
"explicitCompileTimeFixtures": sorted(EXPLICIT_FIXTURES) if INCLUDE_FIXTURES else []}
|
||||
if args.output:
|
||||
with args.output.open("x", encoding="utf-8") as stream:
|
||||
json.dump(report, stream, indent=2)
|
||||
print("PASS exact Git tree/blobs:", json.dumps(report))
|
||||
print("Source identity only; separately verify capture/archive consistency. No build, checkout or native acceptance claim.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Verify pinned State source receipts against sibling capture capsules; optionally compare Git blobs."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
import io
|
||||
import stat
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
COMPARISON = "Exact committed Git blobs equal every captured input; same complete filtered inventory"
|
||||
EXPLICIT_FIXTURES = {
|
||||
"harness/experiments/edge-attachments-20260922/native-fixture.arcadia",
|
||||
"harness/experiments/textual-authoring-20260911/alarm-response.arcadia",
|
||||
}
|
||||
INCLUDE_FIXTURES = False
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def relevant(path):
|
||||
return (INCLUDE_FIXTURES and path in EXPLICIT_FIXTURES) or path in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml") or path.startswith(
|
||||
("crates/", "build/", ".cargo/", "scripts/", "docs/", "examples/", "harness/fixtures/", "harness/prototypes/"))
|
||||
|
||||
|
||||
def git(repo, *arguments, data=None):
|
||||
environment = dict(os.environ, GIT_NO_LAZY_FETCH="1")
|
||||
return subprocess.run(["git", *arguments], cwd=repo, input=data, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, check=True, env=environment).stdout
|
||||
|
||||
|
||||
def compare_commit(repo, commit, inputs):
|
||||
need(git(repo, "rev-parse", "--verify", commit + "^{commit}").decode().strip() == commit, "named commit unavailable")
|
||||
entries = git(repo, "ls-tree", "-r", "--full-tree", "-z", commit).split(b"\0")
|
||||
blobs = {}
|
||||
for entry in filter(None, entries):
|
||||
header, path_bytes = entry.split(b"\t", 1)
|
||||
path = path_bytes.decode("utf-8")
|
||||
if relevant(path):
|
||||
_mode, kind, oid = header.split()
|
||||
need(kind == b"blob", "relevant non-blob tree entry: " + path)
|
||||
need(path not in blobs, "duplicate tree path")
|
||||
blobs[path] = oid
|
||||
need(set(blobs) == set(inputs), "complete filtered Git inventory differs; missing="
|
||||
+ repr(sorted(set(inputs) - set(blobs))) + "; additional=" + repr(sorted(set(blobs) - set(inputs))))
|
||||
# Object IDs avoid path quoting and commit:path interpretation in batch input.
|
||||
paths = sorted(blobs)
|
||||
data = git(repo, "cat-file", "--batch", data=b"".join(blobs[path] + b"\n" for path in paths))
|
||||
offset, actual = 0, {}
|
||||
for path in paths:
|
||||
end = data.index(b"\n", offset)
|
||||
oid, kind, size_bytes = data[offset:end].split()
|
||||
size = int(size_bytes)
|
||||
need(oid == blobs[path] and kind == b"blob" and size >= 0, "Git batch object relationship")
|
||||
content = data[end + 1:end + 1 + size]
|
||||
need(len(content) == size and data[end + 1 + size:end + 2 + size] == b"\n", "Git batch framing")
|
||||
actual[path] = pin(content)
|
||||
offset = end + 2 + size
|
||||
need(offset == len(data), "trailing Git batch bytes")
|
||||
different = [path for path in paths if actual[path] != inputs[path]]
|
||||
need(not different, "captured bytes differ from named commit: " + repr(different))
|
||||
|
||||
|
||||
|
||||
SPECS = {'desktop-2': {'commit': '2a3b9ba822a49ead233c4b5f98412a07c1c1da12', 'inputCount': 733, 'inputMap': {'bytes': 122682, 'sha256': '9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a'}, 'explicitCompileTimeFixtures': [], 'receiptPin': {'bytes': 428, 'sha256': '6012bdc52a3843c9cfd5f3c80ec4270a7c0f1e13ef4738b0e3f4ed048ff8d885'}}, 'lint-2': {'commit': '3480b54201a5f361cca0bcd0d45dd6546a7a7bc9', 'inputCount': 735, 'inputMap': {'bytes': 123053, 'sha256': '4215d0ca0319282768f1acdf05c2c094ea7432e17ed3a715df73aa05cef851bf'}, 'explicitCompileTimeFixtures': ['harness/experiments/edge-attachments-20260922/native-fixture.arcadia', 'harness/experiments/textual-authoring-20260911/alarm-response.arcadia'], 'receiptPin': {'bytes': 583, 'sha256': 'e0a6deab68683278965643f6f34a1038fcea25c1f242b6f75f5559b7697ec7d6'}}}
|
||||
TOOLS = {".gitattributes", "prepare.py", "verify.py", "verify-committed-inputs.py", "README.md"}
|
||||
FILES = TOOLS | {"source-" + run + ".json" for run in SPECS}
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP inventory")
|
||||
for item in archive.infolist():
|
||||
need(safe(item.filename) and not item.is_dir() and not stat.S_ISLNK(item.external_attr >> 16), "safe ZIP member")
|
||||
need(pin(archive.read(item.filename)) == expected[item.filename], "ZIP member pin: " + item.filename)
|
||||
|
||||
def verify_data(receipts, captured, repo=None):
|
||||
global INCLUDE_FIXTURES
|
||||
need(set(receipts) == set(captured) == set(SPECS), "exact source run inventory")
|
||||
for run, spec in SPECS.items():
|
||||
receipt_bytes = receipts[run]
|
||||
need(pin(receipt_bytes) == spec["receiptPin"], run + ": exact historical receipt bytes")
|
||||
receipt = parsed(receipt_bytes)
|
||||
need(receipt["gate"] == run and receipt["comparison"] == COMPARISON, run + ": receipt identity")
|
||||
need(all(receipt[key] == spec[key] for key in ("commit", "inputCount", "inputMap", "explicitCompileTimeFixtures")), run + ": pinned receipt relationships")
|
||||
map_bytes, result, archive_bytes = captured[run]
|
||||
need(pin(map_bytes) == spec["inputMap"] == result["inputs"], run + ": exact captured input map")
|
||||
need(result["exitCode"] == 0 and result["inputsUnchanged"] is True, run + ": accepted unchanged gate")
|
||||
inputs = parsed(map_bytes)
|
||||
need(len(inputs) == spec["inputCount"] == result["inputCount"], run + ": input counts")
|
||||
INCLUDE_FIXTURES = bool(spec["explicitCompileTimeFixtures"])
|
||||
need(spec["explicitCompileTimeFixtures"] == (sorted(EXPLICIT_FIXTURES) if INCLUDE_FIXTURES else []), run + ": exact fixture scope")
|
||||
need(all(safe(name) and relevant(name) for name in inputs), run + ": input scope")
|
||||
need((EXPLICIT_FIXTURES & set(inputs)) == (EXPLICIT_FIXTURES if INCLUDE_FIXTURES else set()), run + ": explicit fixture membership")
|
||||
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
|
||||
need(pin(archive_bytes) == result["archive"], run + ": source capsule pin")
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
||||
inventory(archive, inputs)
|
||||
if repo is not None:
|
||||
compare_commit(repo, spec["commit"], inputs)
|
||||
print("PASS", run, spec["commit"], len(inputs), "source pins" + (" and exact Git blobs" if repo is not None else " (Git comparison not rerun)"))
|
||||
return {"runs": len(SPECS), "gitCompared": repo is not None}
|
||||
|
||||
def raw_captures(gates):
|
||||
return {run: ((gates/run/"inputs.json").read_bytes(), parsed((gates/run/"result.json").read_bytes()),
|
||||
(gates/run/"inputs.zip").read_bytes()) for run in SPECS}
|
||||
|
||||
def packaged(checks):
|
||||
files = parsed((checks/"files.json").read_bytes())
|
||||
need(set(files) == {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}, "checks outer inventory")
|
||||
for name, expected in files.items():
|
||||
need(pin((checks/name).read_bytes()) == expected, "checks outer pin: " + name)
|
||||
manifest = parsed((checks/"manifest.json").read_bytes())
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "checks kind")
|
||||
logical, members = manifest["logicalFiles"], manifest["members"]
|
||||
need(all(safe(name) and safe(target) for name,target in logical.items()) and set(logical.values()) == set(members), "checks logical inventory")
|
||||
captured = {}
|
||||
with zipfile.ZipFile(checks/"evidence.zip") as archive:
|
||||
inventory(archive, members)
|
||||
for run in SPECS:
|
||||
prefix = "cargo/" + run + "/"
|
||||
result = parsed(archive.read(logical[prefix + "result.json"]))
|
||||
spec = manifest["runs"][run]
|
||||
need(all(result[key] == spec[key] for key in ("command", "head", "exitCode", "inputCount")), run + ": checks relationships")
|
||||
for name in ("inputs.json", "inputs.zip"):
|
||||
member = logical[prefix+name]
|
||||
need(member == "capsules/" + members[member]["sha256"] + Path(name).suffix, run + ": source capsule mapping")
|
||||
captured[run] = (archive.read(logical[prefix+"inputs.json"]), result, archive.read(logical[prefix+"inputs.zip"]))
|
||||
return captured
|
||||
|
||||
def verify_package(package, checks, repo=None):
|
||||
files = parsed((package/"files.json").read_bytes())
|
||||
need(set(files) == FILES, "source outer inventory")
|
||||
need({p.name for p in package.iterdir()} == FILES | {"files.json"}, "source exact package inventory")
|
||||
for name, expected in files.items():
|
||||
need((package/name).is_file() and not (package/name).is_symlink() and pin((package/name).read_bytes()) == expected, "source outer pin: " + name)
|
||||
return verify_data({run:(package/("source-"+run+".json")).read_bytes() for run in SPECS}, packaged(checks), repo)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=HERE)
|
||||
parser.add_argument("--checks", type=Path, help="sibling checks package; default PACKAGE/../checks")
|
||||
parser.add_argument("--repo", type=Path, help="optional repository containing both exact commits, for renewed Git-blob comparison")
|
||||
args = parser.parse_args()
|
||||
print("PASS source evidence:", verify_package(args.package, args.checks or args.package.parent/"checks", args.repo))
|
||||
print("No builds, checkouts, helper execution or native acceptance claim. Run the checks verifier separately for full gate relationships.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
# Process multi-selection evidence — 2026-09-23
|
||||
|
||||
This slice implements exact multi-selection and topology-preserving order for OA Process and SA Functional Chain. Desktop, independent-source and corrected Windows native checks pass within the scopes below. This is branch-specific evidence, not a release, combined integration or completion of multi-selection issue #29. Combined release publication remains with the integration owner.
|
||||
|
||||
The [adapter design](multiselect-process-adapter-design-20260923.md) owns the selection, planning and lifecycle contract. The [native evaluator design](multiselect-process-native-design-20260923.md) owns the independent fixture/oracle and discriminating gesture. Detailed evidence is separated into [checks](multiselect-process-evidence-20260923/checks/), [source identity](multiselect-process-evidence-20260923/source/), [render feedback](multiselect-process-evidence-20260923/feedback/), [native trials](multiselect-process-evidence-20260923/native/), [reviews](multiselect-process-evidence-20260923/reviews/) and [preparation failure](multiselect-process-evidence-20260923/preparation-run-2/).
|
||||
|
||||
## Implementation and integration seams
|
||||
|
||||
Selection is transient and owner-scoped, preserving local occurrence/link and qualified path/preview-link identities. Primary controls Properties; qualified subjects stay read-only. Shift toggles, stationary member clicks collapse, and contextual membership/inspection is explicit. Feedback preserves canvas bounds. Visible reconnect handles win hit testing; groups cannot fall through to singleton mutations. Mixed/qualified movement refuses the whole set unchanged.
|
||||
|
||||
Local occurrences in one column move in source order into Before/After slots, preserving other-column indices and all ranks. `[A,U,B,V,C]`, C then A After B, becomes `[B,U,A,V,C]`. Singleton order shares the planner. One `SetSteps` transaction records a change; invalid/no-op results create no history. Facts are captured once per press; plans cache destinations without pointer-time Project clones/Graph rebuilds. Feedback shows slot/count and source-order numbers.
|
||||
|
||||
Integration uses `app::multiselect`, Process App handlers, topology `direct_selection.rs`/`group_reorder.rs`, existing menus/transactions and shared Viewport modifier retention. Mounted tests split press/motion/release across App rebuilds and check snapshots, reconnect precedence, intermediate source/history and final order/Undo/Redo. Scenario has a shared-modifier regression, not multi-selection implementation.
|
||||
|
||||
## Exact-subject failure and correction
|
||||
|
||||
Native run 1 on `b153451...` exposed a blocking identity defect: Back restored caller/camera but showed editable outer-reference Properties alongside qualified-leaf highlighting (frame 26). Baseline/mixed/qualified refusal saves passed three independent complete-Project `unchanged` checks, which could not detect UI identity. The run stopped before order/history/reopen acceptance. It exited 0 with empty logs and unchanged pinned binaries/fixture; it has no successful history-bytes receipt.
|
||||
|
||||
The correction remembers the requested qualified address, including nonprimary menu invocation. Back selects its validated leaf and refreshes read-only inspection. Select/EditProperties clears obsolete context after admission, including same-ID defining-leaf navigation; ordinary Open saves a local frame. Tests assert path, subject, draft, camera, source/history and ordinary return. Back restores the invoked singleton. Review 2 found no concrete regression; protective-test suggestions remain notes rather than claimed coverage.
|
||||
|
||||
Run 3 confirms current/nonprimary Open/Back, exact read-only caller subject, and Explorer navigation to outer reference and same-ID leaf with editable local Properties. [Parent observations](multiselect-process-evidence-20260923/reviews/run-3-observations.md) and the [independent native review](multiselect-process-evidence-20260923/reviews/process-native-review-3.md) establish these UI results separately from saved-Project checks.
|
||||
|
||||
## Source and test evidence
|
||||
|
||||
Gates retain commands/logs, artifact/helper hashes and unchanged 742-file maps/archives, including 71 vendored files and compile-time fixtures. Source receipts compare bytes with named Git blobs; captured HEAD alone cannot identify uncommitted source.
|
||||
|
||||
| Source | Matching evidence and result |
|
||||
| --- | --- |
|
||||
| Historical `1711bff650dabb1df3a9bc37f19060d5db297feb`, map `d5acfcf6...` | `full-desktop-1`: **1,317 passed, 6 ignored**; `format-1` passed. Historical implementation, before final navigation corrections. |
|
||||
| `b15345123a7feaf630ef24d45f591c76c6715778`, map `7f5b3de4...` | `desktop-3`: **147 passed**; strict `lint-2`, `format-2`, `feedback-1` passed; `core-2`: **5 passed**. Matching checker/GUI builds passed. Native run 1 failed as recorded above. |
|
||||
| Production correction `ff712b24190342363d0275467e96f181872da6ad`, map `1ba4ed41...` | `desktop-6`: **150 passed**; `full-desktop-2`: **1,320 passed, 0 failed, 6 ignored**. Strict core/desktop all-target `lint-3` and `format-3` passed. |
|
||||
| Evaluator clarification `b743a10d51032dc1655b94fed5e49fbbbcee83f4`, map `f3fd180a...` | `core-3`: **5 passed**; example-scoped `lint-4`, `format-4`, `windows-build-2`, `native-build-2` passed. Production desktop/core library unchanged from `ff712...`; this is not a second full-desktop run. Matching GUI/checker used for native run 3. |
|
||||
|
||||
Failures remain preserved: `desktop-1` 94 passed/8 failed; `desktop-2` 104/1; `lint-1` failed before the payload-size correction. `desktop-4` rejected invalid Cargo filter placement before tests. `desktop-5` ran 147/1 because the new fixture toggled already-expanded references closed, yielding an empty initial group; corrected setup/navigation pass in `desktop-6`. Preparation run 2 failed while generating its fixture with missing toolchain DLL environment (exit 3221225781); **no GUI trial occurred**. Run 3 corrected the environment and used a fresh directory.
|
||||
|
||||
## Independent semantics, rendering and native history
|
||||
|
||||
The core checker compares complete Projects against immutable baseline/literal expected OA order without invoking the desktop planner. SA/reuse/trace are preservation sentinels. Primary A would be ambiguous: singleton-A After B equals the group result. The clarified evaluator adds unchanged-C rejection and a discriminating trial: membership `[C,A]`, Inspect C, drag C After B. Group output is `[B,U,A,V,C]`; singleton primary/pressed C leaves the original order.
|
||||
|
||||
The native observer performed that sequence in run 3: click C, context Add A, Inspect C, verify two selected/primary C, then drag C After B. The visible column became B,A,C with U,V unchanged. One Ctrl+Z restored baseline; one Ctrl+Y restored reordered output; an in-process reopen retained it. The trial also observed mixed-set refusal and selected-anchor no-op. Run 3 closed normally at `2026-09-23T13:43:54Z`, exit 0, empty stdout/stderr and unchanged pinned checker/GUI/fixture.
|
||||
|
||||
**Routing remains visibly imperfect.** In the [reordered frame](multiselect-process-evidence-20260923/reviews/images/run-3/39-group-reordered-0.jpg), A→U and B→V share portions of their orthogonal paths, obscuring endpoint connectivity. The independent review confirms this defect; unchanged link records do not establish readable routing. It is related to the intersection concerns in issue #32, whose original scope is Auto Arrange; this trial exercises Process order instead. No source-level attribution or routing fix is claimed here.
|
||||
|
||||
All eight saved snapshots passed the independent complete-Project checker: baseline, qualified-navigation, mixed-refusal, anchor-noop, reordered, undo, redo and reopened. The history-byte gate passed for all ten source files: `baseline == undo == qualified-navigation == mixed-refusal == anchor-noop` and `reordered == redo == reopened`, also checking immutable fixture equality for the baseline group. Only `oa/behavior/process_oa_process.arcadia` differs between groups. This lexical proof complements observed one-step history; equality alone cannot prove the user actions.
|
||||
|
||||
Actual headless WGPU App feedback covers OA/SA at 1440×900 and 100% zoom: stable source/selection/camera, legible insertion slot/count and source-order numbers. Raw RGBA, lossless PNG readback and parent inspection are preserved. These split-batch renderings establish actual production pixels, not a paused native drag or other DPI/window sizes. Native membership used context actions; held Shift-click is mounted-test evidence. Reopen is in-process, not cold restart. Aggregate removal, Allocation/Scenario adapters, native SA reorder, Linux/DPI and full accessibility remain outside this slice's acceptance.
|
||||
|
||||
## Reproduction and package verification
|
||||
|
||||
From the named source checkout, use the documented project toolchain. Captures used Windows GNU LLVM, Cargo/rustc 1.98.1, one build job and incremental compilation disabled. Multiple test filters belong **after** `--`:
|
||||
|
||||
```text
|
||||
cargo test -p oa-core --example process_multiselect_native -- --test-threads=1
|
||||
cargo test -p oa-desktop -- topology process_ canvas_workspace repeated_process menu --test-threads=1
|
||||
cargo test -p oa-desktop -- --test-threads=1
|
||||
cargo clippy -p oa-core -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build -p oa-core --example process_multiselect_native
|
||||
cargo build -p oa-desktop
|
||||
```
|
||||
|
||||
Portable evidence checks use Python; feedback PNG verification also needs Pillow. They do not build or execute archived sources. From repository root:
|
||||
|
||||
```text
|
||||
python -B harness/multiselect-process-evidence-20260923/checks/verify.py
|
||||
python -B harness/multiselect-process-evidence-20260923/source/verify.py --repo .
|
||||
python -B harness/multiselect-process-evidence-20260923/feedback/verify.py --checks harness/multiselect-process-evidence-20260923/checks
|
||||
python -B harness/multiselect-process-evidence-20260923/native/verify.py
|
||||
python -B harness/multiselect-process-evidence-20260923/preparation-run-2/verify.py
|
||||
```
|
||||
|
||||
The source check requires named commits locally; omit `--repo .` for offline receipt consistency. Before publication, all five verifiers and review pins passed against a fresh export of the exact 47 staged evidence files; exported bytes matched the working files. The first staging attempt omitted the two ignored empty preparation logs; inventory validation caught that omission before verification or publication, and they were explicitly staged. Package READMEs cover native reproduction with fresh fixtures and matching GUI/checker receipts. Preserve failures and immutable baselines. Package consistency, semantic equality, lexical history, rendered feedback and observed interaction remain distinct claims.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Process selection and column-order adapter
|
||||
|
||||
**Accepted implementation contract; runtime/native acceptance pending.** This document describes the current unpublished Process working source above `bed7f825fd00ec32299032a664a5f5d753aad86d`, implementing the Process portion of the [multiple-selection contract](multiselect-contract-20260923.md). It does not establish a verified release or completion of issue #29. Gitea holds live ownership, priorities and gate results. The initial read-only investigation inspected State follow-up source above `7e348d967d1787c052a145b7448636613a6a7054`; its alternatives, critiques and experiment evidence are retained as history below.
|
||||
|
||||
## Accepted implemented contract
|
||||
|
||||
The surface is `Surface::Process(owner)` for an OA Process or SA FunctionalChain. Selection is a transient insertion-ordered exact target set with one primary Inspector target:
|
||||
|
||||
| Target | Identity and admitted use |
|
||||
| --- | --- |
|
||||
| `ProcessOccurrence(OccurrenceEndpoint { references: [], occurrence })` | One local semantic contribution card. Action, Exchange, Control and SequenceReference cards participate in order planning; these are not saved placements or reusable work definitions. |
|
||||
| `ProcessOccurrence` with nonempty references | One visible qualified tile with its complete reuse path. Select/inspect; no caller-side reorder or deletion. |
|
||||
| `ProcessLink(id)` | One local rendered SequenceLink or ContextualLink. Select/inspect; not a member of a contribution reorder. Existing singleton local SequenceLink reconnect remains separate. |
|
||||
| `ProcessPreviewLink { relation, source, target }` | One actually painted qualified preview segment with exact endpoint paths. Select/inspect; read-only through the caller. |
|
||||
|
||||
Different caller paths to the same defining occurrence remain different targets. A qualified primary identifies the **actual occurrence or relation subject**, not the outer reference's properties. Its caller owner/path remains captured; properties are read-only until explicit **Open** navigates to the defining process. Hidden/collapsed targets are pruned exactly without rebinding to another reuse path or outer card. Open/Back and owner changes establish a new surface lifetime. Legacy `OrderedPath` arrows have no relation identity and do not become editable links. Navigation emphasis alone is not mutation authority.
|
||||
|
||||
Shift-click toggles the exact hit. Plain press on an unselected target replaces the set. A selected-member press freezes membership and primary until stationary release versus drag; stationary release collapses to that member. Right-click captures a target without replacing the selection; contextual Add/Remove changes membership explicitly. Counts and distinct primary/membership paint describe the set. Ordinary local property edits remain primary-only.
|
||||
|
||||
### One admitted column-slot plan
|
||||
|
||||
`behavior/topology/group_reorder.rs::Prepared::new(&Project, owner, &[Target])` captures immutable steps, adjacency, integer ranks and selected IDs. It rejects empty/duplicate selections, non-occurrence members, qualified paths, wrong-owner/missing contributions, cross-column roots, or any missing/foreign/duplicate **unselected** step. A local SequenceLink whose endpoint is omitted from owner steps also refuses before any selected-anchor no-op. Such incomplete source can be core-valid; that does not authorize silently ignoring its topology when editing order.
|
||||
|
||||
The renderer and planner share `topology.rs::column_ranks`, extracted from the established rank algorithm. The renderer filters direct links to displayed endpoints and skips unavailable legacy path pairs, retaining displayed nodes without panicking. This tolerant display behavior does not weaken planner admission. Parallel SequenceLinks remain distinct; longest-path and deterministic cycle-blocked integer ranks retain their existing meaning.
|
||||
|
||||
`Prepared::plan(Destination::Before(id) | Destination::After(id))` requires an anchor in the selected column. After full admission, a selected anchor returns `Plan::Noop`, even for a noncontiguous set. For an unselected anchor: extract the column's order and original step indices; remove selected IDs and form their block in original source order; insert before/after the remaining anchor; write the column permutation back to those same indices. Other-column IDs retain exact indices, and unselected IDs within this column retain relative order. Identical steps return Noop. Otherwise the complete ID-to-rank map is recomputed and must remain exact before one existing `Command::SetSteps` is returned.
|
||||
|
||||
Example: `[a,u,b,v,c]`, column `[a,b,c]`, selection C then A, After B yields `[b,u,a,v,c]`. U/V retain indices 1/3, and A/C retain source order. Selected `{b,c}` After A is unchanged. There is no XY schema, control-flow rewiring, reusable-definition edit or unrelated-record change. Legacy exchange-only paths have successive ranks; cycle-blocked nodes receive distinct fallback ranks, so multi-member sets spanning those columns refuse. A cycle elsewhere does not prevent movement of an acyclic peer group.
|
||||
|
||||
Semantic facts are prepared once per press and candidate plans are cached by anchor/side. Motion within a slot does not clone the Project, rebuild a Graph or run whole-model validation. App release handling re-admits the live exact selection and uses the same planner before one source-validated transaction. Source failure preserves model/history. Successful commit restores the exact primary without an incidental Fit/reveal. Noop emits no command/history entry.
|
||||
|
||||
**Singleton migration is implemented:** `topology/direct.rs::reorder` delegates a singleton typed target to this planner and returns Noop/Change. Moving B before A in `[a,u,b,v,c]` now writes `[b,u,a,v,c]`, replacing the old global splice `[b,a,u,v,c]`. This deliberately changes invisible cross-column source order to preserve unrelated indices consistently for singleton and group operations.
|
||||
|
||||
### Pointer feedback and lifetime
|
||||
|
||||
`behavior/topology/direct_selection.rs` owns an order-specific press; it does not use the saved-placement XY constructor. The press captures exact selection snapshot, target, screen position, bounds, pan and scale. Its 3-screen-pixel threshold is irreversible: out-and-back movement, refused drops and no-op destinations retain the set rather than becoming stationary clicks. Native pointer order is preserved; an unavailable/outside release cannot reuse an earlier inside point.
|
||||
|
||||
Each local card's upper half means Before and its lower half means After. An insertion indicator, refusal/unchanged caption and source-order member numbers describe the cached operation. Cards are not freely translated or reflowed during the drag. Before-first and after-last are reachable through the first/last unselected card. Actual reachability/readability at native zoom/window sizes remains an acceptance gate.
|
||||
|
||||
Snapshot/document/revision/surface/selection-generation changes, tool/editability changes, bounds/pan/scale changes, resize/unfocus, wheel, cursor exit, right/middle press and Escape cancel the captured gesture. Escape cancels a live press before selection clearing. Passive modifiers support Shift across editor focus. Stable count/Inspector layout and retained camera geometry are required; selection and qualified inspection do not authorize incidental source application or camera movement.
|
||||
|
||||
Shift/context hits on painted reconnect rings resolve the exact link. A mixed set cannot reconnect only its primary, and Connect with several selected targets is refused. Existing singleton tools retain their separate ownership. App envelope validation precedes incidental selection/camera/allocation state changes; dirty source/property/forms or stale packets cannot partially act.
|
||||
|
||||
### Current removal boundary and remaining adapters
|
||||
|
||||
Process has no saved placements: Hide explicitly refuses instead of deleting contributions or adding hidden-card persistence. Group Delete and qualified-target Delete explicitly refuse. Existing admissible local singleton deletion remains separate; no depicted group falls through to it. Aggregate local-occurrence removal still requires one captured impact review and atomic union deletion, including ContextualLink cleanup and explicit-to-legacy meaning changes described in the historical critique below.
|
||||
|
||||
This slice implements exact Process selection and column reorder with whole-set refusals. Aggregate removal, Allocation assignment/Arrange-only groups and Scenario order adapters remain required by the [overall contract](multiselect-contract-20260923.md), outside this Process acceptance claim.
|
||||
|
||||
### Implementation and verification boundaries
|
||||
|
||||
| Source | Responsibility |
|
||||
| --- | --- |
|
||||
| `app/multiselect.rs`, `app/process_multiselect.rs` | Scope/envelope admission, exact reconciliation, actual-subject primary and transaction dispatch. |
|
||||
| `app/process_canvas.rs`, `app.rs` | Legacy command guards, navigation/tool lifetime and existing Process command integration. |
|
||||
| `behavior/topology/group_reorder.rs`, `topology.rs::column_ranks` | Immutable admitted facts, column-slot planning and shared production ranks. |
|
||||
| `behavior/topology/direct_selection.rs`, `direct.rs` | Exact hits/modifiers, order gesture, feedback, singleton migration and separate tool ownership. |
|
||||
| `behavior/topology.rs`, `topology/view.rs`, `topology/usages.rs` | Exact paint/visibility, stable controls/camera and incomplete-topology rendering. |
|
||||
| `model_menu.rs`, Inspector/behavior property boundaries | Captured membership menus, qualified read-only subject and explicit Open. |
|
||||
|
||||
Planner tests define independent literal OA/SA orders, whole-Project preservation, source roundtrip/Undo/Redo, all four card kinds/differing heights, reverse noncontiguous selection, end gaps/noops, typed refusal, five-node cycle-plus-peer ranks, parallel links and malformed/unlisted steps. App/input tests cover qualified primary, reconciliation, stale messages and gesture boundaries. Their definitions and source inspection alone do not establish current full-suite or native acceptance.
|
||||
|
||||
The [native evaluator design](multiselect-process-native-design-20260923.md) and [core example](../crates/oa-core/examples/process_multiselect_native.rs) use an immutable deterministic OA/SA fixture, literal expected `[b,u,a,v,c]` and complete Project comparison. Global splice, reversed-block, primary-only and unrelated-record corruptions are falsifiers. Native Shift/focus, menus, cancellation, insertion reachability, stable layout/camera, real save/reopen and Undo/Redo require matching executable evidence. No acceptance receipt is asserted here.
|
||||
|
||||
## Historical investigation and design alternatives
|
||||
|
||||
The following sections retain the original reasoning, corrected by the accepted contract above. Statements of proposed work describe that earlier investigation, not the current implementation status.
|
||||
|
||||
### Representation facts recorded initially
|
||||
|
||||
`diagram_usage.rs` already defines `Surface::Process(owner)`, exact `ProcessOccurrence(OccurrenceEndpoint { references, occurrence })`, local `ProcessLink`, and qualified `ProcessPreviewLink` targets. Local cards are semantic occurrences in owner steps; they reference reusable definitions and are not saved diagram placements. Different reference paths to the same defining occurrence are different selected targets. Legacy derived `OrderedPath` arrows lack relation identities: do not fabricate editable IDs or substitute endpoints.
|
||||
|
||||
`topology/usages.rs::Graph::usage_bounds` provides exact displayed-target visibility, including bounded qualified previews. Hidden preview tiles do not fall back to their outer reference. Its approximate reveal rectangles are not adequate edge hit tests. Qualified preview content stays read-only until its definition is explicitly opened.
|
||||
|
||||
### Movement candidates considered
|
||||
|
||||
| Candidate | Assessment |
|
||||
| --- | --- |
|
||||
| Persist arbitrary XY | Conflicts with derived process topology and needs new schema; reject for this adapter. |
|
||||
| Sequential singleton reorder | Produces click-order-dependent layouts and does not define a simultaneous operation; reject. |
|
||||
| Stable block within one original column | Accepted with the column-slot correction: preserves topology and uses one existing `SetSteps` command. |
|
||||
| Rewire links during a cross-column drag | Alters process execution meaning; requires a separate capability and explicit interaction. |
|
||||
|
||||
The former `topology/direct.rs::reorder` verified local contributions and equal graph X-columns, globally spliced steps, then validated `SetSteps`. `Graph::expanded_qualified` derived ranks from sequence links and legacy order, with steps supplying vertical order; cyclic fallback could depend on enumeration order. The shared integer rank helper and singleton migration supersede that implementation.
|
||||
|
||||
The proposed pure planner admits the whole nonempty set only if every target is a visible local occurrence with an empty reference path and belongs to this owner. Selected IDs and the destination must share one original column. Build the block in original steps order, then use the column-slot permutation below: preserve other-column indices and unselected order within this column. Object-anchored drops on selected items are no-op; a raw gap is a separate API. Check the complete rank map remains unchanged, especially cycle fallback. Distinguish no-op from a command so it creates no source/history entry. Preview and commit must consume this same planned order; do not freely translate cards.
|
||||
|
||||
Before-card only could reuse earlier behavior but made moving to the end awkward. Before/After and all four local contribution kinds were subsequently accepted and implemented; actual interaction acceptance remains pending.
|
||||
|
||||
### Original Hide and removal proposal
|
||||
|
||||
Process has no saved placements and therefore no existing Hide operation. Explain that limitation; do not silently substitute semantic deletion or add persisted hidden cards without a separate design decision.
|
||||
|
||||
Homogeneous local-occurrence removal should present one aggregate impact review and apply one `Batch(Delete(...))`. Core deferred deletion already supports union cleanup. Preserve referenced work, exchanges, reused process definitions and opposite-layer counterparts. Initially refuse qualified or mixed sets in full; local-link-only deletion can be a separate extension. Never invoke singleton deletion once per member. Capture document, revision, surface and selection generation through confirmation, revalidate the whole set, and preserve pending property/source/forms.
|
||||
|
||||
### Initial implementation plan and falsifiers
|
||||
|
||||
The initial plan called for Process dispatch, exact primary paths, right-click membership and whole-set guards rather than assuming every context is a saved diagram. These are implemented across the source boundaries above; the pure planner and input adapter were factored into dedicated modules rather than expanding `direct.rs`. Aggregate confirmation at `app/diagram_removal.rs` remains deferred.
|
||||
|
||||
The saved-view group gesture requires placements and an XY anchor. A Process order-specific gesture was chosen, preserving its lifecycle principles without widening that constructor into an inappropriate generic geometry abstraction.
|
||||
|
||||
First useful outcome: exact local/qualified/link selection with whole-set refusals, plus one atomic same-column local-occurrence reorder and one Undo/Redo. Aggregate removal remains separate acceptance if omitted from that outcome.
|
||||
|
||||
Tests must independently assert source-order blocks from noncontiguous reverse-click selections; unchanged other-column indices and unselected order within the affected column, relations and reusable definitions; identical referenced work represented by distinct occurrences; distinct qualified paths and collapse visibility; full refusal of cross-column/mixed/stale sets; no-op history; ordered pointer/focus/Shift/cancellation/camera behavior; no primary-only reconnect bypass; OA and SA, legacy exchange order, explicit branches and cyclic fallback. Native slot feedback, stable canvas/camera, source save/reopen and one-step history are required beyond compilation.
|
||||
|
||||
### Independent critiques that changed the design
|
||||
|
||||
A fresh read-only Astra review found a concrete defect in global block splicing. Given steps `[a,u,b,v,c]`, one displayed column `[a,b,c]`, and selected `{b,c}`, dropping after `a` is visually a no-op. The proposed global splice produces `[a,b,c,u,v]`, changing source/history without a visible change. Equivalent before/after positions in the same displayed gap must have one interpretation.
|
||||
|
||||
The correction was a **column-slot permutation**, now implemented as specified above. It deliberately replaces the earlier all-unselected-global-order condition with exact other-column indices and within-column unselected order. The independent pure experiment preceded UI implementation; selected-anchor no-op remained distinct from raw-gap normalization.
|
||||
|
||||
Scope correction: legacy ordered-path nodes have successive ranks, and cycle-blocked nodes receive distinct fallback ranks (`topology.rs` rank computation). A multiple selection of those nodes cannot satisfy same-column admission. Test refusal and unchanged execution meaning rather than claiming useful reordering of those sets. A graph containing a cycle can still admit a separate acyclic peer group.
|
||||
|
||||
Removal has an additional semantic consequence: `behavior.rs` derives explicit-versus-legacy interpretation from remaining occurrence kinds and SequenceLinks. Deleting action/control from `[action,exchange1,control,exchange2]` without links leaves an exchange-only sequence with implicit ordered-linear meaning. Aggregate review must expose this transition or initially refuse it. Include caller-owned ContextualLinks targeting removed occurrences in the impact union (`references.rs` cleanup), not just local links. See the presentation-versus-legacy distinction in [language documentation](../docs/language.rst).
|
||||
|
||||
The original production-evaluator plan became the Rust/App tests and native fixture above. Deletion-induced mode switching still needs a separate removal evaluator. A later source critic found an incomplete-order panic (`a→u` with U omitted from owner steps), prompting complete planner admission and graceful renderer omission. Bounded test results must remain tied to the source and receipts actually exercised.
|
||||
|
||||
## Bounded permutation experiment
|
||||
|
||||
[Executable exploratory model and results](experiments/process-column-slots-20260923/README.md) isolate the order transformation from production architecture. An independent Astra investigator implemented it; the parent read both scripts and reran them successfully. The experiment covers 214,990 column cases and 861,096 plans against a four-node graph rank model, with no invariant failures. Its modeled ranks and sentinel serialization do not execute the Rust layout, source writer or history.
|
||||
|
||||
An object-anchored Before/After interface can keep selected-target no-op without losing changed destinations: all remainder gaps were reachable using unselected anchors in 2,036 bounded subsets. Raw-gap normalization is different: in `[a,b,c,d]`, selecting `{b,d}` and normalizing Before `d` gives `[a,c,b,d]`; selected-target no-op keeps the original, while After unselected `c` reaches the changed order. This supports the simpler selected-target no-op interaction. Block contiguity applies to an admitted planned move, not to that no-op. Actual pointer feedback, production graph admission, serialization and history remain unverified.
|
||||
|
||||
An independent critic matched the experiment's rank algorithm to Rust and found no counterexample under the stated valid-input assumptions. The review prompted an explicit five-node changed group beside a cycle; four-node enumeration could only cover singleton movement or unchanged whole-peer blocks there. It also identified the singleton global-splice compatibility choice. Retaining that splice was rejected as two source transformation rules for the same visible operation; the implemented migration above intentionally preserves unrelated column indices instead. Native anchor reachability and selected-target guard execution remain separate acceptance checks. These exploratory results support the design but do not replace runtime/native verification.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Receipt-bound files must retain exact bytes through Git checkout.
|
||||
** -text whitespace=cr-at-eol
|
||||
@@ -0,0 +1,58 @@
|
||||
# Process multiselect closed gate evidence
|
||||
|
||||
These tools adapt the State multiselect checks packager/verifier. They copy
|
||||
explicitly named **closed** runs from `.local/process-group-gates`, preserve
|
||||
their exact seven raw files, retain nonzero exit codes and both streams, and
|
||||
verify all source-map/archive bytes before writing and again after ZIP readback.
|
||||
They never run Cargo, execute archived code, extract inputs, or publish evidence.
|
||||
|
||||
Check-only example:
|
||||
|
||||
```text
|
||||
python -B prepare.py --root CHECKOUT --runs core-1 desktop-1 desktop-2 lint-1 full-desktop-1 lint-2 format-2 --check-only
|
||||
```
|
||||
|
||||
After all desired runs close, supply a fresh, nonexistent output directory:
|
||||
|
||||
```text
|
||||
python -B prepare.py --root CHECKOUT --runs core-1 desktop-1 desktop-2 lint-1 full-desktop-1 desktop-3 feedback-1 lint-2 format-2 native-build-1 windows-build-1 --output FRESH_DIRECTORY
|
||||
python -B verify.py FRESH_DIRECTORY
|
||||
```
|
||||
|
||||
Run names in the second example are a proposed final inventory, not an existence,
|
||||
completion, pass, or source-equivalence claim. The parent chooses the final list.
|
||||
Preserve failed desktop-1, desktop-2, and lint-1 alongside subsequent receipts;
|
||||
their successful packaging means only byte/receipt consistency. No run is inferred
|
||||
from directory scanning. Open/incomplete runs, changed-input receipts, missing
|
||||
historical helpers, unsafe/duplicate archive members, and mismatched input
|
||||
maps/bytes are refused. An existing output directory is refused even if empty.
|
||||
Use repeatable `--helper PATH` to provide an exact historical capture helper when
|
||||
the current two helpers no longer match a selected receipt.
|
||||
|
||||
`manifest.json` maps each logical raw path to its stored ZIP member. Input maps
|
||||
and input ZIPs deduplicate independently by their exact SHA-256 bytes, including
|
||||
ZIP metadata; equal source contents need not imply byte-identical ZIPs.
|
||||
`captureHelpers` names both gate.py and gate-feedback.py and points to their
|
||||
hash-addressed archived bytes. Each run separately references the helper matching
|
||||
its receipt. Current reviewed helper SHA-256 values are:
|
||||
|
||||
```text
|
||||
gate.py 1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20
|
||||
gate-feedback.py 230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29
|
||||
```
|
||||
|
||||
Both current named helpers are included even when no selected run uses one yet.
|
||||
The manifest is authoritative for helper pins at packaging time; this README's
|
||||
values document this tooling review, not a substitute for receipt matching.
|
||||
`files.json` pins the archive, manifest, tools and this README. Keep that pin file
|
||||
under the parent's source/evidence record; self-consistency is not authentication.
|
||||
|
||||
The verifier retains the existing 71-file vendored-input invariant and requires
|
||||
`inputsUnchanged` to be exactly true. Source coverage is whatever each original
|
||||
input map and receipt actually captured; recorded HEAD alone does not identify
|
||||
uncommitted tested bytes. Executable readback pins are receipts only: no executable
|
||||
or toolchain is packaged or independently reconstructed. Rendered feedback images,
|
||||
native screenshots/saves and source-to-commit receipts are separate parent-owned
|
||||
packages. Offline validation is not test execution, native acceptance or usability
|
||||
evidence. `selfcheck.py` checks these tools against explicitly selected closed runs
|
||||
and includes in-memory corruption rejection checks.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 5326,
|
||||
"sha256": "2c8d7cbd1ec2415e671c35734fb5b78beb9a535626b54492a4c1d3687a6e4dd9"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 9497,
|
||||
"sha256": "a52e4057d975717d132c1a8b656d34e1ef8ef8fbd125687715a69696a866fd44"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 3344,
|
||||
"sha256": "9d1eaa1dabdad0d95829da6d6e3004703ba90bc9824a963c43ca61e24b210e7e"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 45584,
|
||||
"sha256": "ea8d070535c3d62f3e2c13b81340ad2d506863fd93fe981f03a4edb87552e943"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 20916711,
|
||||
"sha256": "591a81f55aa409044ecbcaf6da83ca107602a6ab690b347b84fd40bea469fd35"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
"""Package explicitly selected closed gate captures without running Cargo."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
from verify import RAW_FILES, pin, parsed, need, run_name, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs, helpers):
|
||||
gates = root / ".local/process-group-gates"
|
||||
candidates = [gates / name for name in ("gate.py", "gate-feedback.py")] + helpers
|
||||
helper_bytes = [path.read_bytes() for path in candidates]
|
||||
payload, logical, specs = {}, {}, {}
|
||||
def add(name, data):
|
||||
stored = ("capsules/" + pin(data)["sha256"] + Path(name).suffix
|
||||
if name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"} else name)
|
||||
need(stored not in payload or payload[stored] == data, "deduplication collision")
|
||||
payload[stored] = data
|
||||
logical[name] = stored
|
||||
helper_catalog = {}
|
||||
for name, data in zip(("gate.py", "gate-feedback.py"), helper_bytes):
|
||||
helper = "helpers/" + pin(data)["sha256"] + ".py"
|
||||
add(helper, data)
|
||||
helper_catalog[name] = helper
|
||||
for run in runs:
|
||||
folder = gates / run
|
||||
need(folder.is_dir(), "run directory missing: " + run)
|
||||
paths = list(folder.iterdir())
|
||||
need(all(path.is_file() and not path.is_symlink() for path in paths)
|
||||
and {path.name for path in paths} == RAW_FILES, "exact closed raw files required: " + run)
|
||||
raw = {path.name: path.read_bytes() for path in paths}
|
||||
result = parsed(raw["result.json"])
|
||||
need("finish" in result and result["inputsUnchanged"] is True, "unfinished/changed run refused: " + run)
|
||||
matches = [data for data in helper_bytes if pin(data) == result["helper"]]
|
||||
need(bool(matches), "exact historical helper unavailable for " + run + "; supply --helper PATH")
|
||||
helper = "helpers/" + result["helper"]["sha256"] + ".py"
|
||||
add(helper, matches[0])
|
||||
for name, data in sorted(raw.items()):
|
||||
add("cargo/" + run + "/" + name, data)
|
||||
specs[run] = {key: result[key] for key in ("command", "head", "exitCode", "inputCount")}
|
||||
specs[run]["helper"] = helper
|
||||
manifest = {"schema": 1, "kind": "multiselect-cargo-gates", "runs": specs,
|
||||
"captureHelpers": helper_catalog,
|
||||
"logicalFiles": dict(sorted(logical.items())),
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as output:
|
||||
output.write((json.dumps(value, indent=2) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True, help="checkout holding .local/process-group-gates")
|
||||
parser.add_argument("--runs", nargs="+", required=True, help="explicit run names, including failures to retain")
|
||||
parser.add_argument("--helper", type=Path, action="append", default=[], help="additional exact historical helper (repeatable)")
|
||||
parser.add_argument("--output", type=Path, help="fresh, nonexistent package directory (required unless --check-only)")
|
||||
parser.add_argument("--check-only", action="store_true", help="validate raw captures in memory without creating an archive")
|
||||
args = parser.parse_args()
|
||||
need(all(run_name(run) for run in args.runs) and len(args.runs) == len(set(args.runs)), "unique safe run names required")
|
||||
need(args.check_only or args.output is not None, "--output is required unless --check-only")
|
||||
if not args.check_only:
|
||||
need(not args.output.exists(), "output must be a fresh directory: " + str(args.output))
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no package written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing package output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
destination = out / name
|
||||
if destination.exists():
|
||||
need(destination.read_bytes() == data, "different existing package tool: " + name)
|
||||
else:
|
||||
with destination.open("xb") as output:
|
||||
output.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated captures")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Offline consistency checks only; never execute or extract archived inputs."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
RAW_FILES = {"started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"}
|
||||
OUTER_FILES = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def valid_pin(value):
|
||||
return (isinstance(value, dict) and set(value) == {"bytes", "sha256"}
|
||||
and type(value["bytes"]) is int and value["bytes"] >= 0
|
||||
and isinstance(value["sha256"], str)
|
||||
and re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is not None)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and all(ord(char) >= 32 for char in name)
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def run_name(name):
|
||||
return isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) is not None
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(value):
|
||||
raise ValueError("nonfinite JSON constant: " + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
|
||||
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP membership")
|
||||
for entry in archive.infolist():
|
||||
need(safe(entry.filename) and not entry.is_dir(), "unsafe ZIP member: " + entry.filename)
|
||||
need(not stat.S_ISLNK(entry.external_attr >> 16), "ZIP symlink: " + entry.filename)
|
||||
need(valid_pin(expected[entry.filename]), "invalid member pin: " + entry.filename)
|
||||
need(entry.file_size == expected[entry.filename]["bytes"], "ZIP recorded size: " + entry.filename)
|
||||
|
||||
|
||||
def ordered_times(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(value.utcoffset() is not None for value in times) and times == sorted(times)
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
"""Shared read-only verifier for packaged bytes or prepare's in-memory payload."""
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "manifest schema")
|
||||
runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"]
|
||||
helpers = manifest["captureHelpers"]
|
||||
need(isinstance(helpers, dict) and set(helpers) == {"gate.py", "gate-feedback.py"}, "capture helper names")
|
||||
need(all(isinstance(name, str) and re.fullmatch(r"helpers/[0-9a-f]{64}\.py", name)
|
||||
for name in helpers.values()), "capture helper mapping")
|
||||
need(isinstance(runs, dict) and bool(runs) and all(run_name(run) for run in runs), "explicit run inventory")
|
||||
need(all(safe(name) for name in logical) and all(safe(name) for name in stored), "unsafe manifest path")
|
||||
need(set(logical.values()) == set(stored), "unreferenced or absent stored members")
|
||||
for name, expected in stored.items():
|
||||
need(valid_pin(expected) and pin(read(name)) == expected, "stored member hash: " + name)
|
||||
expected_logical = {"cargo/" + run + "/" + name for run in runs for name in RAW_FILES}
|
||||
expected_logical.update(spec["helper"] for spec in runs.values())
|
||||
expected_logical.update(helpers.values())
|
||||
need(set(logical) == expected_logical, "exact logical raw/helper inventory")
|
||||
for name, destination in logical.items():
|
||||
if name.startswith("cargo/") and name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"}:
|
||||
expected = "capsules/" + stored[destination]["sha256"] + PurePosixPath(name).suffix
|
||||
need(destination == expected, "input capsule mapping: " + name)
|
||||
else:
|
||||
need(destination == name, "only input maps/archives may be deduplicated: " + name)
|
||||
def raw(name):
|
||||
return read(logical[name])
|
||||
for name, helper in helpers.items():
|
||||
need(helper == "helpers/" + pin(raw(helper))["sha256"] + ".py", "named helper hash: " + name)
|
||||
seen_capsules = set()
|
||||
for run, spec in runs.items():
|
||||
prefix = "cargo/" + run + "/"
|
||||
start = parsed(raw(prefix + "started.json"))
|
||||
process = parsed(raw(prefix + "process.json"))
|
||||
result = parsed(raw(prefix + "result.json"))
|
||||
required_start = {"start", "command", "cwd", "head", "scope", "inputs", "archive",
|
||||
"inputCount", "environment", "helper", "cargo", "rustc"}
|
||||
need(required_start <= start.keys(), run + ": incomplete start receipt")
|
||||
need(all(result.get(key) == value for key, value in start.items()), run + ": start/result relationship")
|
||||
command = result["command"]
|
||||
need(isinstance(command, list) and command and command[0] == "cargo"
|
||||
and all(isinstance(arg, str) for arg in command), run + ": Cargo command")
|
||||
need(command == process["command"] == spec["command"], run + ": command relationship")
|
||||
need(result["head"] == spec["head"] and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", result["head"]), run + ": HEAD")
|
||||
need(type(result["exitCode"]) is int and result["exitCode"] == spec["exitCode"], run + ": recorded exit")
|
||||
need(result["inputsUnchanged"] is True, run + ": changed inputs refused")
|
||||
need(type(result["seconds"]) in (int, float) and math.isfinite(result["seconds"])
|
||||
and result["seconds"] >= 0, run + ": duration")
|
||||
need(type(process["pid"]) is int and process["pid"] > 0, run + ": process identity")
|
||||
need(ordered_times(start["start"], process["observed"], result["finish"]), run + ": completed timestamp order")
|
||||
for field, filename in [("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log")]:
|
||||
need(result[field] == pin(raw(prefix + filename)), run + ": " + field + " pin")
|
||||
need(valid_pin(result["helper"]) and result["helper"] == pin(raw(spec["helper"])), run + ": helper pin")
|
||||
need(spec["helper"] == "helpers/" + result["helper"]["sha256"] + ".py", run + ": helper mapping")
|
||||
inputs = parsed(raw(prefix + "inputs.json"))
|
||||
need(isinstance(inputs, dict) and all(safe(name) for name in inputs), run + ": safe input inventory")
|
||||
need(type(result["inputCount"]) is int and len(inputs) == result["inputCount"] == spec["inputCount"], run + ": input counts")
|
||||
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
|
||||
capsule = (logical[prefix + "inputs.json"], logical[prefix + "inputs.zip"])
|
||||
if capsule not in seen_capsules:
|
||||
with zipfile.ZipFile(io.BytesIO(raw(prefix + "inputs.zip"))) as source:
|
||||
inventory(source, inputs)
|
||||
for name, expected in inputs.items():
|
||||
need(pin(source.read(name)) == expected, run + ": source hash " + name)
|
||||
seen_capsules.add(capsule)
|
||||
artifacts = []
|
||||
for line in raw(prefix + "stdout.log").decode("utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("reason") == "compiler-artifact" and value.get("executable"):
|
||||
artifacts.append(value)
|
||||
need(isinstance(result["artifacts"], list), run + ": artifact records")
|
||||
need(artifacts == [record["cargo"] for record in result["artifacts"]], run + ": Cargo JSON artifact relationship")
|
||||
for record in result["artifacts"]:
|
||||
need(set(record) == {"cargo", "readback"}
|
||||
and (record["readback"] is None or valid_pin(record["readback"])), run + ": artifact readback pin")
|
||||
print(run, "recorded exit", result["exitCode"], "inputs", len(inputs), "executable records", len(artifacts))
|
||||
return {"runs": len(runs), "recordedFailures": sum(spec["exitCode"] != 0 for spec in runs.values()),
|
||||
"logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules),
|
||||
"captureHelpers": helpers}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER_FILES, "outer file inventory")
|
||||
for name, expected in files.items():
|
||||
need(valid_pin(expected) and pin((root / name).read_bytes()) == expected, "outer hash: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS offline consistency:", verify_package(args.package))
|
||||
print("Historical receipts only; no source/binary execution or native acceptance claim.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
# Actual Process render feedback byte proof
|
||||
|
||||
This small package holds the four actual WGPU RGBA readbacks, adjacent JSON
|
||||
metadata, lossless PNGs, original png-readback.json, encode-png.py and parent
|
||||
observations.md from `.local/process-feedback-review/capture-1`. It requires
|
||||
Python and Pillow; it never invokes Cargo, a GUI, or archived capture helpers.
|
||||
|
||||
Use a portable Process checks package containing the closed `feedback-1` gate:
|
||||
|
||||
```text
|
||||
python -B prepare.py --root C:/ar-multiselect-9e8r6t --checks CHECKS_PACKAGE --check-only
|
||||
python -B prepare.py --root C:/ar-multiselect-9e8r6t --checks CHECKS_PACKAGE --output FRESH_DIRECTORY
|
||||
python -B FRESH_DIRECTORY/verify.py --checks CHECKS_PACKAGE
|
||||
```
|
||||
|
||||
Run the checks package's own verifier too. Feedback references exact logical
|
||||
feedback-1 receipt/helper/log/input pins, so the checks package may include other
|
||||
runs and may be moved independently. No full source capsule is duplicated here.
|
||||
Preparation refuses existing output and compares all 742 captured input bytes
|
||||
to Git commit b15345123a7feaf630ef24d45f591c76c6715778. Verification checks the
|
||||
fixed source map identity and every source-capsule member through the supplied
|
||||
checks package. The original gate HEAD predates b153; its captured inputs, rather
|
||||
than that HEAD, identify the rendered source. The relevant captured test and
|
||||
Mounted renderer paths have explicit source pins.
|
||||
|
||||
Every PNG is decoded and required to equal its raw RGBA byte for byte at the
|
||||
recorded 1440x900 size. For OA and SA separately, before-press and after-motion
|
||||
must preserve complete source bundle, selection string/count, canvas, camera,
|
||||
scale and dimensions while pixels differ. The original PNG readback receipt,
|
||||
unique safe ZIP inventory and every archived file hash are checked. A matching
|
||||
checks receipt must record the exact passing test and capture output directory.
|
||||
|
||||
This is byte/provenance consistency, not native acceptance or independent image
|
||||
interpretation. Pixel files have no gate-time hashes: their linkage is the
|
||||
recorded output directory, create-only captured test code, passing test receipt
|
||||
and parent observations; package pins establish bytes at packaging time.
|
||||
Selection metadata stores the test's captured snapshot; unchanged current
|
||||
selection and empty history are separately asserted in the passing test.
|
||||
There is no history metadata, and this tool does not invent any. Source text in
|
||||
metadata is compared exactly between stages, not reparsed as a Project.
|
||||
The parent observations describe visible quality and scope limits. Encoding
|
||||
helpers and observations are preserved, not executed or treated as instructions.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"README.md": {
|
||||
"bytes": 2623,
|
||||
"sha256": "731c4062a0bc4a66a44246fc69c53b8aa32fd09613e62c9bbef4716f7c930641"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 797909,
|
||||
"sha256": "6ebd3beafec69da81124511a509f56fe966d35fbd1b127567a1b7f57eb1a9051"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 4079,
|
||||
"sha256": "caf50753143216c0cf503473508581f1229528215cf6a72b038ddc8f84a75000"
|
||||
},
|
||||
"prepare.py": {
|
||||
"bytes": 3753,
|
||||
"sha256": "4fb1db6b465ae6fd25cdabdf63b3a5fb76a6cd9399f01afd3dca4dd9ec4e7f86"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 8730,
|
||||
"sha256": "d0bcb4fc8b07c7ba9b4573505b72e865a5eb18070b9b5e4411db757e13175d96"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kind": "process-render-feedback",
|
||||
"sourceCommit": "b15345123a7feaf630ef24d45f591c76c6715778",
|
||||
"checksRun": "feedback-1",
|
||||
"checksPins": {
|
||||
"started.json": {
|
||||
"bytes": 1747,
|
||||
"sha256": "01fbcfd28b752921ded95c40a711909851bcd005394b34b5f4f49a7eaa0a594c"
|
||||
},
|
||||
"process.json": {
|
||||
"bytes": 260,
|
||||
"sha256": "b8559e934c97f9eba407b1ceceed637fc570a9a8ba9e3362b961824cb0344fcd"
|
||||
},
|
||||
"result.json": {
|
||||
"bytes": 3481,
|
||||
"sha256": "2f007dfda469a75eaaf713577b394605af7d3d42fed2137d9e3ed632301c78d3"
|
||||
},
|
||||
"stdout.log": {
|
||||
"bytes": 293378,
|
||||
"sha256": "8c611aa1671316570c79b079882400e3b710d3eff8fe04e37ec59f17071fa92c"
|
||||
},
|
||||
"stderr.log": {
|
||||
"bytes": 210,
|
||||
"sha256": "504bdaa5c008c52a9462aeca71413f9933a4e9c6b0272715ba5ca916411781c1"
|
||||
},
|
||||
"inputs.json": {
|
||||
"bytes": 124276,
|
||||
"sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767"
|
||||
},
|
||||
"inputs.zip": {
|
||||
"bytes": 2985964,
|
||||
"sha256": "f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 3856,
|
||||
"sha256": "230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29"
|
||||
}
|
||||
},
|
||||
"capturingSources": {
|
||||
"crates/oa-desktop/src/behavior/topology/layout_tests.rs": {
|
||||
"bytes": 50414,
|
||||
"sha256": "993d700d6590757fb329cab3029dd0202e71c2c2f852e13fe1228417b01d8fd9"
|
||||
},
|
||||
"crates/oa-desktop/src/behavior/canvas_workspace/tests.rs": {
|
||||
"bytes": 27368,
|
||||
"sha256": "de13f0112b1a39fa03f32a9308dcbd30b235eef9ead877b440dde023c93bc5a3"
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"capture-1/png-readback.json": {
|
||||
"bytes": 1006,
|
||||
"sha256": "4f397109e46512010fdb777c85abb6dc658fc9e26cb255d22165b0a1c6502eac"
|
||||
},
|
||||
"capture-1/process-Oa-after-motion.json": {
|
||||
"bytes": 2674,
|
||||
"sha256": "17c55c7275634ac9a22b78a6eeab7464ceb0f0e389f02947d9ec96caaf18ecfe"
|
||||
},
|
||||
"capture-1/process-Oa-after-motion.png": {
|
||||
"bytes": 111782,
|
||||
"sha256": "051c44a392eb23787b6e7ff28b0384336602eb2d6e166cca2859c7e44a8f5386"
|
||||
},
|
||||
"capture-1/process-Oa-after-motion.rgba": {
|
||||
"bytes": 5184000,
|
||||
"sha256": "7726fa5de90ad7ec5b14e87883c40cace0007ece54279048c6f49c9386ce5d6d"
|
||||
},
|
||||
"capture-1/process-Oa-before-press.json": {
|
||||
"bytes": 2674,
|
||||
"sha256": "6c1d80c1690d13a8fa2c21505322651c7436673368035fa7a054d0ee55ddbb5f"
|
||||
},
|
||||
"capture-1/process-Oa-before-press.png": {
|
||||
"bytes": 108986,
|
||||
"sha256": "d9b5c2e0b7b50ea894986aa38c63b8d1559169974d6a93954dd0a69107b5740d"
|
||||
},
|
||||
"capture-1/process-Oa-before-press.rgba": {
|
||||
"bytes": 5184000,
|
||||
"sha256": "399e7442f56d320a92d6d9beccd2f66a241e572adadcd733ba797aef97453a1e"
|
||||
},
|
||||
"capture-1/process-Sa-after-motion.json": {
|
||||
"bytes": 3186,
|
||||
"sha256": "441d18d75dab872012f68020012840aa67a09b52a5946749ceea669e2839439e"
|
||||
},
|
||||
"capture-1/process-Sa-after-motion.png": {
|
||||
"bytes": 111760,
|
||||
"sha256": "bc31d57fae268b6ad19c5b14aaa0860e14b7a5f80608a8896970f7ad82b041f0"
|
||||
},
|
||||
"capture-1/process-Sa-after-motion.rgba": {
|
||||
"bytes": 5184000,
|
||||
"sha256": "82493c91aa5ac0e1f44a50f52496fe933104acd7992b2f9adbb7710ed3e85e29"
|
||||
},
|
||||
"capture-1/process-Sa-before-press.json": {
|
||||
"bytes": 3186,
|
||||
"sha256": "b13fbdab939a9d34a8950ee89800f41c2082b5928ef5c8781dc813940ecf6df1"
|
||||
},
|
||||
"capture-1/process-Sa-before-press.png": {
|
||||
"bytes": 108966,
|
||||
"sha256": "9b2a4a7cc355639e26e4e87cddf17b9ce859a00e5a80400da62b67ba27198a59"
|
||||
},
|
||||
"capture-1/process-Sa-before-press.rgba": {
|
||||
"bytes": 5184000,
|
||||
"sha256": "30ede8974ba5d9227c109fd9a5c08c93119bdb897ad7f273e87a6602bc42bdd5"
|
||||
},
|
||||
"encode-png.py": {
|
||||
"bytes": 2181,
|
||||
"sha256": "c05b2d9f2a02d72dba50422da733e36758bd6e2773e48d84174b72ec0a804b5e"
|
||||
},
|
||||
"observations.md": {
|
||||
"bytes": 1912,
|
||||
"sha256": "11828e5467785990a11d462813b821f677cbff1c64dcd1ca8244a94fe01e2423"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Package the fixed, closed capture-1 pixels using an existing checks package."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import zipfile
|
||||
from verify import COMMIT, MEMBERS, SOURCES, OUTER, need, pin, checks_data, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--checks", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--check-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
need(args.check_only or args.output is not None, "--output or --check-only required")
|
||||
if args.output:
|
||||
need(not args.output.exists(), "fresh nonexistent output directory required")
|
||||
checks = checks_data(args.checks)
|
||||
gate, source = checks
|
||||
# Compare every captured source byte to the named commit, not current worktree.
|
||||
paths = sorted(source)
|
||||
result = subprocess.run(["git", "cat-file", "--batch"], cwd=args.root,
|
||||
input="".join(COMMIT + ":" + name + "\n" for name in paths).encode(), capture_output=True, check=True)
|
||||
offset = 0
|
||||
for name in paths:
|
||||
end = result.stdout.index(b"\n", offset)
|
||||
header = result.stdout[offset:end].split()
|
||||
need(len(header) == 3 and header[1] == b"blob", "missing commit blob: " + name)
|
||||
size = int(header[2])
|
||||
data = result.stdout[end + 1:end + 1 + size]
|
||||
need(data == source[name], "captured input differs from b153: " + name)
|
||||
offset = end + 2 + size
|
||||
need(offset == len(result.stdout), "exact Git batch readback")
|
||||
folder = args.root / ".local/process-feedback-review"
|
||||
need({p.relative_to(folder).as_posix() for p in (folder / "capture-1").rglob("*") if p.is_file()} == {name for name in MEMBERS if name.startswith("capture-1/")}, "exact captured file inventory")
|
||||
payload = {}
|
||||
for name in sorted(MEMBERS):
|
||||
path = folder / name
|
||||
need(not path.is_symlink() and not getattr(path, "is_junction", lambda: False)(), "linked input")
|
||||
payload[name] = path.read_bytes()
|
||||
manifest = {"schema": 1, "kind": "process-render-feedback", "sourceCommit": COMMIT,
|
||||
"checksRun": "feedback-1", "checksPins": {name: pin(data) for name, data in gate.items()},
|
||||
"capturingSources": {name: pin(source[name]) for name in SOURCES},
|
||||
"members": {name: pin(data) for name, data in payload.items()}}
|
||||
summary = verify_data(manifest, payload.__getitem__, checks)
|
||||
if args.check_only:
|
||||
print("PASS raw pixels, metadata and all 742 b153 source blobs:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
(out / name).write_bytes((HERE / name).read_bytes())
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in payload.items():
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
(out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
(out / "files.json").write_text(json.dumps({name: pin((out / name).read_bytes()) for name in sorted(OUTER)}, indent=2) + "\n", encoding="utf-8")
|
||||
need(verify_package(out, checks) == summary, "written archive readback")
|
||||
print("PASS package readback and all 742 b153 source blobs:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Verify actual rendered feedback bytes against a separate Process checks package."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import stat
|
||||
import zipfile
|
||||
from PIL import Image
|
||||
|
||||
COMMIT = "b15345123a7feaf630ef24d45f591c76c6715778"
|
||||
INPUT_SHA = "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767"
|
||||
TEST = "production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera"
|
||||
SOURCES = ("crates/oa-desktop/src/behavior/topology/layout_tests.rs", "crates/oa-desktop/src/behavior/canvas_workspace/tests.rs")
|
||||
NAMES = [f"process-{layer}-{stage}" for layer in ("Oa", "Sa") for stage in ("before-press", "after-motion")]
|
||||
MEMBERS = {"observations.md", "encode-png.py", "capture-1/png-readback.json"} | {f"capture-1/{name}.{ext}" for name in NAMES for ext in ("rgba", "png", "json")}
|
||||
OUTER = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def safe(name):
|
||||
return isinstance(name, str) and bool(name) and not PurePosixPath(name).is_absolute() and all(c not in name for c in ("\\", ":")) and all(ord(c) >= 32 for c in name) and all(p not in ("", ".", "..") for p in name.split("/"))
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(value):
|
||||
raise ValueError("nonfinite JSON: " + value)
|
||||
return json.loads(data, object_pairs_hook=unique, parse_constant=constant)
|
||||
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP inventory")
|
||||
for row in archive.infolist():
|
||||
need(safe(row.filename) and not row.is_dir() and not stat.S_ISLNK(row.external_attr >> 16), "unsafe ZIP entry")
|
||||
need(row.file_size == expected[row.filename]["bytes"], "ZIP size")
|
||||
|
||||
|
||||
def checks_data(root):
|
||||
"""Read referenced gate bytes, without importing or executing package code."""
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER, "checks outer inventory")
|
||||
for name, expected in files.items():
|
||||
need(pin((root / name).read_bytes()) == expected, "checks outer pin: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "checks format")
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
def raw(logical):
|
||||
member = manifest["logicalFiles"][logical]
|
||||
data = archive.read(member)
|
||||
need(pin(data) == manifest["members"][member], "referenced checks pin")
|
||||
return data
|
||||
prefix = "cargo/feedback-1/"
|
||||
gate = {name: raw(prefix + name) for name in ("started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip")}
|
||||
spec = manifest["runs"]["feedback-1"]
|
||||
gate["helper"] = raw(spec["helper"])
|
||||
result, start, process = [parsed(gate[name + ".json"]) for name in ("result", "started", "process")]
|
||||
need(all(result.get(k) == v for k, v in start.items()) and result["command"] == process["command"], "feedback start/process/result")
|
||||
times = [datetime.fromisoformat(t) for t in (start["start"], process["observed"], result["finish"])]
|
||||
need(all(t.utcoffset() is not None for t in times) and times == sorted(times), "closed feedback chronology")
|
||||
need(result["exitCode"] == 0 and result["inputsUnchanged"] is True and TEST in result["command"], "passed exact feedback gate")
|
||||
need(result["environment"]["ARCADIA_RENDER_EVIDENCE_DIR"].replace("\\", "/") == result["cwd"].replace("\\", "/") + "/.local/process-feedback-review/capture-1", "actual output directory provenance")
|
||||
for key, name in (("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log"), ("helper", "helper")):
|
||||
need(result[key] == pin(gate[name]), "feedback receipt pin: " + key)
|
||||
need(result["inputs"]["sha256"] == INPUT_SHA, "fixed b153 input map identity")
|
||||
inputs = parsed(gate["inputs.json"])
|
||||
need(len(inputs) == result["inputCount"] == 742 and all(safe(name) for name in inputs), "captured source inventory")
|
||||
with zipfile.ZipFile(io.BytesIO(gate["inputs.zip"])) as capsule:
|
||||
inventory(capsule, inputs)
|
||||
source = {name: capsule.read(name) for name in inputs}
|
||||
need(all(pin(source[name]) == expected for name, expected in inputs.items()), "all captured source bytes")
|
||||
need(("test behavior::topology::view::layout_tests::" + TEST + " ... ok").encode() in gate["stdout.log"], "actual test result")
|
||||
return gate, source
|
||||
|
||||
|
||||
def verify_data(manifest, read, checks):
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "process-render-feedback" and manifest["sourceCommit"] == COMMIT, "feedback manifest")
|
||||
need(set(manifest["members"]) == MEMBERS, "exact feedback files")
|
||||
for name, expected in manifest["members"].items():
|
||||
need(pin(read(name)) == expected, "feedback pin: " + name)
|
||||
gate, source = checks
|
||||
need(manifest["checksRun"] == "feedback-1" and manifest["checksPins"] == {name: pin(data) for name, data in gate.items()}, "exact checks references")
|
||||
need(manifest["capturingSources"] == {name: pin(source[name]) for name in SOURCES}, "capturing test and renderer source identity")
|
||||
report = parsed(read("capture-1/png-readback.json"))
|
||||
need(set(report["captures"]) == set(NAMES), "four readback records")
|
||||
for layer in ("Oa", "Sa"):
|
||||
previous = None
|
||||
for stage in ("before-press", "after-motion"):
|
||||
name = f"process-{layer}-{stage}"
|
||||
metadata = parsed(read("capture-1/" + name + ".json"))
|
||||
raw = read("capture-1/" + name + ".rgba")
|
||||
need(metadata["layer"] == layer and metadata["stage"] == stage and metadata["format"] == "RGBA8", "capture identity")
|
||||
need(metadata["width"] == 1440 and metadata["height"] == 900 and len(raw) == 1440 * 900 * 4, "exact original dimensions")
|
||||
need(metadata["selectionCount"] == 2 and isinstance(metadata["selection"], str) and "history" not in metadata, "selection metadata; no invented history field")
|
||||
bundle = metadata["source"]
|
||||
need(safe(bundle["entry"]) and bundle["entry"] in bundle["files"] and all(safe(n) and isinstance(s, str) for n, s in bundle["files"].items()), "complete captured source bundle")
|
||||
with Image.open(io.BytesIO(read("capture-1/" + name + ".png"))) as png:
|
||||
need(png.format == "PNG" and png.size == (1440, 900) and png.convert("RGBA").tobytes() == raw, "decoded PNG equals actual raw RGBA")
|
||||
expected = {"bytes": len(raw), "rgbaSha256": pin(raw)["sha256"], "width": 1440, "height": 900}
|
||||
need(report["captures"][name] == expected, "original PNG readback receipt")
|
||||
if previous:
|
||||
for field in ("source", "selection", "selectionCount", "canvas", "camera", "scale", "width", "height"):
|
||||
need(metadata[field] == previous[0][field], layer + " changed " + field + " before release")
|
||||
need(raw != previous[1], "actual before/after pixel difference")
|
||||
previous = metadata, raw
|
||||
return {"captures": 4, "pngPixelsExact": True, "stableMetadataPairs": 2, "checksRun": "feedback-1", "nativeAcceptance": False}
|
||||
|
||||
|
||||
def verify_package(root, checks):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER, "feedback outer inventory")
|
||||
for name, expected in files.items():
|
||||
need(pin((root / name).read_bytes()) == expected, "feedback outer pin")
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read, checks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
parser.add_argument("--checks", type=Path, required=True, help="Process checks package containing closed feedback-1")
|
||||
args = parser.parse_args()
|
||||
print("PASS feedback byte proof:", verify_package(args.package, checks_data(args.checks)))
|
||||
print("History is asserted by the captured test, not recorded in image metadata. No native acceptance claim.")
|
||||
@@ -0,0 +1,48 @@
|
||||
# Portable Process native receipts
|
||||
|
||||
Adapted from the State native evidence package. Python standard library only;
|
||||
neither preparation nor verification runs any GUI, checker, archived helper or binary.
|
||||
|
||||
From a checkout, validate explicit closed runs:
|
||||
|
||||
```text
|
||||
python -B prepare.py --root C:/ar-multiselect-9e8r6t --runs run-1 --check-only
|
||||
python -B prepare.py --root C:/ar-multiselect-9e8r6t --runs run-1 run-2 --output FRESH_DIRECTORY
|
||||
python -B FRESH_DIRECTORY/verify.py
|
||||
```
|
||||
|
||||
The output directory must not exist. Run-2 is an example, usable only after its
|
||||
finished.json exists. Explicit names prevent accidental capture of a live run.
|
||||
No run is automatically accepted. Run-1 always retains `reviewStatus: failed`:
|
||||
Back exposed the outer reference's editable Properties while highlighting the
|
||||
qualified tile; reorder and history were not performed. Later runs are
|
||||
`unreviewed` by this tool even if independently accepted in the parent's report.
|
||||
|
||||
The archive retains every run file byte, exact prepared helpers (including
|
||||
common.py and README.md), the actual capture-executed.js separately, complete
|
||||
fixture/working/snapshot source, screenshots and accessibility JSON, logs,
|
||||
normal-exit/closure flags, and history-bytes.json when present. The executed
|
||||
capture function is preserved and package-pinned; the preparation receipt does
|
||||
not authenticate it as the same bytes as the prepared capture.js template.
|
||||
|
||||
Both GUI and checker build raw receipts, process/start metadata, logs, complete
|
||||
input maps and source ZIPs are archived. Source maps/ZIPs are deduplicated by
|
||||
exact hash. The historical checker source is also directly available. Helpers
|
||||
must match recorded pins; use repeated `--helper PATH` for unavailable historical
|
||||
versions. Never rewrite original capture helpers to make pins pass.
|
||||
|
||||
Verification checks safe unique ZIP paths and JSON keys, all member hashes,
|
||||
reference closure, lifecycle/time relationships, exact prepared helpers,
|
||||
source-map equality, every source-capsule byte, executable receipt identity,
|
||||
artifact membership in raw Cargo output, all snapshot reports, and literal
|
||||
history bytes if present. Nonzero native/checker exits are retained as data;
|
||||
missing closure or inconsistent receipts refuse packaging. A successful receipt
|
||||
consistency check is not a claim that native interactions succeeded.
|
||||
|
||||
The verifier does not authenticate authorship, independently replay the checker,
|
||||
interpret screenshot contents, verify pixel dimensions, infer click/drag/focus
|
||||
actions, prove one-step history, or establish usability/native acceptance. Input
|
||||
hashes establish recorded consistency, not a reproducible-build proof. Binaries
|
||||
are identified by build readback pins and native receipts, not included or run.
|
||||
Independent parent review remains necessary. Failed checker output is preserved
|
||||
verbatim and not required to be a positive JSON report.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 6712,
|
||||
"sha256": "d6b265b0047e9e0f8d67b71a9208b0467cd12b0470690713bfce6e3ceba6e0d3"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 16518,
|
||||
"sha256": "035e2e3eacdfb4205e6560fa85befbe65bbc2d20c5c8a42a11e4a965131ed0e4"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 2857,
|
||||
"sha256": "57d447f984fd8dccc1e93fdb7b493bbb9e76a081b2a0a2d28689da86c0d61ce4"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 80265,
|
||||
"sha256": "7bd4fa9247941d17939de989501b66c0d5d04f222424fb092c00f52f765b5bca"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 12355442,
|
||||
"sha256": "e94c3e9f2fb644c8e415e09b75eaf973d9c2a93534ad9e41d69793a3c11e8f88"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
"""Package explicitly named closed Process native runs; never execute archived helpers."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from verify import pin, parsed, need, norm, relative, safe, zip_inventory, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
FAILED_RUN_ONE = "Observed Back restored qualified highlight with outer-reference editable Properties; reorder and Undo/Redo/reopen history were not performed. Failed native workflow, despite three positive unchanged Project reports."
|
||||
|
||||
|
||||
def collect(root, runs, extra_helpers=()):
|
||||
native = root / ".local/process-multiselect-native"
|
||||
candidates = [p for p in native.iterdir() if p.is_file()]
|
||||
candidates += sorted((root / ".local/process-group-gates").glob("*.py"))
|
||||
candidates += list(extra_helpers)
|
||||
helpers = [(p.suffix, p.read_bytes()) for p in candidates]
|
||||
payload, specs = {}, {}
|
||||
def add(name, data):
|
||||
need(safe(name) and (name not in payload or payload[name] == data), "member collision: " + name)
|
||||
payload[name] = data
|
||||
return name
|
||||
def helper(expected):
|
||||
matches = [(suffix, data) for suffix, data in helpers if pin(data) == expected]
|
||||
need(matches, "exact historical helper unavailable: " + str(expected))
|
||||
suffix, data = matches[0]
|
||||
return add("helpers/" + expected["sha256"] + suffix, data)
|
||||
def tree(folder, prefix):
|
||||
need(folder.is_dir(), "missing directory: " + str(folder))
|
||||
for path in sorted(folder.rglob("*")):
|
||||
need(not path.is_symlink() and not getattr(path, "is_junction", lambda: False)(), "linked evidence input")
|
||||
if path.is_file():
|
||||
add(prefix + path.relative_to(folder).as_posix(), path.read_bytes())
|
||||
for run in runs:
|
||||
folder = native / run
|
||||
need((folder / "finished.json").is_file(), "open/unlaunched run refused: " + run)
|
||||
prepared = parsed((folder / "prepared.json").read_bytes())
|
||||
tree(folder, "runs/" + run + "/raw/")
|
||||
spec = {"originalRun": norm(prepared["cwd"]) + "/.local/process-multiselect-native/" + run,
|
||||
"reviewStatus": "failed" if run == "run-1" else "unreviewed",
|
||||
"reviewNote": FAILED_RUN_ONE if run == "run-1" else "Packaging consistency does not decide native acceptance. Consult the independent parent review.",
|
||||
"helpers": {name: helper(expected) for name, expected in prepared["helpers"].items()},
|
||||
"executedCapture": pin((folder / "capture-executed.js").read_bytes()), "builds": {}}
|
||||
for kind in ("gui", "checker"):
|
||||
recorded = prepared[kind]
|
||||
rel = relative(recorded["receipt"], prepared["cwd"])
|
||||
need(rel.startswith(".local/process-group-gates/") and rel.endswith("/result.json"), "unexpected receipt location")
|
||||
receipt = root / rel
|
||||
result_data = receipt.read_bytes()
|
||||
need(pin(result_data) == recorded["receiptPin"], "historical build receipt changed")
|
||||
result = parsed(result_data)
|
||||
prefix = "builds/" + pin(result_data)["sha256"] + "/"
|
||||
build = {"originalRelative": rel, "helper": helper(result["helper"])}
|
||||
for name in ("started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"):
|
||||
data = (receipt.parent / name).read_bytes()
|
||||
# Deduplicate source capsules independently of differing build receipts.
|
||||
member = ("inputs/" + pin(data)["sha256"] + Path(name).suffix) if name.startswith("inputs.") else prefix + name
|
||||
build[name] = add(member, data)
|
||||
spec["builds"][kind] = build
|
||||
if kind == "checker":
|
||||
capsule = payload[build["inputs.zip"]]
|
||||
need(pin(capsule) == result["archive"], "historical checker capsule pin")
|
||||
inputs = parsed(payload[build["inputs.json"]])
|
||||
with zipfile.ZipFile(io.BytesIO(capsule)) as archive:
|
||||
zip_inventory(archive, inputs)
|
||||
source = archive.read("crates/oa-core/examples/process_multiselect_native.rs")
|
||||
spec["checkerSource"] = add("checker-source/" + pin(source)["sha256"] + ".rs", source)
|
||||
specs[run] = spec
|
||||
manifest = {"schema": 1, "kind": "process-multiselect-native", "runs": specs,
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
return payload, manifest, verify_data(manifest, payload.__getitem__)
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as stream:
|
||||
stream.write((json.dumps(value, indent=2) + "\n").encode())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--runs", nargs="+", required=True)
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--check-only", action="store_true")
|
||||
parser.add_argument("--helper", type=Path, action="append", default=[], help="Exact historical helper bytes if current helper changed")
|
||||
args = parser.parse_args()
|
||||
need(len(args.runs) == len(set(args.runs)) and all(re.fullmatch(r"run-[0-9]+", name) for name in args.runs), "explicit unique run-N names required")
|
||||
need(args.check_only or args.output is not None, "--output or --check-only required")
|
||||
if args.output is not None:
|
||||
need(not args.output.exists(), "fresh nonexistent output directory required")
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no archive written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
with (out / name).open("xb") as stream:
|
||||
stream.write((HERE / name).read_bytes())
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")})
|
||||
need(verify_package(out) == summary, "written package differs from raw evidence")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Verify archived native receipts and bytes; do not rerun the GUI or checker."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
BASE = {"prepared.json", "started.json", "finished.json", "generation.json", "generation.stdout.log",
|
||||
"generation.stderr.log", "app.stdout.log", "app.stderr.log", "capture-executed.js"}
|
||||
OUTER = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute() and all(ord(c) >= 32 for c in name)
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def norm(path):
|
||||
return path.replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def relative(path, root):
|
||||
prefix = norm(root) + "/"
|
||||
need(norm(path).startswith(prefix), "recorded path outside owner: " + path)
|
||||
value = norm(path)[len(prefix):]
|
||||
need(safe(value), "unsafe recorded relative path")
|
||||
return value
|
||||
|
||||
|
||||
def ordered(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(t.utcoffset() is not None for t in times) and times == sorted(times)
|
||||
|
||||
|
||||
def zip_inventory(archive, members):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(members), "exact archive members")
|
||||
for info in archive.infolist():
|
||||
need(safe(info.filename) and not info.is_dir() and not stat.S_ISLNK(info.external_attr >> 16), "unsafe archive member")
|
||||
need(info.file_size == members[info.filename]["bytes"], "archive member size")
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "process-multiselect-native", "manifest kind")
|
||||
members, runs = manifest["members"], manifest["runs"]
|
||||
need(bool(runs) and all(re.fullmatch(r"run-[0-9]+", run) for run in runs), "run names")
|
||||
need(all(safe(name) for name in members), "safe member names")
|
||||
for name, expected in members.items():
|
||||
need(pin(read(name)) == expected, "member pin: " + name)
|
||||
used, summaries = set(), {}
|
||||
def raw(name):
|
||||
need(name in members, "missing member: " + name)
|
||||
used.add(name)
|
||||
return read(name)
|
||||
def obj(name):
|
||||
return parsed(raw(name))
|
||||
def tree(prefix):
|
||||
return {name[len(prefix):]: raw(name) for name in members if name.startswith(prefix)}
|
||||
def pinned_tree(prefix, expected):
|
||||
actual = tree(prefix)
|
||||
need({name: pin(data) for name, data in actual.items()} == expected, "pinned tree: " + prefix)
|
||||
return actual
|
||||
for run, spec in runs.items():
|
||||
prefix = "runs/" + run + "/raw/"
|
||||
prepared, start, end, generation = [obj(prefix + n + ".json") for n in ("prepared", "started", "finished", "generation")]
|
||||
need(spec["reviewStatus"] in ("failed", "unreviewed") and isinstance(spec["reviewNote"], str) and spec["reviewNote"], "explicit non-acceptance status")
|
||||
if run == "run-1":
|
||||
need(spec["reviewStatus"] == "failed", "run-1 must retain observed native failure")
|
||||
origin = norm(prepared["cwd"]) + "/.local/process-multiselect-native/" + run
|
||||
need(spec["originalRun"] == origin and prepared["run"] == run and start["cwd"] == prepared["cwd"], "original run mapping")
|
||||
need(start["prepared"] == pin(raw(prefix + "prepared.json")), "launch preparation pin")
|
||||
need(ordered(generation["start"], generation["finish"], prepared["prepared"], start["start"], end["finish"]), "native lifetime")
|
||||
need(type(start["pid"]) is int and start["pid"] > 0 and type(end["exitCode"]) is int, "closed native process")
|
||||
for key in ("guiUnchanged", "checkerUnchanged", "fixtureUnchanged"):
|
||||
need(type(end[key]) is bool, "closure flag")
|
||||
need(start["gui"] == prepared["gui"] and start["checker"] == prepared["checker"] == generation["checker"], "binary identity")
|
||||
need(start["command"] == [prepared["gui"]["binary"]], "GUI command")
|
||||
need(generation["exitCode"] == 0 and generation["command"][:2] == [prepared["checker"]["binary"], "generate"] and len(generation["command"]) == 3 and norm(generation["command"][2]) == origin + "/fixture", "generation command")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(end[stream] == pin(raw(prefix + "app." + stream + ".log")), "GUI log pin")
|
||||
need(generation[stream] == pin(raw(prefix + "generation." + stream + ".log")), "generation log pin")
|
||||
need(set(spec["helpers"]) == set(prepared["helpers"]), "exact prepared helper mapping")
|
||||
for name, expected in prepared["helpers"].items():
|
||||
need(safe(name) and "/" not in name and pin(raw(spec["helpers"][name])) == expected, "historical helper: " + name)
|
||||
need(start["helper"] == prepared["helpers"]["native.py"], "launch helper")
|
||||
need(pin(raw(prefix + "capture-executed.js")) == spec["executedCapture"], "actual capture function pin")
|
||||
fixture = pinned_tree(prefix + "fixture/", prepared["fixture"])
|
||||
fm = parsed(fixture["manifest.json"])
|
||||
need(fm["fixture"] == "process-multiselect-native-20260923" and fm["source_entry"] == prepared["entry"] and safe(prepared["entry"]), "fixture identity/entry")
|
||||
need(parsed(fixture["baseline.json"])["id"] == fm["ids"]["project"], "complete Project baseline identity")
|
||||
fixture_source = {name[7:]: data for name, data in fixture.items() if name.startswith("source/")}
|
||||
need(prepared["entry"] in fixture_source and norm(prepared["workingEntry"]) == origin + "/working/" + prepared["entry"], "working entry mapping")
|
||||
working = tree(prefix + "working/")
|
||||
need(bool(working) and all(name.endswith(".arcadia") for name in working), "saved working tree")
|
||||
build_maps = {}
|
||||
for kind, target in (("gui", "arcadia-studio"), ("checker", "process_multiselect_native")):
|
||||
recorded, b = prepared[kind], spec["builds"][kind]
|
||||
result, began, process = [obj(b[n]) for n in ("result.json", "started.json", "process.json")]
|
||||
need(pin(raw(b["result.json"])) == recorded["receiptPin"] and relative(recorded["receipt"], prepared["cwd"]) == b["originalRelative"], "build receipt mapping")
|
||||
need(all(result.get(k) == v for k, v in began.items()) and process["command"] == result["command"] and type(process["pid"]) is int and process["pid"] > 0, "build start/result/process")
|
||||
need(result["exitCode"] == 0 and result["inputsUnchanged"] is True and ordered(result["start"], process["observed"], result["finish"], generation["start"]), "successful preceding build")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(pin(raw(b[stream + ".log"])) == result[stream], "build log pin")
|
||||
need(pin(raw(b["helper"])) == result["helper"], "build helper pin")
|
||||
need(pin(raw(b["inputs.json"])) == recorded["inputs"] == result["inputs"], "build source map pin")
|
||||
inputs = obj(b["inputs.json"])
|
||||
need(len(inputs) == result["inputCount"] and all(safe(name) for name in inputs), "source map count/paths")
|
||||
capsule = raw(b["inputs.zip"])
|
||||
need(pin(capsule) == result["archive"], "build source capsule pin")
|
||||
with zipfile.ZipFile(io.BytesIO(capsule)) as archive:
|
||||
zip_inventory(archive, inputs)
|
||||
need(all(pin(archive.read(name)) == expected for name, expected in inputs.items()), "all captured source bytes")
|
||||
if kind == "checker":
|
||||
path = "crates/oa-core/examples/process_multiselect_native.rs"
|
||||
need(raw(spec["checkerSource"]) == archive.read(path), "historical checker source")
|
||||
artifacts = [row for row in result["artifacts"] if row["cargo"]["target"]["name"] == target]
|
||||
need(len(artifacts) == 1 and artifacts[0]["readback"] == recorded["pin"] and artifacts[0]["cargo"]["executable"] == recorded["binary"] and artifacts[0]["cargo"]["profile"]["test"] is False, "native executable provenance")
|
||||
stdout_rows = [parsed(line) for line in raw(b["stdout.log"]).splitlines() if line.strip().startswith(b"{")]
|
||||
need(artifacts[0]["cargo"] in stdout_rows, "artifact appears in raw Cargo output")
|
||||
build_maps[kind] = inputs
|
||||
need(prepared["gui"]["inputs"] == prepared["checker"]["inputs"] and build_maps["gui"] == build_maps["checker"], "same GUI/checker source map")
|
||||
top = {name[len(prefix):] for name in members if name.startswith(prefix) and "/" not in name[len(prefix):]}
|
||||
base = BASE | (top & {"history-bytes.json", "observations.md"})
|
||||
if "observations.md" in top:
|
||||
raw(prefix + "observations.md")
|
||||
captures = sorted(name for name in top - base if name.endswith(".json"))
|
||||
need(captures, "native captures required")
|
||||
expected_top = base | set(captures)
|
||||
for name in captures:
|
||||
capture = obj(prefix + name)
|
||||
need(ordered(start["start"], capture["time"], end["finish"]), "capture lifetime")
|
||||
window = capture["window"]
|
||||
need(norm(window["app"]) == "process:" + norm(prepared["gui"]["binary"]) and type(window["id"]) is int and window["id"] > 0, "captured executable/window")
|
||||
need(isinstance(capture["accessibility"], dict) and isinstance(capture["images"], list), "accessibility/capture payload")
|
||||
for image in capture["images"]:
|
||||
filename = image["file"]
|
||||
need(safe(filename) and "/" not in filename and filename.startswith(name[:-5] + "-") and filename.endswith((".jpg", ".png")) and filename not in expected_top, "unique image mapping")
|
||||
need(all(type(image[k]) is int and image[k] > 0 for k in ("width", "height")), "image dimensions")
|
||||
expected_top.add(filename)
|
||||
data = raw(prefix + filename)
|
||||
need(data.startswith(b"\xff\xd8") if filename.endswith(".jpg") else data.startswith(b"\x89PNG\r\n\x1a\n"), "image signature")
|
||||
need(top == expected_top, "exact top-level run files")
|
||||
labels = {name[len(prefix + "snapshots/"):].split("/")[0] for name in members if name.startswith(prefix + "snapshots/")}
|
||||
states, results, positive = {}, {}, []
|
||||
for label in sorted(labels):
|
||||
need(re.fullmatch(r"[a-z0-9-]+", label), "snapshot label")
|
||||
folder = prefix + "snapshots/" + label + "/"
|
||||
began, result = obj(folder + "started.json"), obj(folder + "result.json")
|
||||
need(all(result.get(k) == v for k, v in began.items()), "snapshot start/result")
|
||||
need(result["prepared"] == start["prepared"] and result["checker"] == prepared["checker"] and result["helper"] == prepared["helpers"]["snapshot.py"], "snapshot provenance")
|
||||
need(ordered(start["start"], result["start"], result["finish"], end["finish"]), "snapshot lifetime")
|
||||
mode, command = result["mode"], result["command"]
|
||||
need(mode in ("unchanged", "reordered") and len(command) == 5 and command[:3] == [prepared["checker"]["binary"], "check", mode] and norm(command[3]) == origin + "/fixture/baseline.json" and norm(command[4]) == origin + "/snapshots/" + label + "/source/" + prepared["entry"], "snapshot command")
|
||||
need(result["baseline"] == pin(fixture["baseline.json"]), "snapshot baseline")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(result[stream] == pin(raw(folder + stream + ".log")), "snapshot log pin")
|
||||
need(type(result["exitCode"]) is int and all(type(result[k]) is bool for k in ("binaryUnchanged", "workingSourceUnchanged", "snapshotUnchanged", "baselineUnchanged")), "snapshot closure")
|
||||
states[label] = pinned_tree(folder + "source/", result["source"])
|
||||
need({name for name in members if name.startswith(folder)} == {folder + n for n in ("started.json", "result.json", "stdout.log", "stderr.log")} | {folder + "source/" + n for n in states[label]}, "exact snapshot files")
|
||||
if result["exitCode"] == 0:
|
||||
report = obj(folder + "stdout.log")
|
||||
need(report["result"] == "PASS" and report["comparison"] == "complete Project equality; literal expected order" and report["mode"] == mode and report["baseline"] == command[3] and report["saved"] == command[4], "positive complete Project report")
|
||||
positive.append(label)
|
||||
results[label] = result
|
||||
history_present = "history-bytes.json" in top
|
||||
if history_present:
|
||||
history = obj(prefix + "history-bytes.json")
|
||||
groups = history["equalByteGroups"]
|
||||
need(len(groups) == 2 and groups[0][:2] == ["baseline", "undo"] and groups[1] == ["reordered", "redo", "reopened"], "literal Process history groups")
|
||||
history_labels = [label for group in groups for label in group]
|
||||
need(len(history_labels) == len(set(history_labels)) and set(history_labels) == set(history["snapshots"]) and set(history_labels) <= set(positive), "history labels/positive reports")
|
||||
need(history["status"] == "PASS" and history["prepared"] == start["prepared"] and history["helper"] == prepared["helpers"]["history-bytes.py"] and ordered(end["finish"], history["verified"]), "history provenance")
|
||||
need(end["exitCode"] == 0 and all(end[k] is True for k in ("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), "history clean closure")
|
||||
for group, mode in zip(groups, ("unchanged", "reordered")):
|
||||
for label in group:
|
||||
need(states[label] == states[group[0]] and results[label]["mode"] == mode and history["snapshots"][label] == pin(raw(prefix + "snapshots/" + label + "/result.json")), "literal history bytes/receipt")
|
||||
need(all(results[label][k] is True for k in ("binaryUnchanged", "workingSourceUnchanged", "snapshotUnchanged", "baselineUnchanged")), "stable history snapshot")
|
||||
need(len(states[label]) == history["sourceFilesPerSnapshot"], "history source count")
|
||||
need(states["baseline"] == fixture_source and states["baseline"] != states["reordered"], "baseline exact and reorder changed")
|
||||
changed = sorted(name for name in set(states["baseline"]) | set(states["reordered"]) if states["baseline"].get(name) != states["reordered"].get(name))
|
||||
need(changed == history["changedFilesFromBaseline"] == ["oa/behavior/process_oa_process.arcadia"], "only intended OA source changed")
|
||||
sequence = [results[label] for label in ("baseline", "reordered", "undo", "redo", "reopened")]
|
||||
need(all(ordered(a["finish"], b["start"]) for a, b in zip(sequence, sequence[1:])), "history chronology")
|
||||
summaries[run] = {"reviewStatus": spec["reviewStatus"], "exitCode": end["exitCode"], "captures": len(captures), "snapshots": len(labels), "positiveReports": len(positive), "historyBytes": history_present}
|
||||
need(used == set(members), "unreferenced package members: " + repr(set(members) - used))
|
||||
return summaries
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER, "outer files")
|
||||
for name, expected in files.items():
|
||||
need(pin((root / name).read_bytes()) == expected, "outer pin: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
zip_inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS byte/receipt consistency only:", verify_package(args.package))
|
||||
print("No GUI/checker execution or native acceptance claim; see independent parent review.")
|
||||
@@ -0,0 +1 @@
|
||||
* -text
|
||||
@@ -0,0 +1,31 @@
|
||||
# Process run-2 preparation failure
|
||||
|
||||
These are the exact three files retained from
|
||||
`.local/process-multiselect-native/run-2`: generation.json and its empty stdout
|
||||
and stderr logs. Fixture generation exited **3221225781 (0xC0000135)** before
|
||||
preparation completed. There was no GUI launch, prepared.json, started.json or
|
||||
finished.json. This is a failed preparation receipt, not a native run or a
|
||||
failed complete-Project comparison.
|
||||
|
||||
The checker came from `native-build-2`, source commit
|
||||
`b743a10d51032dc1655b94fed5e49fbbbcee83f4`. The parent diagnosed the omitted
|
||||
toolchain runtime environment in its preparation shell; Windows reported the
|
||||
missing-DLL status. The parent subsequently retried with that environment in
|
||||
run-3 successfully. That diagnosis and retry outcome are parent observations;
|
||||
the raw run-2 receipt records the exit code, executable/build pins and empty
|
||||
streams, and does not record its environment or name a missing DLL. Run-3's
|
||||
separate native evidence owns its acceptance claims.
|
||||
|
||||
Verify with Python's standard library, using the portable sibling checks:
|
||||
|
||||
```text
|
||||
python -B verify.py --checks ../checks
|
||||
```
|
||||
|
||||
The verifier checks exact raw/outer bytes, failed generation command and
|
||||
chronology, empty stream pins, and the checker's precise build receipt, input
|
||||
map and Cargo executable/readback relationship. It also checks the shared
|
||||
source map against the sibling source proof for b743a10. Run the checks and
|
||||
source verifiers separately for their complete gate/source verification.
|
||||
No binary, capture helper or GUI is executed. Pins establish consistency,
|
||||
not independent authentication or a reproduction of the environment failure.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user