Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c61d0d716 | ||
|
|
a337dd4bf0 | ||
|
|
980934bf63 | ||
|
|
785823a5da | ||
|
|
b5db730941 | ||
|
|
1f745cc518 | ||
|
|
04210b3092 | ||
|
|
b445167e7d | ||
|
|
8fc3ba3531 | ||
|
|
ba9c11717e | ||
|
|
62b432861b | ||
|
|
2c495c99b4 | ||
|
|
660248f087 | ||
|
|
43dd566939 | ||
|
|
bd098ee6b7 |
Generated
+6
@@ -2155,10 +2155,16 @@ dependencies = [
|
|||||||
name = "oa-desktop"
|
name = "oa-desktop"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"axum",
|
||||||
"iced",
|
"iced",
|
||||||
|
"iced_runtime",
|
||||||
"oa-core",
|
"oa-core",
|
||||||
|
"oa-server",
|
||||||
|
"reqwest",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
|
|||||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
@@ -102,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
|||||||
|
|
||||||
## Save and reopen
|
## Save and reopen
|
||||||
|
|
||||||
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||||
|
|
||||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
||||||
|
|
||||||
@@ -118,14 +118,18 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
|
|||||||
|
|
||||||
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
|
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
|
||||||
|
|
||||||
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
|
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
|
||||||
|
|
||||||
|
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
|
||||||
|
|
||||||
|
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
||||||
-H 'Content-Type: application/json' --data-binary @system.json
|
-H 'Content-Type: application/json' --data-binary @system.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||||
|
|
||||||
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
|
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
|
||||||
|
|
||||||
@@ -142,4 +146,4 @@ cargo build --release --workspace --locked
|
|||||||
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
|
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
|
||||||
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
|
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
|
||||||
|
|
||||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
||||||
use super::historical::keyed_slots_overlap;
|
use super::historical::{keyed_slots_overlap, selector_slots};
|
||||||
use super::mutate::*;
|
use super::mutate::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
|
|||||||
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
||||||
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
||||||
}
|
}
|
||||||
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
|
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
|
||||||
match selector {
|
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
|
||||||
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
|
match layer {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
Layer::Oa => &review.oa,
|
||||||
|
Layer::Sa => &review.sa,
|
||||||
}
|
}
|
||||||
FacetSelector::Literal(keys) => {
|
}
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
match fact {
|
||||||
|
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
|
||||||
|
matches!(observed(review, layer), Observed::Value(true))
|
||||||
}
|
}
|
||||||
FacetSelector::Annotation(keys) => {
|
FacetReview::Annotation { review, .. } => {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
|
}
|
||||||
|
FacetReview::Binding { review, .. } => {
|
||||||
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
|
|||||||
source_layer: Layer,
|
source_layer: Layer,
|
||||||
target_layer: Layer,
|
target_layer: Layer,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// A correspondence key can exist without membership on that side (for
|
||||||
|
// example an OA literal automatically projects the same SA literal key).
|
||||||
|
// Preserve actual target-only values, except previously reviewed removals.
|
||||||
for fact in initial_facts {
|
for fact in initial_facts {
|
||||||
if !constructor_contains(group, &fact.selector)
|
if !constructor_contains(group, &fact.selector)
|
||||||
|| !target_only(&fact.selector, source_layer, target_layer)
|
|| member_present(&fact.current, source_layer)
|
||||||
|
|| !member_present(&fact.current, target_layer)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let slot = target_slot(target, &fact.selector, target_layer)?;
|
let slot = target_slot(target, &fact.selector, target_layer)?;
|
||||||
let was_corresponding = baseline.facets.iter().any(|old| {
|
let was_corresponding = baseline.facets.iter().any(|old| {
|
||||||
!target_only(&old.selector(), source_layer, target_layer)
|
member_present(old, source_layer)
|
||||||
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
||||||
});
|
});
|
||||||
if was_corresponding {
|
if was_corresponding {
|
||||||
@@ -723,12 +733,28 @@ fn build_review(
|
|||||||
constructor_actions.push((target, selected.clone(), local_record));
|
constructor_actions.push((target, selected.clone(), local_record));
|
||||||
}
|
}
|
||||||
let refreshed = semantic_facets(observed, &trace, &references)?;
|
let refreshed = semantic_facets(observed, &trace, &references)?;
|
||||||
let group_selectors: BTreeSet<_> = refreshed
|
let mut group_selectors: BTreeSet<_> = refreshed
|
||||||
.iter()
|
.iter()
|
||||||
.chain(all.iter())
|
.chain(all.iter())
|
||||||
.map(|f| f.selector.clone())
|
.map(|f| f.selector.clone())
|
||||||
.filter(|f| constructor_contains(selected, f))
|
.filter(|f| constructor_contains(selected, f))
|
||||||
.collect();
|
.collect();
|
||||||
|
// A constructor copy can turn a live one-sided member into a
|
||||||
|
// pair. Its initial projection is not another reviewed fact.
|
||||||
|
// Established one-sided history still requires an explicit reset.
|
||||||
|
let paired_sides: BTreeSet<_> = group_selectors
|
||||||
|
.iter()
|
||||||
|
.map(selector_slots)
|
||||||
|
.filter(|slots| slots.len() == 2)
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
group_selectors.retain(|selector| {
|
||||||
|
!paired_sides.contains(selector)
|
||||||
|
|| baseline
|
||||||
|
.facets
|
||||||
|
.iter()
|
||||||
|
.any(|old| old.selector() == *selector)
|
||||||
|
});
|
||||||
let mut group = vec![];
|
let mut group = vec![];
|
||||||
for selector in group_selectors {
|
for selector in group_selectors {
|
||||||
match capture_facet(observed, oa, sa, &selector) {
|
match capture_facet(observed, oa, sa, &selector) {
|
||||||
|
|||||||
@@ -395,11 +395,30 @@ pub fn plan_transition(
|
|||||||
commands: vec![],
|
commands: vec![],
|
||||||
};
|
};
|
||||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
let mut omitted = if request.direction == TransitionDirection::SaToOa {
|
||||||
sa_port_omissions(project)
|
sa_port_omissions(project)
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
BTreeSet::new()
|
||||||
};
|
};
|
||||||
|
// An explicit existing Legacy counterpart can be compared and reviewed
|
||||||
|
// without inventing an OA typed port. Only this port is retained: dependent
|
||||||
|
// SA implementation records and new counterpart requests stay omitted.
|
||||||
|
for mapping in &request.mappings {
|
||||||
|
if project
|
||||||
|
.elements
|
||||||
|
.get(&mapping.source)
|
||||||
|
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
|
||||||
|
&& mapping
|
||||||
|
.target
|
||||||
|
.and_then(|target| project.elements.get(&target))
|
||||||
|
.is_some_and(|e| {
|
||||||
|
e.layer() == Layer::Oa
|
||||||
|
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
omitted.remove(&mapping.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut reported_omissions = BTreeSet::new();
|
let mut reported_omissions = BTreeSet::new();
|
||||||
if selected.len() != request.sources.len() {
|
if selected.len() != request.sources.len() {
|
||||||
conflict(
|
conflict(
|
||||||
|
|||||||
@@ -1917,3 +1917,395 @@ fn retired_source_branch_retains_historical_member_pair_in_both_directions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructor_closure_preserves_target_only_literals_in_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
|
||||||
|
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
|
||||||
|
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||||
|
enumeration(&mut p, target, &["local"]);
|
||||||
|
let t = trace(&mut p, oa, sa);
|
||||||
|
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
|
||||||
|
p.traces
|
||||||
|
.get_mut(&t)
|
||||||
|
.unwrap()
|
||||||
|
.baseline
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.semantic = Some(SemanticBaseline {
|
||||||
|
facets: facts.into_iter().map(|f| f.current).collect(),
|
||||||
|
});
|
||||||
|
enumeration(&mut p, source, &["incoming"]);
|
||||||
|
let mut r = request(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
vec![(
|
||||||
|
FacetSelector::Data(DataFacet::Constructor),
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
if reverse {
|
||||||
|
r.transition.direction = TransitionDirection::SaToOa;
|
||||||
|
}
|
||||||
|
let q = applied(&p, r);
|
||||||
|
assert!(
|
||||||
|
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
|
||||||
|
definition: DataTypeDefinition::Enumeration { literals }, ..
|
||||||
|
}) if literals == &["incoming", "local"]),
|
||||||
|
"reverse={reverse}"
|
||||||
|
);
|
||||||
|
let base = q.traces[&t]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
validate_semantic_baseline(base).unwrap();
|
||||||
|
let incoming = FacetSelector::Literal(KeyPair {
|
||||||
|
oa: Some("incoming".into()),
|
||||||
|
sa: Some("incoming".into()),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
base.facets
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.selector() == incoming)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||||
|
assert!(
|
||||||
|
facts
|
||||||
|
.iter()
|
||||||
|
.filter(|f| matches!(f.selector, FacetSelector::Literal(_)))
|
||||||
|
.all(|f| f.drift == Drift::Unchanged)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructor_closure_pairs_signal_members_and_requires_history_reset_in_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
for historical in [false, true] {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let oa = add(&mut p, Kind::Event, Layer::Oa, "oa");
|
||||||
|
let sa = add(&mut p, Kind::Event, Layer::Sa, "sa");
|
||||||
|
let oa_item = add(&mut p, Kind::ExchangeItem, Layer::Oa, "oa_item");
|
||||||
|
let sa_item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "sa_item");
|
||||||
|
let (source, target, source_item) = if reverse {
|
||||||
|
(sa, oa, sa_item)
|
||||||
|
} else {
|
||||||
|
(oa, sa, oa_item)
|
||||||
|
};
|
||||||
|
set(
|
||||||
|
&mut p,
|
||||||
|
source,
|
||||||
|
SharedBody::Event {
|
||||||
|
body: EventBody::Signal {
|
||||||
|
items: vec![source_item],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
set(
|
||||||
|
&mut p,
|
||||||
|
target,
|
||||||
|
SharedBody::Event {
|
||||||
|
body: EventBody::Change {
|
||||||
|
expression: "old".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let t = trace(&mut p, oa, sa);
|
||||||
|
let previous = FacetSelector::Member {
|
||||||
|
collection: ReferenceCollection::SignalItems,
|
||||||
|
keys: if reverse {
|
||||||
|
KeyPair {
|
||||||
|
oa: None,
|
||||||
|
sa: Some(sa_item),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
KeyPair {
|
||||||
|
oa: Some(oa_item),
|
||||||
|
sa: None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if historical {
|
||||||
|
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
|
||||||
|
let old = facts
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| f.selector == previous)
|
||||||
|
.unwrap()
|
||||||
|
.current;
|
||||||
|
p.traces
|
||||||
|
.get_mut(&t)
|
||||||
|
.unwrap()
|
||||||
|
.baseline
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.semantic = Some(SemanticBaseline { facets: vec![old] });
|
||||||
|
}
|
||||||
|
let mut r = request(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
vec![(
|
||||||
|
FacetSelector::Event(EventFacet::Constructor),
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
if reverse {
|
||||||
|
r.transition.direction = TransitionDirection::SaToOa;
|
||||||
|
}
|
||||||
|
r.references.insert(oa_item, sa_item);
|
||||||
|
if historical {
|
||||||
|
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!invalid.conflicts.is_empty(),
|
||||||
|
"Established one-sided history requires reset: reverse={reverse}"
|
||||||
|
);
|
||||||
|
r.reviews[0].reset.push(previous);
|
||||||
|
}
|
||||||
|
let q = applied(&p, r);
|
||||||
|
let target_item = if reverse { oa_item } else { sa_item };
|
||||||
|
assert!(
|
||||||
|
matches!(q.elements[&target].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if items == &[target_item])
|
||||||
|
);
|
||||||
|
let base = q.traces[&t]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
validate_semantic_baseline(base).unwrap();
|
||||||
|
let members: Vec<_> = base
|
||||||
|
.facets
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
matches!(
|
||||||
|
f.selector(),
|
||||||
|
FacetSelector::Member {
|
||||||
|
collection: ReferenceCollection::SignalItems,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
members.len(),
|
||||||
|
1,
|
||||||
|
"reverse={reverse}, historical={historical}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
members[0].selector(),
|
||||||
|
FacetSelector::Member {
|
||||||
|
collection: ReferenceCollection::SignalItems,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: Some(oa_item),
|
||||||
|
sa: Some(sa_item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validation::validate(&q)
|
||||||
|
.iter()
|
||||||
|
.all(|issue| issue.severity != validation::Severity::Error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructor_closure_does_not_restore_previously_corresponding_removed_members() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
|
||||||
|
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
|
||||||
|
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||||
|
enumeration(&mut p, source, &["paired"]);
|
||||||
|
let t = trace(&mut p, oa, sa);
|
||||||
|
let constructor = FacetSelector::Data(DataFacet::Constructor);
|
||||||
|
let directed = |facets| {
|
||||||
|
let mut r = request(source, target, facets);
|
||||||
|
if reverse {
|
||||||
|
r.transition.direction = TransitionDirection::SaToOa;
|
||||||
|
}
|
||||||
|
r
|
||||||
|
};
|
||||||
|
p = applied(
|
||||||
|
&p,
|
||||||
|
directed(vec![(constructor.clone(), SemanticDecision::KeepTarget)]),
|
||||||
|
);
|
||||||
|
if reverse {
|
||||||
|
// Explicitly establish the counterpart key while its target branch
|
||||||
|
// is absent, rather than silently changing the one-sided history.
|
||||||
|
let mut r = directed(vec![(
|
||||||
|
FacetSelector::Literal(KeyPair {
|
||||||
|
oa: Some("paired".into()),
|
||||||
|
sa: Some("paired".into()),
|
||||||
|
}),
|
||||||
|
SemanticDecision::KeepTarget,
|
||||||
|
)]);
|
||||||
|
r.reviews[0].reset.push(FacetSelector::Literal(KeyPair {
|
||||||
|
oa: None,
|
||||||
|
sa: Some("paired".into()),
|
||||||
|
}));
|
||||||
|
p = applied(&p, r);
|
||||||
|
}
|
||||||
|
enumeration(&mut p, source, &["incoming"]);
|
||||||
|
enumeration(&mut p, target, &["paired", "local"]);
|
||||||
|
let q = applied(
|
||||||
|
&p,
|
||||||
|
directed(vec![(constructor, SemanticDecision::TakeSource)]),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
|
||||||
|
definition: DataTypeDefinition::Enumeration { literals }, ..
|
||||||
|
}) if literals == &["incoming", "local"]),
|
||||||
|
"reverse={reverse}"
|
||||||
|
);
|
||||||
|
validate_semantic_baseline(
|
||||||
|
q.traces[&t]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reverse_legacy_port_fixture() -> (Project, Id, Id, Id) {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let oa_owner = add(&mut p, Kind::Actor, Layer::Oa, "oa owner");
|
||||||
|
let sa_owner = add(&mut p, Kind::SystemActor, Layer::Sa, "sa owner");
|
||||||
|
let oa = Id::new_v4();
|
||||||
|
let sa = Id::new_v4();
|
||||||
|
let mut legacy = make_port(&p, oa, oa_owner, "oa port", PortContract::Legacy).unwrap();
|
||||||
|
*legacy.shared_mut().unwrap() = SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
p.elements.insert(oa, legacy);
|
||||||
|
let typed = make_port(
|
||||||
|
&p,
|
||||||
|
sa,
|
||||||
|
sa_owner,
|
||||||
|
"sa port",
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::Out,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
p.elements.insert(sa, typed);
|
||||||
|
let t = trace(&mut p, oa, sa);
|
||||||
|
(p, oa, sa, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverse_existing_port_review_retains_legacy_and_roundtrips_typed_history() {
|
||||||
|
for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] {
|
||||||
|
let (p, oa, sa, t) = reverse_legacy_port_fixture();
|
||||||
|
let mut r = request(
|
||||||
|
sa,
|
||||||
|
oa,
|
||||||
|
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
|
||||||
|
);
|
||||||
|
r.transition.direction = TransitionDirection::SaToOa;
|
||||||
|
r.traces.insert(sa, t);
|
||||||
|
let expected_direction = if decision == SemanticDecision::Candidate {
|
||||||
|
let mut candidate = p.elements[&oa].clone();
|
||||||
|
let Some(SharedBody::Port {
|
||||||
|
legacy: Some(old), ..
|
||||||
|
}) = candidate.shared_mut()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
old.direction = PortDirection::In;
|
||||||
|
r.candidates.push(SemanticRecord::Element(candidate));
|
||||||
|
PortDirection::In
|
||||||
|
} else {
|
||||||
|
PortDirection::InOut
|
||||||
|
};
|
||||||
|
let q = applied(&p, r);
|
||||||
|
assert_eq!(q.elements[&sa], p.elements[&sa]);
|
||||||
|
assert_eq!(q.elements.len(), p.elements.len());
|
||||||
|
assert!(matches!(q.elements[&oa].shared(), Some(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, interface: None }),
|
||||||
|
}) if *direction == expected_direction));
|
||||||
|
let baseline = q.traces[&t]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
validate_semantic_baseline(baseline).unwrap();
|
||||||
|
assert!(baseline.facets.iter().any(|f| matches!(
|
||||||
|
f,
|
||||||
|
FacetReview::Port(PortReview::Constructor(Reviewed {
|
||||||
|
oa: Observed::Value(PortConstructor::Legacy),
|
||||||
|
sa: Observed::Value(PortConstructor::Component),
|
||||||
|
}))
|
||||||
|
)));
|
||||||
|
assert_eq!(language::parse(&language::format(&q)).unwrap(), q);
|
||||||
|
assert_eq!(
|
||||||
|
persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(),
|
||||||
|
q
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverse_existing_port_review_rejects_typed_oa_proposals_without_mutation() {
|
||||||
|
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
|
||||||
|
let (p, oa, sa, _) = reverse_legacy_port_fixture();
|
||||||
|
let snapshot = p.clone();
|
||||||
|
let mut r = request(
|
||||||
|
sa,
|
||||||
|
oa,
|
||||||
|
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
|
||||||
|
);
|
||||||
|
r.transition.direction = TransitionDirection::SaToOa;
|
||||||
|
if decision == SemanticDecision::Candidate {
|
||||||
|
let mut candidate = p.elements[&oa].clone();
|
||||||
|
*candidate.shared_mut().unwrap() = p.elements[&sa].shared().unwrap().clone();
|
||||||
|
r.candidates.push(SemanticRecord::Element(candidate));
|
||||||
|
}
|
||||||
|
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||||
|
assert!(!plan.conflicts.is_empty());
|
||||||
|
assert!(
|
||||||
|
plan.conflicts
|
||||||
|
.iter()
|
||||||
|
.all(|c| !c.contains("outside selected transition")),
|
||||||
|
"Must reach structural validation: {:?}",
|
||||||
|
plan.conflicts
|
||||||
|
);
|
||||||
|
assert!(prepare_semantic_transition(&p, &plan).is_err());
|
||||||
|
assert_eq!(p, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverse_new_port_counterpart_remains_an_explicit_sa_only_omission() {
|
||||||
|
let (p, _, sa, _) = reverse_legacy_port_fixture();
|
||||||
|
let mut r = TransitionRequest::new(TransitionDirection::SaToOa, vec![sa]);
|
||||||
|
r.mappings.push(TransitionMapping {
|
||||||
|
source: sa,
|
||||||
|
target: None,
|
||||||
|
target_kind: Some(Kind::Port),
|
||||||
|
});
|
||||||
|
let plan = plan_transition(&p, &r).unwrap();
|
||||||
|
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||||
|
assert!(plan.changes.is_empty());
|
||||||
|
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
|
||||||
|
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,5 +13,14 @@ path = "src/main.rs"
|
|||||||
oa-core = { path = "../oa-core" }
|
oa-core = { path = "../oa-core" }
|
||||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["rt", "time"] }
|
tokio = { version = "1", features = ["rt", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
iced_runtime = "0.14"
|
||||||
|
oa-server = { path = "../oa-server" }
|
||||||
|
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod hosted;
|
||||||
use crate::forms::Draft;
|
use crate::forms::Draft;
|
||||||
use crate::{
|
use crate::{
|
||||||
document::Document,
|
document::Document,
|
||||||
@@ -280,6 +281,7 @@ impl std::fmt::Display for Choice {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Pending {
|
pub enum Pending {
|
||||||
|
HostedOpen,
|
||||||
New,
|
New,
|
||||||
Demo,
|
Demo,
|
||||||
Open,
|
Open,
|
||||||
@@ -299,6 +301,7 @@ pub struct FilePrompt {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
|
Hosted(crate::hosted::Event),
|
||||||
PortAnchor(crate::port_edit::Event),
|
PortAnchor(crate::port_edit::Event),
|
||||||
Connection(crate::connection_edit::Event),
|
Connection(crate::connection_edit::Event),
|
||||||
Menu(crate::model_menu::Event),
|
Menu(crate::model_menu::Event),
|
||||||
@@ -410,6 +413,7 @@ pub enum Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
pub hosted: crate::hosted::State,
|
||||||
pub connection: Option<crate::connection_edit::Session>,
|
pub connection: Option<crate::connection_edit::Session>,
|
||||||
pub model_menu: Option<crate::model_menu::Opened>,
|
pub model_menu: Option<crate::model_menu::Opened>,
|
||||||
pub source_tree: crate::source_tree::State,
|
pub source_tree: crate::source_tree::State,
|
||||||
@@ -525,6 +529,7 @@ impl App {
|
|||||||
Kind::Activity
|
Kind::Activity
|
||||||
},
|
},
|
||||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||||
|
hosted: Default::default(),
|
||||||
busy: false,
|
busy: false,
|
||||||
pending: None,
|
pending: None,
|
||||||
delete_pending: false,
|
delete_pending: false,
|
||||||
@@ -844,7 +849,10 @@ impl App {
|
|||||||
let source_version = self.source_version + 1;
|
let source_version = self.source_version + 1;
|
||||||
let lint_running = self.lint_running;
|
let lint_running = self.lint_running;
|
||||||
let guide = std::mem::take(&mut self.guide);
|
let guide = std::mem::take(&mut self.guide);
|
||||||
|
let mut hosted = std::mem::take(&mut self.hosted);
|
||||||
|
hosted.leave_document();
|
||||||
*self = Self::with_project(project);
|
*self = Self::with_project(project);
|
||||||
|
self.hosted = hosted;
|
||||||
self.window_size = window_size;
|
self.window_size = window_size;
|
||||||
self.canvas_size = canvas_size;
|
self.canvas_size = canvas_size;
|
||||||
self.source_version = source_version;
|
self.source_version = source_version;
|
||||||
@@ -906,8 +914,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn view(&self) -> Element<'_, Message> {
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
let base = crate::context_menu::Popup::new(
|
||||||
.into()
|
crate::views::view(self),
|
||||||
|
crate::model_menu::popup(self),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
crate::hosted::view::overlay(self, base)
|
||||||
}
|
}
|
||||||
pub fn subscription(&self) -> Subscription<Message> {
|
pub fn subscription(&self) -> Subscription<Message> {
|
||||||
Subscription::batch([
|
Subscription::batch([
|
||||||
@@ -1019,6 +1031,7 @@ impl App {
|
|||||||
&& !matches!(
|
&& !matches!(
|
||||||
message,
|
message,
|
||||||
Message::Saved(_)
|
Message::Saved(_)
|
||||||
|
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||||
| Message::Loaded(_)
|
| Message::Loaded(_)
|
||||||
| Message::IncludesLoaded(_, _)
|
| Message::IncludesLoaded(_, _)
|
||||||
| Message::SavePath(_)
|
| Message::SavePath(_)
|
||||||
@@ -1038,6 +1051,10 @@ impl App {
|
|||||||
message,
|
message,
|
||||||
Message::Escape | Message::Request(Pending::Close(_))
|
Message::Escape | Message::Request(Pending::Close(_))
|
||||||
) {
|
) {
|
||||||
|
if self.hosted.visible {
|
||||||
|
self.hosted.visible = false;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
if self.workflow.take().is_some() {
|
if self.workflow.take().is_some() {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -1062,6 +1079,7 @@ impl App {
|
|||||||
| Message::Lint(_)
|
| Message::Lint(_)
|
||||||
| Message::Linted(_, _)
|
| Message::Linted(_, _)
|
||||||
| Message::Saved(_)
|
| Message::Saved(_)
|
||||||
|
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||||
| Message::Loaded(_)
|
| Message::Loaded(_)
|
||||||
| Message::IncludesLoaded(_, _)
|
| Message::IncludesLoaded(_, _)
|
||||||
| Message::SavePath(_)
|
| Message::SavePath(_)
|
||||||
@@ -1069,7 +1087,8 @@ impl App {
|
|||||||
| Message::Exported(_)
|
| Message::Exported(_)
|
||||||
);
|
);
|
||||||
if !modal_event
|
if !modal_event
|
||||||
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|
||||||
|
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||||
|| (self.file_prompt.is_some()
|
|| (self.file_prompt.is_some()
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
message,
|
message,
|
||||||
@@ -1187,6 +1206,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::Hosted(event) => return self.hosted_event(event),
|
||||||
Message::ScenarioCanvas(event) => {
|
Message::ScenarioCanvas(event) => {
|
||||||
use crate::behavior::scenario::{self, CanvasEvent};
|
use crate::behavior::scenario::{self, CanvasEvent};
|
||||||
let context = event.context();
|
let context = event.context();
|
||||||
@@ -2490,6 +2510,9 @@ impl App {
|
|||||||
return self.handle(Message::Save(false));
|
return self.handle(Message::Save(false));
|
||||||
}
|
}
|
||||||
Message::Save(save_as) => {
|
Message::Save(save_as) => {
|
||||||
|
if !save_as && self.hosted.session.is_some() {
|
||||||
|
return self.save_hosted();
|
||||||
|
}
|
||||||
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||||
self.continue_after_save = false;
|
self.continue_after_save = false;
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
@@ -2541,6 +2564,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.disk_project = Some(disk);
|
self.disk_project = Some(disk);
|
||||||
self.path = Some(receipt.path);
|
self.path = Some(receipt.path);
|
||||||
|
self.hosted.session = None;
|
||||||
|
self.hosted.review = None;
|
||||||
|
self.hosted.uncertain = None;
|
||||||
self.document.mark_saved(receipt.snapshot);
|
self.document.mark_saved(receipt.snapshot);
|
||||||
self.prune_closed_files(!self.document.dirty());
|
self.prune_closed_files(!self.document.dirty());
|
||||||
self.status = "All source files saved".into();
|
self.status = "All source files saved".into();
|
||||||
@@ -2879,6 +2905,7 @@ impl App {
|
|||||||
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
match pending {
|
match pending {
|
||||||
|
Pending::HostedOpen => self.open_hosted(),
|
||||||
Pending::New => {
|
Pending::New => {
|
||||||
self.reset(Project::empty(), None);
|
self.reset(Project::empty(), None);
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -3037,6 +3064,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
// Installing a local repair workspace changes its destination,
|
||||||
|
// even while the last valid graphical model stays visible. A
|
||||||
|
// failed/cancelled Open that installs nothing retains its binding.
|
||||||
|
self.hosted.leave_document();
|
||||||
self.canvas_document = Id::new_v4();
|
self.canvas_document = Id::new_v4();
|
||||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
self.path = Some(path);
|
self.path = Some(path);
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
|
||||||
|
use super::*;
|
||||||
|
use crate::hosted::{
|
||||||
|
Event, Operation, Outcome, Request, Review, Session,
|
||||||
|
client::{Connection, Failure, Remote},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn hosted_label(&self) -> String {
|
||||||
|
self.hosted.session.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
if self.dirty() {
|
||||||
|
"Unsaved changes".into()
|
||||||
|
} else if self.path.is_some() {
|
||||||
|
"All changes saved".into()
|
||||||
|
} else {
|
||||||
|
"Local project".into()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|session| {
|
||||||
|
format!(
|
||||||
|
"Hosted · revision {} · {}",
|
||||||
|
session.revision,
|
||||||
|
if self.dirty() {
|
||||||
|
"local changes"
|
||||||
|
} else {
|
||||||
|
"saved"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_label(&self) -> &'static str {
|
||||||
|
if self.hosted.session.is_some() {
|
||||||
|
"Save to server"
|
||||||
|
} else {
|
||||||
|
"Save all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hosted_problem(&mut self, detail: impl Into<String>) {
|
||||||
|
let detail = detail.into();
|
||||||
|
self.status = detail.clone();
|
||||||
|
self.hosted.error = Some(Failure {
|
||||||
|
detail,
|
||||||
|
code: "desktop_hosted".into(),
|
||||||
|
status: None,
|
||||||
|
uncertain: false,
|
||||||
|
published: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hosted_ready(&mut self) -> bool {
|
||||||
|
if self.pending_recovery.is_some() {
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
self.hosted.visible = false;
|
||||||
|
self.page = Page::Text;
|
||||||
|
self.status =
|
||||||
|
"Recover the interrupted source save before changing storage destinations.".into();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||||
|
self.hosted.visible = false;
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.source_workspace.detached_dirty() {
|
||||||
|
self.hosted.visible = false;
|
||||||
|
self.page = Page::Text;
|
||||||
|
self.status =
|
||||||
|
"Reattach or revert detached source drafts before saving to a server.".into();
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hosted_start(
|
||||||
|
&mut self,
|
||||||
|
operation: Operation,
|
||||||
|
connection: Connection,
|
||||||
|
resource_id: Option<Id>,
|
||||||
|
) -> Task<Message> {
|
||||||
|
let request = Request {
|
||||||
|
id: Id::new_v4(),
|
||||||
|
document: self.canvas_document,
|
||||||
|
revision: self.document.revision(),
|
||||||
|
operation,
|
||||||
|
connection,
|
||||||
|
resource_id,
|
||||||
|
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
|
||||||
|
snapshot: self.document.snapshot(),
|
||||||
|
};
|
||||||
|
self.hosted.request = Some(request.clone());
|
||||||
|
if operation != Operation::Check {
|
||||||
|
self.hosted.error = None;
|
||||||
|
}
|
||||||
|
self.busy = true;
|
||||||
|
self.status = match operation {
|
||||||
|
Operation::Catalog(_) => "Reading hosted models…",
|
||||||
|
Operation::Open(_) => "Opening hosted model…",
|
||||||
|
Operation::Publish => "Publishing source project…",
|
||||||
|
Operation::Save => "Saving to server…",
|
||||||
|
Operation::Check => "Checking current server revision…",
|
||||||
|
}
|
||||||
|
.into();
|
||||||
|
let query = self.hosted.query.clone();
|
||||||
|
let after = if operation == Operation::Catalog(true) {
|
||||||
|
self.hosted.next
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let result = match operation {
|
||||||
|
Operation::Catalog(_) => request
|
||||||
|
.connection
|
||||||
|
.catalog(&query, after)
|
||||||
|
.await
|
||||||
|
.map(Outcome::Catalog),
|
||||||
|
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
|
||||||
|
Operation::Publish => request
|
||||||
|
.connection
|
||||||
|
.publish(request.snapshot.bundle.clone())
|
||||||
|
.await
|
||||||
|
.map(Outcome::Remote),
|
||||||
|
Operation::Save => request
|
||||||
|
.connection
|
||||||
|
.save(
|
||||||
|
request.resource_id.expect("save has a resource"),
|
||||||
|
request.etag.as_deref().expect("save has a precondition"),
|
||||||
|
request.snapshot.bundle.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Outcome::Remote),
|
||||||
|
Operation::Check => request
|
||||||
|
.connection
|
||||||
|
.open(request.resource_id.expect("check has a resource"))
|
||||||
|
.await
|
||||||
|
.map(Outcome::Remote),
|
||||||
|
};
|
||||||
|
Message::Hosted(Event::Completed(request.id, request.document, result))
|
||||||
|
},
|
||||||
|
|message| message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub(super) fn open_hosted(&mut self) -> Task<Message> {
|
||||||
|
if self.pending_recovery.is_some() {
|
||||||
|
self.hosted_problem(
|
||||||
|
"Recover the interrupted source save before opening another model.",
|
||||||
|
);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Some((connection, id)) = self.hosted.open_target.take() {
|
||||||
|
return self.hosted_start(Operation::Open(id), connection, Some(id));
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
pub(super) fn save_hosted(&mut self) -> Task<Message> {
|
||||||
|
if !self.hosted_ready() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if self.hosted.review.is_some() {
|
||||||
|
self.hosted.visible = true;
|
||||||
|
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Some(session) = &self.hosted.session {
|
||||||
|
return self.hosted_start(
|
||||||
|
Operation::Save,
|
||||||
|
session.connection.clone(),
|
||||||
|
Some(session.resource_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_hosted(&mut self) -> Task<Message> {
|
||||||
|
let destination = self
|
||||||
|
.hosted
|
||||||
|
.uncertain
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
|
||||||
|
.or_else(|| {
|
||||||
|
self.hosted
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| (s.connection.clone(), s.resource_id))
|
||||||
|
});
|
||||||
|
if let Some((connection, id)) = destination {
|
||||||
|
return self.hosted_start(Operation::Check, connection, Some(id));
|
||||||
|
}
|
||||||
|
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
|
||||||
|
if remote.compiled.bundle != request.snapshot.bundle {
|
||||||
|
self.hosted.review = Some(Review {
|
||||||
|
connection: request.connection.clone(),
|
||||||
|
remote,
|
||||||
|
});
|
||||||
|
self.hosted.review_path = None;
|
||||||
|
self.hosted.visible = true;
|
||||||
|
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.hosted.session = Some(Session {
|
||||||
|
connection: request.connection.clone(),
|
||||||
|
resource_id: remote.resource_id,
|
||||||
|
revision: remote.revision,
|
||||||
|
etag: remote.etag,
|
||||||
|
});
|
||||||
|
self.path = None;
|
||||||
|
self.disk_project = None;
|
||||||
|
self.document.mark_saved(request.snapshot.clone());
|
||||||
|
self.hosted.uncertain = None;
|
||||||
|
self.hosted.review = None;
|
||||||
|
self.hosted.review_path = None;
|
||||||
|
self.hosted.error = None;
|
||||||
|
self.status = format!(
|
||||||
|
"Hosted revision {} saved. Undo remains local until your next Save.",
|
||||||
|
remote.revision
|
||||||
|
);
|
||||||
|
// A late receipt may save an older snapshot; it must not close newer work.
|
||||||
|
if self.continue_after_save {
|
||||||
|
self.continue_after_save = false;
|
||||||
|
if request.revision == self.document.revision()
|
||||||
|
&& !self.dirty()
|
||||||
|
&& let Some(pending) = self.pending.take()
|
||||||
|
{
|
||||||
|
return self.proceed(pending);
|
||||||
|
}
|
||||||
|
self.pending = None;
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
|
||||||
|
match event {
|
||||||
|
Event::Show => {
|
||||||
|
self.hosted.visible = true;
|
||||||
|
self.model_menu = None;
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
}
|
||||||
|
Event::Close => self.hosted.visible = false,
|
||||||
|
Event::Base(base) => {
|
||||||
|
self.hosted.base = base;
|
||||||
|
self.hosted.connection = None;
|
||||||
|
self.hosted.catalog.clear();
|
||||||
|
self.hosted.invalidate_catalog();
|
||||||
|
self.hosted.selected = None;
|
||||||
|
}
|
||||||
|
Event::Token(token) => {
|
||||||
|
self.hosted.token = token;
|
||||||
|
self.hosted.connection = None;
|
||||||
|
self.hosted.catalog.clear();
|
||||||
|
self.hosted.selected = None;
|
||||||
|
self.hosted.invalidate_catalog();
|
||||||
|
}
|
||||||
|
Event::Query(query) => {
|
||||||
|
if self.hosted.query != query {
|
||||||
|
self.hosted.query = query;
|
||||||
|
self.hosted.selected = None;
|
||||||
|
self.hosted.invalidate_catalog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
|
||||||
|
Ok(connection) => {
|
||||||
|
self.hosted.invalidate_catalog();
|
||||||
|
return self.hosted_start(Operation::Catalog(false), connection, None);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.status = error.detail.clone();
|
||||||
|
self.hosted.error = Some(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Event::More => {
|
||||||
|
if !self.hosted.catalog_stale
|
||||||
|
&& self.hosted.next.is_some()
|
||||||
|
&& let Some(connection) = &self.hosted.connection
|
||||||
|
{
|
||||||
|
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Select(id) => self.hosted.selected = Some(id),
|
||||||
|
Event::OpenSelected => {
|
||||||
|
if let (Some(connection), Some(id)) =
|
||||||
|
(&self.hosted.connection, self.hosted.selected)
|
||||||
|
{
|
||||||
|
self.hosted.open_target = Some((connection.clone(), id));
|
||||||
|
self.hosted.visible = false;
|
||||||
|
return self.handle(Message::Request(Pending::HostedOpen));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Publish => {
|
||||||
|
if !self.hosted_ready() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
|
||||||
|
{
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(error) => {
|
||||||
|
self.status = error.detail.clone();
|
||||||
|
self.hosted.error = Some(error);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return self.hosted_start(Operation::Publish, connection, None);
|
||||||
|
}
|
||||||
|
Event::Save => return self.save_hosted(),
|
||||||
|
Event::Check => return self.check_hosted(),
|
||||||
|
Event::ReviewFile(path) => {
|
||||||
|
if let Some(source) = self
|
||||||
|
.hosted
|
||||||
|
.review
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
|
||||||
|
{
|
||||||
|
self.hosted.review_editor = text_editor::Content::with_text(source);
|
||||||
|
self.hosted.review_path = Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ReviewAction(action) => {
|
||||||
|
if !action.is_edit() {
|
||||||
|
self.hosted.review_editor.perform(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ReviewBack => self.hosted.review_path = None,
|
||||||
|
Event::Reload => {
|
||||||
|
if !self.hosted_ready() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Some(review) = self.hosted.review.clone() {
|
||||||
|
let remote = review.remote;
|
||||||
|
let connection = review.connection;
|
||||||
|
{
|
||||||
|
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.document.mark_saved(remote.compiled.clone());
|
||||||
|
self.hosted.session = Some(Session {
|
||||||
|
connection,
|
||||||
|
resource_id: remote.resource_id,
|
||||||
|
revision: remote.revision,
|
||||||
|
etag: remote.etag,
|
||||||
|
});
|
||||||
|
self.path = None;
|
||||||
|
self.disk_project = None;
|
||||||
|
self.hosted.review = None;
|
||||||
|
self.hosted.review_path = None;
|
||||||
|
self.hosted.uncertain = None;
|
||||||
|
self.hosted.error = None;
|
||||||
|
self.sync();
|
||||||
|
self.hosted.visible = false;
|
||||||
|
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
|
||||||
|
}
|
||||||
|
Err(error) => self.hosted_problem(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Local => {
|
||||||
|
self.hosted.visible = false;
|
||||||
|
return self.handle(Message::Save(true));
|
||||||
|
}
|
||||||
|
Event::Forget => {
|
||||||
|
if self.hosted.session.is_some() {
|
||||||
|
self.hosted_problem("Save as local before disconnecting this hosted model.");
|
||||||
|
} else {
|
||||||
|
self.hosted = Default::default();
|
||||||
|
self.hosted.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Completed(id, document, result) => {
|
||||||
|
let Some(request) = self.hosted.request.as_ref() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
if request.id != id
|
||||||
|
|| request.document != document
|
||||||
|
|| document != self.canvas_document
|
||||||
|
{
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let request = self.hosted.request.take().expect("matching request");
|
||||||
|
self.busy = false;
|
||||||
|
// A catalog is a read snapshot, never a live mirror. Known or
|
||||||
|
// uncertain writes and observed newer revisions invalidate only
|
||||||
|
// the service whose results are currently displayed.
|
||||||
|
let catalog_changed = match &result {
|
||||||
|
Ok(Outcome::Remote(remote)) => {
|
||||||
|
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||||
|
// A changed bound head can enter/leave the active
|
||||||
|
// filter or page even when no row was displayed.
|
||||||
|
|| self.hosted.session.as_ref().is_some_and(|session| {
|
||||||
|
session.connection.base() == request.connection.base()
|
||||||
|
&& session.resource_id == remote.resource_id
|
||||||
|
&& session.etag != remote.etag
|
||||||
|
})
|
||||||
|
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
|
||||||
|
failed.operation == Operation::Save
|
||||||
|
&& failed.connection.base() == request.connection.base()
|
||||||
|
&& failed.resource_id == Some(remote.resource_id)
|
||||||
|
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
|
||||||
|
})
|
||||||
|
|| self.hosted.catalog.iter().any(|item| {
|
||||||
|
item.resource_id == remote.resource_id
|
||||||
|
&& item.revision != remote.revision
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||||
|
&& (error.uncertain || error.status == Some(412))
|
||||||
|
}
|
||||||
|
Ok(Outcome::Catalog(_)) => false,
|
||||||
|
};
|
||||||
|
if catalog_changed
|
||||||
|
&& self
|
||||||
|
.hosted
|
||||||
|
.connection
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|connection| connection.base() == request.connection.base())
|
||||||
|
{
|
||||||
|
self.hosted.invalidate_catalog();
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Err(error) => {
|
||||||
|
let conflict =
|
||||||
|
error.status == Some(412) && request.operation == Operation::Save;
|
||||||
|
if error.uncertain || conflict {
|
||||||
|
let mut failed = request.clone();
|
||||||
|
if let Some(id) = error.published {
|
||||||
|
failed.resource_id = Some(id);
|
||||||
|
}
|
||||||
|
if matches!(request.operation, Operation::Save | Operation::Publish) {
|
||||||
|
self.hosted.uncertain = Some(failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.status = error.detail.clone();
|
||||||
|
self.hosted.error = Some(error);
|
||||||
|
self.hosted.visible = true;
|
||||||
|
self.continue_after_save = false;
|
||||||
|
self.pending = None;
|
||||||
|
if conflict {
|
||||||
|
return self.check_hosted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Outcome::Catalog(catalog)) => {
|
||||||
|
if let Some(session) = &mut self.hosted.session
|
||||||
|
&& session.connection.base() == request.connection.base()
|
||||||
|
{
|
||||||
|
session.connection = request.connection.clone();
|
||||||
|
}
|
||||||
|
if let Some(failed) = &mut self.hosted.uncertain
|
||||||
|
&& failed.connection.base() == request.connection.base()
|
||||||
|
{
|
||||||
|
failed.connection = request.connection.clone();
|
||||||
|
}
|
||||||
|
if let Some(review) = &mut self.hosted.review
|
||||||
|
&& review.connection.base() == request.connection.base()
|
||||||
|
{
|
||||||
|
review.connection = request.connection.clone();
|
||||||
|
}
|
||||||
|
self.hosted.connection = Some(request.connection);
|
||||||
|
if request.operation != Operation::Catalog(true) {
|
||||||
|
self.hosted.catalog.clear();
|
||||||
|
}
|
||||||
|
for item in catalog.items {
|
||||||
|
if !self
|
||||||
|
.hosted
|
||||||
|
.catalog
|
||||||
|
.iter()
|
||||||
|
.any(|old| old.resource_id == item.resource_id)
|
||||||
|
{
|
||||||
|
self.hosted.catalog.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hosted.next = catalog.next_after;
|
||||||
|
self.hosted.catalog_stale = false;
|
||||||
|
if !self
|
||||||
|
.hosted
|
||||||
|
.catalog
|
||||||
|
.iter()
|
||||||
|
.any(|item| Some(item.resource_id) == self.hosted.selected)
|
||||||
|
{
|
||||||
|
self.hosted.selected = None;
|
||||||
|
}
|
||||||
|
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
|
||||||
|
}
|
||||||
|
Ok(Outcome::Remote(remote)) => match request.operation {
|
||||||
|
Operation::Open(_) => {
|
||||||
|
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
|
||||||
|
Ok(document) => {
|
||||||
|
self.reset(document.project().clone(), None);
|
||||||
|
self.document = document;
|
||||||
|
self.source_workspace =
|
||||||
|
Workspace::new(self.document.bundle(), true);
|
||||||
|
self.sync();
|
||||||
|
self.hosted.session = Some(Session {
|
||||||
|
connection: request.connection,
|
||||||
|
resource_id: remote.resource_id,
|
||||||
|
revision: remote.revision,
|
||||||
|
etag: remote.etag,
|
||||||
|
});
|
||||||
|
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
|
||||||
|
}
|
||||||
|
Err(error) => self.hosted_problem(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Operation::Publish | Operation::Save => {
|
||||||
|
return self.acknowledge_hosted(&request, remote);
|
||||||
|
}
|
||||||
|
Operation::Check => {
|
||||||
|
let acknowledged = self
|
||||||
|
.hosted
|
||||||
|
.uncertain
|
||||||
|
.as_ref()
|
||||||
|
.filter(|failed| {
|
||||||
|
failed.connection.base() == request.connection.base()
|
||||||
|
&& failed.resource_id == Some(remote.resource_id)
|
||||||
|
&& failed.snapshot.bundle == remote.compiled.bundle
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
if let Some(failed) = acknowledged {
|
||||||
|
return self.acknowledge_hosted(&failed, remote);
|
||||||
|
}
|
||||||
|
if self.hosted.session.as_ref().is_some_and(|session| {
|
||||||
|
session.connection.base() == request.connection.base()
|
||||||
|
&& session.resource_id == remote.resource_id
|
||||||
|
&& session.etag == remote.etag
|
||||||
|
}) {
|
||||||
|
self.hosted.review = None;
|
||||||
|
self.hosted.review_path = None;
|
||||||
|
self.status =
|
||||||
|
"The server revision is unchanged. Local edits are retained."
|
||||||
|
.into();
|
||||||
|
} else {
|
||||||
|
self.status = format!(
|
||||||
|
"Server revision {} is available. Review before replacing local work.",
|
||||||
|
remote.revision
|
||||||
|
);
|
||||||
|
self.hosted.review = Some(Review {
|
||||||
|
connection: request.connection.clone(),
|
||||||
|
remote,
|
||||||
|
});
|
||||||
|
self.hosted.review_path = None;
|
||||||
|
self.hosted.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Operation::Catalog(_) => {
|
||||||
|
self.hosted_problem("Unexpected response to catalog request.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::hosted::client::Secret;
|
||||||
|
|
||||||
|
fn client() -> Connection {
|
||||||
|
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
|
||||||
|
}
|
||||||
|
fn send(app: &mut App, event: Event) {
|
||||||
|
let _ = app.update(Message::Hosted(event));
|
||||||
|
}
|
||||||
|
fn rename(app: &mut App, name: &str) {
|
||||||
|
app.document
|
||||||
|
.execute(Command::Rename(app.project().id, name.into()))
|
||||||
|
.unwrap();
|
||||||
|
app.sync();
|
||||||
|
}
|
||||||
|
fn bind(app: &mut App) -> Id {
|
||||||
|
let resource = Id::new_v4();
|
||||||
|
app.hosted.session = Some(Session {
|
||||||
|
connection: client(),
|
||||||
|
resource_id: resource,
|
||||||
|
revision: 1,
|
||||||
|
etag: "\"opaque-base\"".into(),
|
||||||
|
});
|
||||||
|
resource
|
||||||
|
}
|
||||||
|
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
|
||||||
|
Remote {
|
||||||
|
resource_id,
|
||||||
|
revision,
|
||||||
|
etag: format!("\"opaque-{revision}\""),
|
||||||
|
compiled: snapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
|
||||||
|
let r = app.hosted.request.as_ref().unwrap();
|
||||||
|
send(app, Event::Completed(r.id, r.document, result));
|
||||||
|
}
|
||||||
|
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
|
||||||
|
Failure {
|
||||||
|
detail: "Test failure".into(),
|
||||||
|
code: "test".into(),
|
||||||
|
status,
|
||||||
|
uncertain,
|
||||||
|
published: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
rename(&mut app, "Published revision");
|
||||||
|
app.path = Some(PathBuf::from("local.arcadia"));
|
||||||
|
send(&mut app, Event::Publish);
|
||||||
|
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||||
|
assert!(app.busy);
|
||||||
|
rename(&mut app, "Later local edit");
|
||||||
|
let resource = Id::new_v4();
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
|
||||||
|
assert!(!app.busy);
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||||
|
assert!(app.path.is_none());
|
||||||
|
assert_eq!(app.project().name, "Later local edit");
|
||||||
|
assert!(app.document.dirty());
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.project().name, "Published revision");
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||||
|
other
|
||||||
|
.execute(Command::Rename(other.project().id, "Other engineer".into()))
|
||||||
|
.unwrap();
|
||||||
|
rename(&mut app, "Local engineering work");
|
||||||
|
let local = app.document.snapshot();
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
complete(&mut app, Err(failure(Some(412), false)));
|
||||||
|
assert_eq!(
|
||||||
|
app.hosted.request.as_ref().unwrap().operation,
|
||||||
|
Operation::Check
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||||
|
complete(
|
||||||
|
&mut app,
|
||||||
|
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
assert!(!app.busy);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
send(&mut app, Event::Reload);
|
||||||
|
assert_eq!(app.project().name, "Other engineer");
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.project().name, "Local engineering work");
|
||||||
|
assert!(app.document.dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
rename(&mut app, "Saved despite lost response");
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||||
|
complete(&mut app, Err(failure(None, true)));
|
||||||
|
assert!(app.document.dirty());
|
||||||
|
assert!(!app.busy);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
assert!(app.hosted.uncertain.is_none());
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
bind(&mut app);
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
let request = app.hosted.request.as_ref().unwrap().clone();
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
|
||||||
|
);
|
||||||
|
assert!(app.busy);
|
||||||
|
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
|
||||||
|
app.reset(Project::empty(), None);
|
||||||
|
let current = app.document.snapshot();
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Completed(
|
||||||
|
request.id,
|
||||||
|
request.document,
|
||||||
|
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(¤t, &app.document.snapshot()));
|
||||||
|
assert!(app.hosted.session.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
bind(&mut app);
|
||||||
|
app.source_workspace
|
||||||
|
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
|
||||||
|
let draft = app.source_workspace.bundle();
|
||||||
|
let accepted = app.document.snapshot();
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||||
|
assert_eq!(app.source_workspace.bundle(), draft);
|
||||||
|
assert!(!app.busy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
rename(&mut app, "Keep this work");
|
||||||
|
app.hosted.connection = Some(client());
|
||||||
|
app.hosted.selected = Some(Id::new_v4());
|
||||||
|
send(&mut app, Event::OpenSelected);
|
||||||
|
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
let _ = app.update(Message::Discard);
|
||||||
|
let previous = app.document.snapshot();
|
||||||
|
complete(&mut app, Err(failure(Some(401), false)));
|
||||||
|
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
|
||||||
|
assert_eq!(app.project().name, "Keep this work");
|
||||||
|
assert!(app.document.dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
rename(&mut app, "Local fallback");
|
||||||
|
let failure = SaveFailure {
|
||||||
|
document: app.canvas_document,
|
||||||
|
message: "Disk failed".into(),
|
||||||
|
recovery: None,
|
||||||
|
};
|
||||||
|
let _ = app.update(Message::Saved(Err(failure)));
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let target = directory.path().join(&app.document.bundle().entry);
|
||||||
|
let receipt = write_snapshot(
|
||||||
|
app.canvas_document,
|
||||||
|
app.document.revision(),
|
||||||
|
app.document.snapshot(),
|
||||||
|
target.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||||
|
assert!(app.hosted.session.is_none());
|
||||||
|
assert_eq!(app.path, Some(target));
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
assert!(app.document.can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
send(&mut app, Event::Show);
|
||||||
|
let original = app.document.snapshot();
|
||||||
|
let _ = app.update(Message::Command(Command::Rename(
|
||||||
|
app.project().id,
|
||||||
|
"Underlying edit".into(),
|
||||||
|
)));
|
||||||
|
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||||
|
let token = "a-secret-never-in-a-diagnostic";
|
||||||
|
let event = Message::Hosted(Event::Token(Secret(token.into())));
|
||||||
|
assert!(!format!("{event:?}").contains(token));
|
||||||
|
let _ = app.update(event);
|
||||||
|
send(&mut app, Event::Forget);
|
||||||
|
assert!(app.hosted.token.0.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let mut bundle = app.document.bundle().clone();
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.insert("detached.arcadia".into(), "// kept draft".into());
|
||||||
|
let reachable = app.document.bundle().files.keys().cloned().collect();
|
||||||
|
app.source_workspace.discovered(&bundle, reachable);
|
||||||
|
app.source_workspace
|
||||||
|
.switch("detached.arcadia", app.source_editor.cursor());
|
||||||
|
app.source_workspace.edit(
|
||||||
|
"// unsaved detached rationale".into(),
|
||||||
|
app.source_editor.cursor(),
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Publish);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
assert!(app.source_workspace.detached_dirty());
|
||||||
|
assert_eq!(
|
||||||
|
app.source_workspace.files["detached.arcadia"].text,
|
||||||
|
"// unsaved detached rationale"
|
||||||
|
);
|
||||||
|
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
|
||||||
|
send(&mut app, Event::Publish);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
app.pending_recovery,
|
||||||
|
Some(PathBuf::from("interrupted.arcadia"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let old_id = bind(&mut app);
|
||||||
|
app.hosted.base = "http://127.0.0.1:9876".into();
|
||||||
|
rename(&mut app, "New publication");
|
||||||
|
send(&mut app, Event::Publish);
|
||||||
|
let new_id = Id::new_v4();
|
||||||
|
let mut error = failure(None, true);
|
||||||
|
error.published = Some(new_id);
|
||||||
|
complete(&mut app, Err(error));
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
let request = app.hosted.request.as_ref().unwrap();
|
||||||
|
assert_eq!(request.resource_id, Some(new_id));
|
||||||
|
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
|
||||||
|
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||||
|
changed
|
||||||
|
.execute(Command::Rename(
|
||||||
|
changed.project().id,
|
||||||
|
"Changed after publication".into(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
complete(
|
||||||
|
&mut app,
|
||||||
|
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
|
||||||
|
);
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
|
||||||
|
send(&mut app, Event::Reload);
|
||||||
|
let session = app.hosted.session.as_ref().unwrap();
|
||||||
|
assert_eq!(session.resource_id, new_id);
|
||||||
|
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
|
||||||
|
assert_eq!(app.project().name, "Changed after publication");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(app: &mut App, message: Message) {
|
||||||
|
use iced::futures::StreamExt;
|
||||||
|
let mut tasks = vec![app.update(message)];
|
||||||
|
while let Some(task) = tasks.pop() {
|
||||||
|
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||||
|
while let Some(action) = stream.next().await {
|
||||||
|
if let iced_runtime::Action::Output(message) = action {
|
||||||
|
tasks.push(app.update(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||||
|
let token = "current-secret-for-hosted-regression-0123456789";
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, oa_server::app(store, Some(token.into())))
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = done.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let mut bundle = app.document.bundle().clone();
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.get_mut(&bundle.entry)
|
||||||
|
.unwrap()
|
||||||
|
.insert_str(0, "// Hosted rationale\n");
|
||||||
|
app.document.apply_bundle(bundle).unwrap();
|
||||||
|
app.sync();
|
||||||
|
app.hosted.base = base.clone();
|
||||||
|
app.hosted.token = Secret(token.into());
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert!(app.hosted.catalog.is_empty());
|
||||||
|
assert!(
|
||||||
|
app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(
|
||||||
|
!app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Show)).await;
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(
|
||||||
|
app.hosted.request.is_none(),
|
||||||
|
"Showing the dialog does not poll"
|
||||||
|
);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert_eq!(app.hosted.catalog.len(), 1);
|
||||||
|
assert_eq!(app.hosted.catalog[0].revision, 1);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||||
|
let session = app
|
||||||
|
.hosted
|
||||||
|
.session
|
||||||
|
.clone()
|
||||||
|
.expect("publish binds a resource");
|
||||||
|
assert!(!app.busy);
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
assert!(
|
||||||
|
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
|
||||||
|
);
|
||||||
|
let project_id = app.project().id;
|
||||||
|
dispatch(
|
||||||
|
&mut app,
|
||||||
|
Message::Command(Command::Rename(project_id, "Graphical update".into())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatch(&mut app, Message::Save(false)).await;
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert_eq!(
|
||||||
|
app.hosted.catalog[0].revision, 1,
|
||||||
|
"Old results stay identified as a snapshot"
|
||||||
|
);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert_eq!(app.hosted.catalog[0].revision, 2);
|
||||||
|
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
|
||||||
|
let external = Connection::new(&base, Secret(token.into())).unwrap();
|
||||||
|
let remote = external.open(session.resource_id).await.unwrap();
|
||||||
|
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
|
||||||
|
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
|
||||||
|
other
|
||||||
|
.execute(Command::Rename(project_id, "API engineering change".into()))
|
||||||
|
.unwrap();
|
||||||
|
external
|
||||||
|
.save(session.resource_id, &remote.etag, other.bundle().clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
dispatch(
|
||||||
|
&mut app,
|
||||||
|
Message::Command(Command::Rename(project_id, "Local pending change".into())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatch(&mut app, Message::Save(false)).await;
|
||||||
|
assert!(app.hosted.review.is_some());
|
||||||
|
assert_eq!(app.project().name, "Local pending change");
|
||||||
|
assert!(app.document.dirty());
|
||||||
|
// Simulate a retired credential in every preexisting reconciliation handle.
|
||||||
|
let old =
|
||||||
|
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
|
||||||
|
app.hosted.session.as_mut().unwrap().connection = old.clone();
|
||||||
|
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
|
||||||
|
app.hosted.review.as_mut().unwrap().connection = old;
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||||
|
let reconciled_write = app.hosted.uncertain.clone().unwrap();
|
||||||
|
// Reload the captured review directly. A fresh Check must not hide a stale
|
||||||
|
// credential in that review by replacing it with a newer response.
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
|
||||||
|
assert_eq!(app.project().name, "API engineering change");
|
||||||
|
dispatch(&mut app, Message::Undo).await;
|
||||||
|
assert_eq!(app.project().name, "Local pending change");
|
||||||
|
dispatch(&mut app, Message::Save(false)).await;
|
||||||
|
assert!(!app.document.dirty());
|
||||||
|
// Independently exercise the corrected uncertain-write connection.
|
||||||
|
app.hosted.uncertain = Some(reconciled_write);
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Check)).await;
|
||||||
|
assert!(app.hosted.error.is_none());
|
||||||
|
assert!(app.hosted.uncertain.is_none());
|
||||||
|
let accepted = external.open(session.resource_id).await.unwrap();
|
||||||
|
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
|
||||||
|
assert_eq!(
|
||||||
|
accepted.compiled.decoded.project.name,
|
||||||
|
"Local pending change"
|
||||||
|
);
|
||||||
|
let mut second = App::with_project(Project::empty());
|
||||||
|
second.hosted.base = base;
|
||||||
|
second.hosted.token = Secret(token.into());
|
||||||
|
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
|
||||||
|
assert!(
|
||||||
|
second
|
||||||
|
.hosted
|
||||||
|
.catalog
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.resource_id == session.resource_id)
|
||||||
|
);
|
||||||
|
send(&mut second, Event::Select(session.resource_id));
|
||||||
|
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
|
||||||
|
assert_eq!(second.document.bundle(), app.document.bundle());
|
||||||
|
assert_eq!(
|
||||||
|
second.hosted.session.as_ref().unwrap().resource_id,
|
||||||
|
session.resource_id
|
||||||
|
);
|
||||||
|
dispatch(
|
||||||
|
&mut second,
|
||||||
|
Message::Command(Command::Rename(project_id, "Saved before New".into())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatch(&mut second, Message::Request(Pending::New)).await;
|
||||||
|
assert!(second.pending.is_some());
|
||||||
|
dispatch(&mut second, Message::SaveAndContinue).await;
|
||||||
|
assert!(second.hosted.session.is_none());
|
||||||
|
assert!(second.pending.is_none());
|
||||||
|
assert_ne!(second.project().id, project_id);
|
||||||
|
assert_eq!(
|
||||||
|
external
|
||||||
|
.open(session.resource_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.compiled
|
||||||
|
.decoded
|
||||||
|
.project
|
||||||
|
.name,
|
||||||
|
"Saved before New"
|
||||||
|
);
|
||||||
|
drop(external);
|
||||||
|
drop(app);
|
||||||
|
drop(second);
|
||||||
|
let _ = stop.send(());
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, oa_server::app(store, None))
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = done.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
app.hosted.base = base.clone();
|
||||||
|
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||||
|
let session = app.hosted.session.clone().unwrap();
|
||||||
|
let accepted = app.document.snapshot();
|
||||||
|
let original_identity = app.canvas_document;
|
||||||
|
let external = Connection::new(&base, Secret::default()).unwrap();
|
||||||
|
let old = external.open(session.resource_id).await.unwrap();
|
||||||
|
|
||||||
|
let local = tempfile::tempdir().unwrap();
|
||||||
|
let path = local.path().join("repair.arcadia");
|
||||||
|
let invalid = "arcadia 5; ? // unfinished local work";
|
||||||
|
std::fs::write(&path, invalid).unwrap();
|
||||||
|
let path = path.canonicalize().unwrap();
|
||||||
|
dispatch(
|
||||||
|
&mut app,
|
||||||
|
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_ne!(app.canvas_document, original_identity);
|
||||||
|
assert_eq!(app.path.as_ref(), Some(&path));
|
||||||
|
assert!(!app.source_workspace.applied);
|
||||||
|
assert_eq!(app.source_editor.text(), invalid);
|
||||||
|
assert!(
|
||||||
|
Arc::ptr_eq(&accepted, &app.document.snapshot()),
|
||||||
|
"A repair workspace must preserve the last valid graphical model"
|
||||||
|
);
|
||||||
|
assert!(!app.source_diagnostics.is_empty());
|
||||||
|
assert!(
|
||||||
|
app.hosted.session.is_none(),
|
||||||
|
"The installed local repair workspace must own its save destination"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut repaired = Project::empty();
|
||||||
|
repaired.name = "Independent local repair".into();
|
||||||
|
let repaired_source = language::format(&repaired);
|
||||||
|
dispatch(
|
||||||
|
&mut app,
|
||||||
|
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatch(
|
||||||
|
&mut app,
|
||||||
|
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||||
|
Arc::new(repaired_source),
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
dispatch(&mut app, Message::ApplySource).await;
|
||||||
|
assert_eq!(app.project(), &repaired);
|
||||||
|
assert!(!app.source_dirty());
|
||||||
|
dispatch(&mut app, Message::Save(false)).await;
|
||||||
|
|
||||||
|
let remote_after = external.open(session.resource_id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
remote_after.etag, old.etag,
|
||||||
|
"Saving a repaired local workspace must never target the old hosted resource"
|
||||||
|
);
|
||||||
|
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
|
||||||
|
assert!(app.hosted.session.is_none());
|
||||||
|
assert!(app.hosted.review.is_none());
|
||||||
|
assert!(app.hosted.uncertain.is_none());
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
assert_eq!(app.path.as_ref(), Some(&path));
|
||||||
|
assert!(!app.dirty());
|
||||||
|
let saved = source_files::load_project(&path, LIMITS).unwrap();
|
||||||
|
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
|
||||||
|
assert_eq!(
|
||||||
|
app.hosted.base, base,
|
||||||
|
"Connection settings remain available in memory"
|
||||||
|
);
|
||||||
|
drop(external);
|
||||||
|
drop(app);
|
||||||
|
let _ = stop.send(());
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
let accepted = app.document.snapshot();
|
||||||
|
let identity = app.canvas_document;
|
||||||
|
let source = app.source_workspace.bundle();
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let unreadable = directory.path().join("missing.json");
|
||||||
|
let error = read_snapshot(unreadable).unwrap_err();
|
||||||
|
for result in [Ok(None), Err(error)] {
|
||||||
|
let _ = app.update(Message::Loaded(result));
|
||||||
|
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||||
|
assert_eq!(app.canvas_document, identity);
|
||||||
|
assert_eq!(app.source_workspace.bundle(), source);
|
||||||
|
assert!(app.path.is_none());
|
||||||
|
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
|
||||||
|
Outcome::Catalog(crate::hosted::client::Catalog {
|
||||||
|
items: vec![crate::hosted::client::Summary {
|
||||||
|
resource_id: resource,
|
||||||
|
revision,
|
||||||
|
model_id: app.project().id,
|
||||||
|
name: format!("Catalog snapshot {revision}"),
|
||||||
|
elements: app.project().elements.len(),
|
||||||
|
relations: app.project().relations.len(),
|
||||||
|
}],
|
||||||
|
next_after: Some(resource),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let first = Id::new_v4();
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let catalog = catalog_result(&app, first, 1);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
send(&mut app, Event::More);
|
||||||
|
assert_eq!(
|
||||||
|
app.hosted.request.as_ref().unwrap().operation,
|
||||||
|
Operation::Catalog(true)
|
||||||
|
);
|
||||||
|
let next_page = catalog_result(&app, Id::new_v4(), 1);
|
||||||
|
complete(&mut app, Ok(next_page));
|
||||||
|
assert_eq!(app.hosted.catalog.len(), 2);
|
||||||
|
send(&mut app, Event::Select(first));
|
||||||
|
send(&mut app, Event::Query("different search".into()));
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(app.hosted.next.is_none());
|
||||||
|
assert!(app.hosted.selected.is_none());
|
||||||
|
assert_eq!(app.hosted.catalog.len(), 2);
|
||||||
|
assert!(
|
||||||
|
app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Connect / refresh")
|
||||||
|
);
|
||||||
|
send(&mut app, Event::More);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
complete(&mut app, Err(failure(None, false)));
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert_eq!(app.hosted.catalog[0].resource_id, first);
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let second = Id::new_v4();
|
||||||
|
let catalog = catalog_result(&app, second, 1);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert_eq!(app.hosted.catalog.len(), 1);
|
||||||
|
assert_eq!(app.hosted.catalog[0].resource_id, second);
|
||||||
|
send(&mut app, Event::Query("different search".into()));
|
||||||
|
assert!(
|
||||||
|
!app.hosted.catalog_stale,
|
||||||
|
"Unchanged query preserves the snapshot"
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Token(Secret("different-credential".into())),
|
||||||
|
);
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(app.hosted.catalog.is_empty());
|
||||||
|
assert!(app.hosted.connection.is_none());
|
||||||
|
assert!(app.hosted.next.is_none());
|
||||||
|
assert!(app.hosted.selected.is_none());
|
||||||
|
assert!(
|
||||||
|
!app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let catalog = catalog_result(&app, second, 2);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||||
|
assert!(app.hosted.catalog.is_empty());
|
||||||
|
assert!(app.hosted.connection.is_none());
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
let other = Id::new_v4();
|
||||||
|
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let catalog = catalog_result(&app, other, 7);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
rename(&mut app, "Saved to original service");
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
let saved = remote(app.document.snapshot(), resource, 2);
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(saved)));
|
||||||
|
assert!(
|
||||||
|
!app.hosted.catalog_stale,
|
||||||
|
"A write to A cannot invalidate B's catalog"
|
||||||
|
);
|
||||||
|
assert_eq!(app.hosted.next, Some(other));
|
||||||
|
assert_eq!(app.hosted.catalog[0].resource_id, other);
|
||||||
|
|
||||||
|
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let catalog = catalog_result(&app, resource, 2);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
let changed = remote(app.document.snapshot(), resource, 3);
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||||
|
assert!(
|
||||||
|
app.hosted.catalog_stale,
|
||||||
|
"A known newer server revision invalidates old metadata"
|
||||||
|
);
|
||||||
|
assert!(app.hosted.next.is_none());
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
let catalog = catalog_result(&app, resource, 3);
|
||||||
|
complete(&mut app, Ok(catalog));
|
||||||
|
send(&mut app, Event::Reload);
|
||||||
|
rename(&mut app, "Write with uncertain outcome");
|
||||||
|
send(&mut app, Event::Save);
|
||||||
|
complete(&mut app, Err(failure(None, true)));
|
||||||
|
assert!(app.hosted.catalog_stale);
|
||||||
|
assert!(app.hosted.next.is_none());
|
||||||
|
assert_eq!(app.hosted.catalog[0].revision, 3);
|
||||||
|
app.hosted.leave_document();
|
||||||
|
assert!(
|
||||||
|
app.hosted.catalog_stale,
|
||||||
|
"Leaving the hosted destination cannot make old results fresh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
rename(&mut app, "Original");
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
send(&mut app, Event::Query("Renamed".into()));
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
complete(
|
||||||
|
&mut app,
|
||||||
|
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||||
|
items: vec![],
|
||||||
|
next_after: None,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert!(
|
||||||
|
app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut unchanged = remote(app.document.snapshot(), resource, 1);
|
||||||
|
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(unchanged)));
|
||||||
|
assert!(
|
||||||
|
!app.hosted.catalog_stale,
|
||||||
|
"An unchanged bound head keeps the filtered snapshot fresh"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different engineer changes the hosted model so it now matches the filter.
|
||||||
|
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||||
|
changed
|
||||||
|
.execute(Command::Rename(changed.project().id, "Renamed".into()))
|
||||||
|
.unwrap();
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
complete(
|
||||||
|
&mut app,
|
||||||
|
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
|
||||||
|
);
|
||||||
|
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
|
||||||
|
assert!(
|
||||||
|
app.hosted.request.is_none(),
|
||||||
|
"No polling or automatic catalog request"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.hosted.catalog_stale,
|
||||||
|
"Known newer revision can change membership in an empty filtered catalog"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
let resource = bind(&mut app);
|
||||||
|
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||||
|
send(&mut app, Event::Query("Renamed".into()));
|
||||||
|
send(&mut app, Event::Connect);
|
||||||
|
complete(
|
||||||
|
&mut app,
|
||||||
|
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||||
|
items: vec![],
|
||||||
|
next_after: None,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Check);
|
||||||
|
let changed = remote(app.document.snapshot(), resource, 2);
|
||||||
|
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||||
|
assert!(app.hosted.review.is_some());
|
||||||
|
assert!(!app.hosted.catalog_stale);
|
||||||
|
assert!(
|
||||||
|
app.hosted
|
||||||
|
.catalog_message()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("No models found")
|
||||||
|
);
|
||||||
|
assert!(app.hosted.request.is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
use super::*;
|
||||||
|
use iced::advanced::{Layout, layout, widget};
|
||||||
|
use iced::{Rectangle, Vector};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Geometry {
|
||||||
|
scrolls: Vec<(Option<widget::Id>, Rectangle, Vector)>,
|
||||||
|
actions: Option<Rectangle>,
|
||||||
|
}
|
||||||
|
impl widget::Operation for Geometry {
|
||||||
|
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||||
|
operate(self);
|
||||||
|
}
|
||||||
|
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||||
|
if id == Some(&widget::Id::new("test-process-actions")) {
|
||||||
|
self.actions = Some(bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scrollable(
|
||||||
|
&mut self,
|
||||||
|
id: Option<&widget::Id>,
|
||||||
|
bounds: Rectangle,
|
||||||
|
_: Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
_: &mut dyn widget::operation::Scrollable,
|
||||||
|
) {
|
||||||
|
self.scrolls.push((id.cloned(), bounds, translation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn workspace(pending: bool) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||||
|
let diagram = scrollable(iced::widget::Space::new().width(500).height(1200))
|
||||||
|
.id("test-process-page")
|
||||||
|
.height(Fill)
|
||||||
|
.into();
|
||||||
|
let form = if pending {
|
||||||
|
editor_panel(
|
||||||
|
container(iced::widget::Space::new().height(32))
|
||||||
|
.id("test-process-actions")
|
||||||
|
.into(),
|
||||||
|
iced::widget::Space::new().height(1400).into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
iced::widget::Space::new().height(0).into()
|
||||||
|
};
|
||||||
|
panes(diagram, form, pending)
|
||||||
|
}
|
||||||
|
fn geometry(
|
||||||
|
view: &mut iced::Element<'_, Message, iced::Theme, ()>,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
size: Size,
|
||||||
|
) -> (layout::Node, Geometry) {
|
||||||
|
let node = view
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||||
|
let mut geometry = Geometry::default();
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(tree, Layout::new(&node), &(), &mut geometry);
|
||||||
|
(node, geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible() {
|
||||||
|
// Actual available center-pane sizes at the minimum/default supported windows.
|
||||||
|
// Space represents arbitrarily long diagram/form content; the production
|
||||||
|
// pane/card builders and real Iced layout/scroll operations are exercised.
|
||||||
|
for size in [Size::new(500., 570.), Size::new(840., 770.)] {
|
||||||
|
let mut view = workspace(false);
|
||||||
|
let mut tree = widget::Tree::new(view.as_widget());
|
||||||
|
let (node, _) = geometry(&mut view, &mut tree, size);
|
||||||
|
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||||
|
widget::Id::new("test-process-page"),
|
||||||
|
widget::operation::scrollable::AbsoluteOffset {
|
||||||
|
x: None,
|
||||||
|
y: Some(180.),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||||
|
view = workspace(true);
|
||||||
|
tree.diff(view.as_widget());
|
||||||
|
let (node, before) = geometry(&mut view, &mut tree, size);
|
||||||
|
let page = before
|
||||||
|
.scrolls
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.0 == Some(widget::Id::new("test-process-page")))
|
||||||
|
.unwrap();
|
||||||
|
let form = before
|
||||||
|
.scrolls
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(node.size(), size);
|
||||||
|
assert!(
|
||||||
|
form.1.height >= 200.,
|
||||||
|
"usable form viewport: {size:?} {:?}",
|
||||||
|
form.1
|
||||||
|
);
|
||||||
|
assert!(form.1.y >= page.1.y + page.1.height);
|
||||||
|
assert!(form.1.y + form.1.height <= size.height);
|
||||||
|
assert_eq!(page.2.y, 180., "opening a form retains the page scroll");
|
||||||
|
let actions = before.actions.unwrap();
|
||||||
|
assert!(actions.y >= page.1.y + page.1.height);
|
||||||
|
assert!(actions.y + actions.height <= form.1.y);
|
||||||
|
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||||
|
widget::Id::new("process-form"),
|
||||||
|
widget::operation::scrollable::AbsoluteOffset {
|
||||||
|
x: None,
|
||||||
|
y: Some(600.),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||||
|
let (_, after) = geometry(&mut view, &mut tree, size);
|
||||||
|
assert_eq!(
|
||||||
|
after.actions,
|
||||||
|
Some(actions),
|
||||||
|
"Create/Cancel stay pinned during form scrolling"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
after.scrolls[0].2.y, 180.,
|
||||||
|
"form scrolling does not move the diagram page"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.scrolls
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||||
|
);
|
||||||
|
view = workspace(false);
|
||||||
|
tree.diff(view.as_widget());
|
||||||
|
let (_, closed) = geometry(&mut view, &mut tree, size);
|
||||||
|
assert_eq!(
|
||||||
|
closed.scrolls[0].2.y, 180.,
|
||||||
|
"cancel retains the page scroll"
|
||||||
|
);
|
||||||
|
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -305,6 +305,7 @@ pub(super) fn view<'a>(
|
|||||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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) {
|
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||||
@@ -473,21 +474,65 @@ pub(super) fn view<'a>(
|
|||||||
button("Cancel").on_press(event(Event::Cancel))
|
button("Cancel").on_press(event(Event::Cancel))
|
||||||
]
|
]
|
||||||
.spacing(7);
|
.spacing(7);
|
||||||
out = out.push(
|
form_panel = editor_panel(actions.into(), form.into());
|
||||||
container(
|
|
||||||
column![
|
|
||||||
actions,
|
|
||||||
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
|
|
||||||
]
|
|
||||||
.spacing(8),
|
|
||||||
)
|
|
||||||
.padding(12)
|
|
||||||
.style(style::card),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
super::super::scenario_scroll::observe_topology(
|
panes(
|
||||||
context,
|
super::super::scenario_scroll::observe_topology(
|
||||||
scenario::ScrollArea::Page,
|
context,
|
||||||
scrollable(out.padding(18)),
|
scenario::ScrollArea::Page,
|
||||||
|
scrollable(out.padding(18)),
|
||||||
|
),
|
||||||
|
form_panel,
|
||||||
|
pending,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||||
|
/// workspace height. The editor must never be clipped by the page scrollable.
|
||||||
|
fn panes<'a, R: iced::advanced::Renderer + 'a>(
|
||||||
|
diagram: iced::Element<'a, Message, iced::Theme, R>,
|
||||||
|
form: iced::Element<'a, Message, iced::Theme, R>,
|
||||||
|
pending: bool,
|
||||||
|
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||||
|
column![
|
||||||
|
container(diagram).width(Fill).height(if pending {
|
||||||
|
iced::Length::FillPortion(1)
|
||||||
|
} else {
|
||||||
|
Fill
|
||||||
|
}),
|
||||||
|
container(form).width(Fill).height(if pending {
|
||||||
|
iced::Length::FillPortion(1)
|
||||||
|
} else {
|
||||||
|
iced::Length::Shrink
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.width(Fill)
|
||||||
|
.height(Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||||
|
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||||
|
fields: iced::Element<'a, Message, iced::Theme, R>,
|
||||||
|
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||||
|
container(
|
||||||
|
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||||
|
.width(Fill)
|
||||||
|
.height(Fill)
|
||||||
|
.padding(12)
|
||||||
|
.style(style::card),
|
||||||
|
)
|
||||||
|
.width(Fill)
|
||||||
|
.height(Fill)
|
||||||
|
.padding(iced::Padding {
|
||||||
|
top: 0.,
|
||||||
|
right: 18.,
|
||||||
|
bottom: 18.,
|
||||||
|
left: 18.,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, debug_assertions))]
|
||||||
|
#[path = "layout_tests.rs"]
|
||||||
|
mod layout_tests;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ pub struct Draft {
|
|||||||
pub inputs: BTreeMap<String, Input>,
|
pub inputs: BTreeMap<String, Input>,
|
||||||
pub exact_strings: bool,
|
pub exact_strings: bool,
|
||||||
context: Option<Value>,
|
context: Option<Value>,
|
||||||
|
pub semantic_groups: bool,
|
||||||
|
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||||
port_contract_drafts: BTreeMap<String, Value>,
|
port_contract_drafts: BTreeMap<String, Value>,
|
||||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,8 @@ impl Draft {
|
|||||||
inputs: BTreeMap::new(),
|
inputs: BTreeMap::new(),
|
||||||
exact_strings: false,
|
exact_strings: false,
|
||||||
context: None,
|
context: None,
|
||||||
|
semantic_groups: false,
|
||||||
|
constructor_drafts: BTreeMap::new(),
|
||||||
port_contract_drafts: BTreeMap::new(),
|
port_contract_drafts: BTreeMap::new(),
|
||||||
value_browsers: BTreeMap::new(),
|
value_browsers: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
@@ -248,6 +252,75 @@ impl Draft {
|
|||||||
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Preserve each selected constructor branch, including incomplete raw input
|
||||||
|
/// and editor state. No sibling fields are parsed or cleared by a switch.
|
||||||
|
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
|
||||||
|
let current = self
|
||||||
|
.value
|
||||||
|
.pointer(path)
|
||||||
|
.ok_or("Property no longer exists.")?
|
||||||
|
.clone();
|
||||||
|
if same_variant(¤t, &proposed) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let constructor = path.ends_with("/DataType/definition")
|
||||||
|
|| path.ends_with("/Port/contract")
|
||||||
|
|| path.ends_with("/Occurrence/body")
|
||||||
|
|| path.ends_with("/Event/body")
|
||||||
|
|| path.ends_with("/message_kind");
|
||||||
|
if !constructor {
|
||||||
|
return self.shape_scoped(path, proposed);
|
||||||
|
}
|
||||||
|
// Input/output direction changes carry the item declaration. Read those
|
||||||
|
// raw reference strings before stashing their editor buffers so an
|
||||||
|
// incomplete reference cannot silently fall back to its stored UUID.
|
||||||
|
let port_current = if path.ends_with("/Port/contract") {
|
||||||
|
self.materialize_scopes(&[path.to_owned()])?
|
||||||
|
.pointer(path)
|
||||||
|
.cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let tag = |value: &Value| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned()
|
||||||
|
};
|
||||||
|
let previous_key = (path.to_owned(), tag(¤t));
|
||||||
|
let next_key = (path.to_owned(), tag(&proposed));
|
||||||
|
let mut inputs = BTreeMap::new();
|
||||||
|
for key in self
|
||||||
|
.inputs
|
||||||
|
.keys()
|
||||||
|
.filter(|key| within(key, path))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
{
|
||||||
|
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
|
||||||
|
}
|
||||||
|
self.constructor_drafts
|
||||||
|
.insert(previous_key, (current.clone(), inputs));
|
||||||
|
let saved = self.constructor_drafts.remove(&next_key);
|
||||||
|
let (value, restored) = if let Some(saved) = saved {
|
||||||
|
saved
|
||||||
|
} else {
|
||||||
|
let value = if path.ends_with("/Port/contract") {
|
||||||
|
port_fields::switch_contract(
|
||||||
|
port_current.as_ref().unwrap_or(¤t),
|
||||||
|
proposed,
|
||||||
|
&mut BTreeMap::new(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
proposed
|
||||||
|
};
|
||||||
|
(value, BTreeMap::new())
|
||||||
|
};
|
||||||
|
self.shape_scoped(path, value)?;
|
||||||
|
self.inputs.extend(restored);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
|
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
|
||||||
let previous = std::mem::take(&mut self.inputs);
|
let previous = std::mem::take(&mut self.inputs);
|
||||||
self.rebuild();
|
self.rebuild();
|
||||||
@@ -645,7 +718,12 @@ fn free_string(path: &str) -> bool {
|
|||||||
// These are choices for the closed model, not a second validation boundary.
|
// These are choices for the closed model, not a second validation boundary.
|
||||||
// A current reference is always retained, even while other draft fields make it
|
// A current reference is always retained, even while other draft fields make it
|
||||||
// incompatible. Applying the complete draft remains the core validator's job.
|
// incompatible. Applying the complete draft remains the core validator's job.
|
||||||
fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec<Choice> {
|
pub(crate) fn reference_choices(
|
||||||
|
draft: &Draft,
|
||||||
|
project: &Project,
|
||||||
|
path: &str,
|
||||||
|
current: Id,
|
||||||
|
) -> Vec<Choice> {
|
||||||
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
|
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
|
||||||
let compatible = reference_ids(draft, project, path);
|
let compatible = reference_ids(draft, project, path);
|
||||||
let mut objects = compatible
|
let mut objects = compatible
|
||||||
@@ -1290,6 +1368,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
|||||||
"layer" => &["Oa", "Sa"],
|
"layer" => &["Oa", "Sa"],
|
||||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||||
"storage" => &["Value", "Reference"],
|
"storage" => &["Value", "Reference"],
|
||||||
|
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
|
||||||
"direction" if path.contains("DataField") => {
|
"direction" if path.contains("DataField") => {
|
||||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||||
}
|
}
|
||||||
@@ -1546,7 +1625,7 @@ fn property<'a>(
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||||
if path.ends_with("/Port/legacy") {
|
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
|
||||||
let mut content = column![].spacing(4);
|
let mut content = column![].spacing(4);
|
||||||
if !value.is_null() {
|
if !value.is_null() {
|
||||||
content = content.push(
|
content = content.push(
|
||||||
@@ -1560,6 +1639,29 @@ fn property<'a>(
|
|||||||
}
|
}
|
||||||
return content.into();
|
return content.into();
|
||||||
}
|
}
|
||||||
|
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
|
||||||
|
let mut content = column![
|
||||||
|
label_view(),
|
||||||
|
text(
|
||||||
|
"Retained generic association is part of this group. Change or clear it explicitly."
|
||||||
|
)
|
||||||
|
.size(11)
|
||||||
|
]
|
||||||
|
.spacing(5);
|
||||||
|
if value.is_null() {
|
||||||
|
content = content.push(button(text("Add generic association").size(11)).on_press(
|
||||||
|
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
content = content
|
||||||
|
.push(property(draft, project, value, path, label, true))
|
||||||
|
.push(
|
||||||
|
button(text("Clear generic association").size(11))
|
||||||
|
.on_press(Message::FieldShape(path.into(), Value::Null)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content.into();
|
||||||
|
}
|
||||||
let enum_choices = if skip_variant {
|
let enum_choices = if skip_variant {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
@@ -1817,6 +1919,73 @@ fn property<'a>(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let function = p
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Function", None)
|
||||||
|
.unwrap();
|
||||||
|
let component = p
|
||||||
|
.add_element(Kind::System, Layer::Sa, "Component", None)
|
||||||
|
.unwrap();
|
||||||
|
let item = p
|
||||||
|
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
|
||||||
|
.unwrap();
|
||||||
|
let id = Id::new_v4();
|
||||||
|
p.elements.insert(
|
||||||
|
id,
|
||||||
|
ports::make_port(
|
||||||
|
&p,
|
||||||
|
id,
|
||||||
|
function,
|
||||||
|
"Port",
|
||||||
|
PortContract::FunctionInput {
|
||||||
|
incoming_items: vec![item],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let mut draft = Draft::from_project(&p, id).unwrap();
|
||||||
|
draft.semantic_groups = true;
|
||||||
|
let path = "/body/Sa/Shared/Port/contract";
|
||||||
|
let input = format!("{path}/FunctionInput/incoming_items/0");
|
||||||
|
draft.input(&input, "incomplete-id".into());
|
||||||
|
let cursor = draft.inputs[&input].editor.cursor();
|
||||||
|
draft
|
||||||
|
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
|
||||||
|
"incomplete-id"
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
|
||||||
|
draft
|
||||||
|
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
|
||||||
|
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
|
||||||
|
let choices =
|
||||||
|
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
|
||||||
|
assert!(
|
||||||
|
choices
|
||||||
|
.iter()
|
||||||
|
.any(|(_, v)| v.get("FunctionInput").is_some())
|
||||||
|
);
|
||||||
|
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
|
||||||
|
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
|
||||||
|
let mut context = draft.value.clone();
|
||||||
|
context["parent"] = json!(component);
|
||||||
|
draft.set_context(Some(context));
|
||||||
|
let choices =
|
||||||
|
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
|
||||||
|
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
|
||||||
|
assert!(
|
||||||
|
!choices
|
||||||
|
.iter()
|
||||||
|
.any(|(_, v)| v.get("FunctionInput").is_some())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||||
reference_ids(draft, project, path)
|
reference_ids(draft, project, path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ pub(super) fn variants(
|
|||||||
let current = draft.context_value();
|
let current = draft.context_value();
|
||||||
let sa = current.pointer("/body/Sa").is_some();
|
let sa = current.pointer("/body/Sa").is_some();
|
||||||
if !sa {
|
if !sa {
|
||||||
return Some(vec![]);
|
return Some(if draft.semantic_groups {
|
||||||
|
vec![("Legacy generic port".into(), json!("Legacy"))]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let owner = current
|
let owner = current
|
||||||
.get("parent")
|
.get("parent")
|
||||||
@@ -69,6 +73,9 @@ pub(super) fn variants(
|
|||||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if draft.semantic_groups {
|
||||||
|
choices.push(("Legacy generic port".into(), json!("Legacy")));
|
||||||
|
}
|
||||||
return Some(choices);
|
return Some(choices);
|
||||||
}
|
}
|
||||||
if path.ends_with("/Port/contract/Component/kind") {
|
if path.ends_with("/Port/contract/Component/kind") {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl Topic {
|
|||||||
Self::Source => "Textual editing & Git",
|
Self::Source => "Textual editing & Git",
|
||||||
Self::Recovery => "Checks & error recovery",
|
Self::Recovery => "Checks & error recovery",
|
||||||
Self::Files => "Files & keyboard shortcuts",
|
Self::Files => "Files & keyboard shortcuts",
|
||||||
Self::Server => "REST API & automation",
|
Self::Server => "Hosted models & REST API",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,16 +751,42 @@ fn article(topic: Topic) -> Article {
|
|||||||
Topic::Server => {
|
Topic::Server => {
|
||||||
const {
|
const {
|
||||||
Article {
|
Article {
|
||||||
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
|
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
|
||||||
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
||||||
sections: &[
|
sections: &[
|
||||||
example(
|
example(
|
||||||
"Start a local service",
|
"Start a local service",
|
||||||
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
|
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
|
||||||
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
||||||
),
|
),
|
||||||
walkthrough(
|
walkthrough(
|
||||||
"Build a safe editing client",
|
"Open or publish a hosted model",
|
||||||
|
&[
|
||||||
|
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
|
||||||
|
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
|
||||||
|
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
|
||||||
|
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
walkthrough(
|
||||||
|
"Review another client's changes",
|
||||||
|
&[
|
||||||
|
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
|
||||||
|
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
|
||||||
|
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
|
||||||
|
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
explain(
|
||||||
|
"Return to a local file",
|
||||||
|
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
|
||||||
|
),
|
||||||
|
explain(
|
||||||
|
"Connection credentials",
|
||||||
|
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
|
||||||
|
),
|
||||||
|
walkthrough(
|
||||||
|
"Automate model edits",
|
||||||
&[
|
&[
|
||||||
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
|
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
|
||||||
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
|
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
|
||||||
@@ -769,10 +795,6 @@ fn article(topic: Topic) -> Article {
|
|||||||
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
|
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
explain(
|
|
||||||
"Desktop files and hosted models",
|
|
||||||
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
|
|
||||||
),
|
|
||||||
explain(
|
explain(
|
||||||
"Deployment and persistence",
|
"Deployment and persistence",
|
||||||
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
|
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
//! Optional HTTP transport. It never adopts a document or persists credentials.
|
||||||
|
use oa_core::{
|
||||||
|
Id, Project,
|
||||||
|
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
|
||||||
|
};
|
||||||
|
use reqwest::{Client, Method, Url, header};
|
||||||
|
use serde::{Deserialize, de::DeserializeOwned};
|
||||||
|
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
// The service permits two 8 MiB representations plus envelope framing. Keep
|
||||||
|
// transport/parser budgets finite even when connecting to a misconfigured host.
|
||||||
|
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
|
||||||
|
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
|
||||||
|
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
|
||||||
|
max_files: 256,
|
||||||
|
max_include_depth: 32,
|
||||||
|
max_include_edges: 1_024,
|
||||||
|
model: oa_core::language::ParseLimits {
|
||||||
|
max_source_bytes: 8 * 1024 * 1024,
|
||||||
|
max_graph_objects: 5_000,
|
||||||
|
max_records: 20_000,
|
||||||
|
max_step_references: 50_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Secret(pub String);
|
||||||
|
|
||||||
|
impl fmt::Debug for Secret {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("Secret([redacted])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Connection {
|
||||||
|
base: Url,
|
||||||
|
client: Client,
|
||||||
|
token: Secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Connection {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
// Do not expose headers, credentials, or user-supplied URL text.
|
||||||
|
f.debug_struct("Connection").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Remote {
|
||||||
|
pub resource_id: Id,
|
||||||
|
pub revision: u64,
|
||||||
|
pub etag: String,
|
||||||
|
pub compiled: Arc<CompiledSourceProject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Catalog {
|
||||||
|
pub items: Vec<Summary>,
|
||||||
|
pub next_after: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub resource_id: Id,
|
||||||
|
pub revision: u64,
|
||||||
|
pub model_id: Id,
|
||||||
|
pub name: String,
|
||||||
|
pub elements: usize,
|
||||||
|
pub relations: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Failure {
|
||||||
|
pub detail: String,
|
||||||
|
pub code: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
/// A mutation may have committed. Read current state before another write.
|
||||||
|
pub uncertain: bool,
|
||||||
|
/// Publication was acknowledged, but its accepted source could not be read.
|
||||||
|
pub published: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Failure {
|
||||||
|
fn local(code: &str, detail: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
detail: detail.into(),
|
||||||
|
code: code.into(),
|
||||||
|
status: None,
|
||||||
|
uncertain: false,
|
||||||
|
published: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Failure {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Failure {}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Envelope {
|
||||||
|
resource_id: Id,
|
||||||
|
revision: u64,
|
||||||
|
model: Project,
|
||||||
|
source_project: SourceBundle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AcceptedSource {
|
||||||
|
revision: u64,
|
||||||
|
source_project: SourceBundle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Problem {
|
||||||
|
code: String,
|
||||||
|
detail: String,
|
||||||
|
#[serde(default)]
|
||||||
|
issues: Vec<ProblemIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProblemIssue {
|
||||||
|
message: String,
|
||||||
|
path: Option<String>,
|
||||||
|
line: Option<usize>,
|
||||||
|
column: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reply {
|
||||||
|
status: u16,
|
||||||
|
etag: Option<String>,
|
||||||
|
body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
/// `base` is the service root, optionally beneath a reverse-proxy path.
|
||||||
|
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
|
||||||
|
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
|
||||||
|
Self::with_timeout(base, token, REQUEST_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
|
||||||
|
let invalid = || {
|
||||||
|
Failure::local(
|
||||||
|
"invalid_endpoint",
|
||||||
|
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let base = base.trim();
|
||||||
|
if base.contains('\\')
|
||||||
|
|| base.chars().any(char::is_control)
|
||||||
|
|| base.split_once("://").is_some_and(|(_, rest)| {
|
||||||
|
rest.split(['/', '?', '#'])
|
||||||
|
.next()
|
||||||
|
.is_some_and(|authority| authority.contains('@'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let mut base = Url::parse(base).map_err(|_| invalid())?;
|
||||||
|
let host = base.host_str().ok_or_else(invalid)?;
|
||||||
|
let loopback = host.eq_ignore_ascii_case("localhost")
|
||||||
|
|| host
|
||||||
|
.trim_matches(['[', ']'])
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.is_ok_and(|ip| ip.is_loopback());
|
||||||
|
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|
||||||
|
|| !base.username().is_empty()
|
||||||
|
|| base.password().is_some()
|
||||||
|
|| base.query().is_some()
|
||||||
|
|| base.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
if !base.path().ends_with('/') {
|
||||||
|
// set_path accepts the existing URL's encoded path without decoding
|
||||||
|
// it; this preserves an optional reverse-proxy prefix exactly.
|
||||||
|
base.set_path(&format!("{}/", base.path()));
|
||||||
|
}
|
||||||
|
let mut headers = header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT,
|
||||||
|
header::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
if !token.0.is_empty() {
|
||||||
|
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||||
|
return Err(Failure::local(
|
||||||
|
"invalid_token",
|
||||||
|
"The bearer token must contain printable ASCII without whitespace.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut value =
|
||||||
|
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
|
||||||
|
Failure::local(
|
||||||
|
"invalid_token",
|
||||||
|
"The bearer token is not a valid HTTP credential.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
value.set_sensitive(true);
|
||||||
|
headers.insert(header::AUTHORIZATION, value);
|
||||||
|
}
|
||||||
|
let mut builder = Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.retry(reqwest::retry::never())
|
||||||
|
.connect_timeout(Duration::from_secs(10).min(timeout))
|
||||||
|
.timeout(timeout)
|
||||||
|
.user_agent("Arcadia-Studio/hosted-desktop");
|
||||||
|
if loopback {
|
||||||
|
builder = builder.no_proxy();
|
||||||
|
}
|
||||||
|
let client = builder.build().map_err(|_| {
|
||||||
|
Failure::local(
|
||||||
|
"client_unavailable",
|
||||||
|
"The HTTP client could not be initialized.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
base,
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base(&self) -> &str {
|
||||||
|
self.base.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self, path: &str) -> Url {
|
||||||
|
// All path suffixes are literals or UUIDs, never user-provided paths.
|
||||||
|
self.base
|
||||||
|
.join(&format!("api/v1/{path}"))
|
||||||
|
.expect("validated hierarchical base")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact(&self, text: String) -> String {
|
||||||
|
let text = if self.token.0.is_empty() {
|
||||||
|
text
|
||||||
|
} else {
|
||||||
|
text.replace(&self.token.0, "[redacted]")
|
||||||
|
};
|
||||||
|
// Limit user-visible diagnostics independently of the response budget.
|
||||||
|
text.chars()
|
||||||
|
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||||
|
.take(8_192)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
|
||||||
|
Failure {
|
||||||
|
detail: detail.into(),
|
||||||
|
code: "invalid_response".into(),
|
||||||
|
status: Some(reply.status),
|
||||||
|
uncertain: mutation,
|
||||||
|
published: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
|
||||||
|
serde_json::from_slice(&reply.body).map_err(|_| {
|
||||||
|
self.invalid_reply(
|
||||||
|
reply,
|
||||||
|
mutation,
|
||||||
|
"The service returned an invalid JSON response. Local work is unchanged.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
|
||||||
|
reply
|
||||||
|
.etag
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| valid_etag(value))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
self.invalid_reply(
|
||||||
|
reply,
|
||||||
|
mutation,
|
||||||
|
"The service did not return a valid strong ETag. Local work is unchanged.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
url: Url,
|
||||||
|
bundle: Option<SourceBundle>,
|
||||||
|
etag: Option<&str>,
|
||||||
|
) -> Result<Reply, Failure> {
|
||||||
|
let mutation = method != Method::GET;
|
||||||
|
let expected_status = if method == Method::POST { 201 } else { 200 };
|
||||||
|
let mut request = self.client.request(method, url);
|
||||||
|
if let Some(bundle) = bundle {
|
||||||
|
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
|
||||||
|
Failure::local(
|
||||||
|
"invalid_source_project",
|
||||||
|
"The source project could not be encoded for the service.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if bytes.len() > 8 * 1024 * 1024 {
|
||||||
|
return Err(Failure::local(
|
||||||
|
"request_too_large",
|
||||||
|
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
request = request
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(bytes);
|
||||||
|
}
|
||||||
|
if let Some(etag) = etag {
|
||||||
|
if !valid_etag(etag) {
|
||||||
|
return Err(Failure::local(
|
||||||
|
"invalid_etag",
|
||||||
|
"A captured strong server ETag is required to save. Check the server version first.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
request = request.header(header::IF_MATCH, etag);
|
||||||
|
}
|
||||||
|
let mut response = request.send().await.map_err(|error| Failure {
|
||||||
|
detail: if error.is_timeout() {
|
||||||
|
"The service request timed out. Check the server before retrying a write."
|
||||||
|
} else {
|
||||||
|
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
|
||||||
|
}.into(),
|
||||||
|
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
|
||||||
|
status: None,
|
||||||
|
uncertain: mutation && !error.is_connect() && !error.is_builder(),
|
||||||
|
published: None,
|
||||||
|
})?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let limit = if response.status().is_success() {
|
||||||
|
MAX_RESPONSE_BYTES
|
||||||
|
} else {
|
||||||
|
MAX_PROBLEM_BYTES
|
||||||
|
};
|
||||||
|
let uncertain =
|
||||||
|
mutation && (response.status().is_success() || response.status().is_server_error());
|
||||||
|
let oversized = || Failure {
|
||||||
|
detail:
|
||||||
|
"The service response exceeds the desktop transport limit. Local work is unchanged."
|
||||||
|
.into(),
|
||||||
|
code: "response_too_large".into(),
|
||||||
|
status: Some(status),
|
||||||
|
uncertain,
|
||||||
|
published: None,
|
||||||
|
};
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|size| size > limit as u64)
|
||||||
|
{
|
||||||
|
return Err(oversized());
|
||||||
|
}
|
||||||
|
let etag = response
|
||||||
|
.headers()
|
||||||
|
.get(header::ETAG)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_owned);
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
|
||||||
|
detail:
|
||||||
|
"The service response was interrupted. Check the server before retrying a write."
|
||||||
|
.into(),
|
||||||
|
code: "response_interrupted".into(),
|
||||||
|
status: Some(status),
|
||||||
|
uncertain,
|
||||||
|
published: None,
|
||||||
|
})? {
|
||||||
|
if chunk.len() > limit.saturating_sub(body.len()) {
|
||||||
|
return Err(oversized());
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
if status != expected_status {
|
||||||
|
let mut failure = Failure {
|
||||||
|
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
|
||||||
|
code: if (300..400).contains(&status) {
|
||||||
|
"redirect_refused"
|
||||||
|
} else {
|
||||||
|
"http_error"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
status: Some(status),
|
||||||
|
uncertain,
|
||||||
|
published: None,
|
||||||
|
};
|
||||||
|
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
|
||||||
|
failure.uncertain |= mutation && problem.code == "outcome_unknown";
|
||||||
|
// These service failures explicitly precede admission/commit.
|
||||||
|
// Other 5xx responses remain uncertain, including storage I/O.
|
||||||
|
if matches!(
|
||||||
|
(status, problem.code.as_str()),
|
||||||
|
(503, "overloaded") | (507, "store_capacity")
|
||||||
|
) {
|
||||||
|
failure.uncertain = false;
|
||||||
|
}
|
||||||
|
if !problem.code.is_empty()
|
||||||
|
&& problem.code.len() <= 80
|
||||||
|
&& problem
|
||||||
|
.code
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||||
|
{
|
||||||
|
failure.code = self.redact(problem.code);
|
||||||
|
}
|
||||||
|
let mut detail = problem.detail;
|
||||||
|
for issue in problem.issues.into_iter().take(20) {
|
||||||
|
detail.push('\n');
|
||||||
|
if let Some(path) = issue.path {
|
||||||
|
detail.push_str(&path);
|
||||||
|
if let Some(line) = issue.line {
|
||||||
|
detail.push_str(&format!(":{line}"));
|
||||||
|
if let Some(column) = issue.column {
|
||||||
|
detail.push_str(&format!(":{column}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detail.push_str(": ");
|
||||||
|
}
|
||||||
|
detail.push_str(&issue.message);
|
||||||
|
}
|
||||||
|
failure.detail = self.redact(detail);
|
||||||
|
}
|
||||||
|
return Err(failure);
|
||||||
|
}
|
||||||
|
Ok(Reply { status, etag, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
|
||||||
|
let mut url = self.endpoint("projects");
|
||||||
|
{
|
||||||
|
let mut pairs = url.query_pairs_mut();
|
||||||
|
pairs.append_pair("limit", "100");
|
||||||
|
if !query.is_empty() {
|
||||||
|
pairs.append_pair("q", query);
|
||||||
|
}
|
||||||
|
if let Some(after) = after {
|
||||||
|
pairs.append_pair("after", &after.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reply = self.request(Method::GET, url, None, None).await?;
|
||||||
|
let catalog: Catalog = self.decode(&reply, false)?;
|
||||||
|
let mut previous = after;
|
||||||
|
for item in &catalog.items {
|
||||||
|
if item.resource_id.is_nil()
|
||||||
|
|| item.model_id.is_nil()
|
||||||
|
|| item.revision == 0
|
||||||
|
|| previous.is_some_and(|id| item.resource_id <= id)
|
||||||
|
{
|
||||||
|
return Err(self.invalid_reply(
|
||||||
|
&reply,
|
||||||
|
false,
|
||||||
|
"The service returned an invalid project catalog.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
previous = Some(item.resource_id);
|
||||||
|
}
|
||||||
|
if catalog.items.len() > 100
|
||||||
|
|| catalog
|
||||||
|
.next_after
|
||||||
|
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
|
||||||
|
{
|
||||||
|
return Err(self.invalid_reply(
|
||||||
|
&reply,
|
||||||
|
false,
|
||||||
|
"The service returned invalid catalog pagination.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
|
||||||
|
let reply = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
self.endpoint(&format!("projects/{id}")),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let envelope: Envelope = self.decode(&reply, false)?;
|
||||||
|
let etag = self.etag(&reply, false)?;
|
||||||
|
if envelope.resource_id != id
|
||||||
|
|| id.is_nil()
|
||||||
|
|| envelope.revision == 0
|
||||||
|
|| envelope.model.id.is_nil()
|
||||||
|
{
|
||||||
|
return Err(self.invalid_reply(
|
||||||
|
&reply,
|
||||||
|
false,
|
||||||
|
"The service returned a different project identity or invalid revision.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let compiled = self.compile(envelope.source_project, &reply, false).await?;
|
||||||
|
if compiled.decoded.project != envelope.model {
|
||||||
|
return Err(self.invalid_reply(
|
||||||
|
&reply,
|
||||||
|
false,
|
||||||
|
"The hosted source and model disagree. The document was not opened.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Remote {
|
||||||
|
resource_id: id,
|
||||||
|
revision: envelope.revision,
|
||||||
|
etag,
|
||||||
|
compiled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||||
|
let reply = self
|
||||||
|
.request(
|
||||||
|
Method::POST,
|
||||||
|
self.endpoint("projects/import-source-project"),
|
||||||
|
Some(bundle),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let receipt: Summary = self.decode(&reply, true)?;
|
||||||
|
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
|
||||||
|
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
|
||||||
|
}
|
||||||
|
let published = Some(receipt.resource_id);
|
||||||
|
let etag = self.etag(&reply, true).map_err(|mut failure| {
|
||||||
|
failure.published = published;
|
||||||
|
failure
|
||||||
|
})?;
|
||||||
|
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
|
||||||
|
failure.uncertain = true;
|
||||||
|
failure.published = published;
|
||||||
|
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
|
||||||
|
failure
|
||||||
|
})?;
|
||||||
|
if remote.revision != receipt.revision
|
||||||
|
|| remote.etag != etag
|
||||||
|
|| remote.compiled.decoded.project.id != receipt.model_id
|
||||||
|
{
|
||||||
|
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
|
||||||
|
failure.published = published;
|
||||||
|
return Err(failure);
|
||||||
|
}
|
||||||
|
Ok(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||||
|
let reply = self
|
||||||
|
.request(
|
||||||
|
Method::PUT,
|
||||||
|
self.endpoint(&format!("projects/{id}/source-project")),
|
||||||
|
Some(bundle),
|
||||||
|
Some(etag),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let accepted: AcceptedSource = self.decode(&reply, true)?;
|
||||||
|
let etag = self.etag(&reply, true)?;
|
||||||
|
if id.is_nil() || accepted.revision == 0 {
|
||||||
|
return Err(self.invalid_reply(
|
||||||
|
&reply,
|
||||||
|
true,
|
||||||
|
"The service returned an invalid saved revision.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let compiled = self.compile(accepted.source_project, &reply, true).await?;
|
||||||
|
Ok(Remote {
|
||||||
|
resource_id: id,
|
||||||
|
revision: accepted.revision,
|
||||||
|
etag,
|
||||||
|
compiled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compile(
|
||||||
|
&self,
|
||||||
|
bundle: SourceBundle,
|
||||||
|
reply: &Reply,
|
||||||
|
mutation: bool,
|
||||||
|
) -> Result<Arc<CompiledSourceProject>, Failure> {
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
|
||||||
|
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
self.invalid_reply(
|
||||||
|
reply,
|
||||||
|
mutation,
|
||||||
|
"The hosted source could not be checked. Local work is unchanged.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
|
||||||
|
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
|
||||||
|
Err(issues) => {
|
||||||
|
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
|
||||||
|
for issue in issues.into_iter().take(10) {
|
||||||
|
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
|
||||||
|
}
|
||||||
|
failure.detail = self.redact(failure.detail);
|
||||||
|
Err(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_etag(value: &str) -> bool {
|
||||||
|
(2..=1_024).contains(&value.len())
|
||||||
|
&& value.starts_with('"')
|
||||||
|
&& value.ends_with('"')
|
||||||
|
&& value.as_bytes()[1..value.len() - 1]
|
||||||
|
.iter()
|
||||||
|
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "client_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post, put},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
sync::oneshot,
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
base: String,
|
||||||
|
stop: Option<oneshot::Sender<()>>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
_directory: Option<tempfile::TempDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestServer {
|
||||||
|
async fn fixture(router: Router) -> Self {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let (stop, done) = oneshot::channel();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = done.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
base,
|
||||||
|
stop: Some(stop),
|
||||||
|
task,
|
||||||
|
_directory: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn production(token: Option<String>, prefix: bool) -> Self {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().to_owned();
|
||||||
|
let store = Arc::new(
|
||||||
|
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let router = oa_server::app(store, token);
|
||||||
|
let router = if prefix {
|
||||||
|
Router::new().nest("/teams/engineering", router)
|
||||||
|
} else {
|
||||||
|
router
|
||||||
|
};
|
||||||
|
let mut server = Self::fixture(router).await;
|
||||||
|
server._directory = Some(directory);
|
||||||
|
if prefix {
|
||||||
|
server.base.push_str("/teams/engineering");
|
||||||
|
}
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection(&self) -> Connection {
|
||||||
|
Connection::new(&self.base, Secret::default()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(mut self) {
|
||||||
|
self.stop.take().unwrap().send(()).unwrap();
|
||||||
|
(&mut self.task).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authored() -> SourceBundle {
|
||||||
|
let bundle = SourceBundle {
|
||||||
|
entry: "harbor.arcadia".into(),
|
||||||
|
files: BTreeMap::from([
|
||||||
|
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
|
||||||
|
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||||
|
.unwrap()
|
||||||
|
.bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
|
||||||
|
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||||
|
.unwrap()
|
||||||
|
.decoded
|
||||||
|
.project;
|
||||||
|
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
|
||||||
|
for base in [
|
||||||
|
"http://example.com",
|
||||||
|
"http://192.168.1.4",
|
||||||
|
"ftp://localhost",
|
||||||
|
"not a url",
|
||||||
|
"https://user:secret@example.com",
|
||||||
|
"https://@example.com",
|
||||||
|
"https://user@example.com",
|
||||||
|
"https://example.com/?token=secret",
|
||||||
|
"https://example.com/#secret",
|
||||||
|
"https://example.com\\hidden",
|
||||||
|
"https://example.com/\nsecret",
|
||||||
|
] {
|
||||||
|
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
|
||||||
|
assert_eq!(failure.code, "invalid_endpoint");
|
||||||
|
assert!(!failure.detail.contains("secret"));
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
}
|
||||||
|
for base in [
|
||||||
|
"http://localhost:8787",
|
||||||
|
"http://127.0.0.2:8787",
|
||||||
|
"http://[::1]:8787",
|
||||||
|
"https://example.com/prefix",
|
||||||
|
] {
|
||||||
|
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
|
||||||
|
}
|
||||||
|
let client = Connection::new(
|
||||||
|
"https://example.com/teams/space%20name",
|
||||||
|
Secret("private-token".into()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
|
||||||
|
assert_eq!(
|
||||||
|
client.endpoint("projects").as_str(),
|
||||||
|
"https://example.com/teams/space%20name/api/v1/projects"
|
||||||
|
);
|
||||||
|
assert!(!format!("{client:?}").contains("private-token"));
|
||||||
|
assert_eq!(
|
||||||
|
format!("{:?}", Secret("private-token".into())),
|
||||||
|
"Secret([redacted])"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Connection::new("http://localhost", Secret("bad\nheader".into()))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"invalid_token"
|
||||||
|
);
|
||||||
|
for etag in [
|
||||||
|
"*",
|
||||||
|
"W/\"weak\"",
|
||||||
|
"unquoted",
|
||||||
|
"\"one\",\"two\"",
|
||||||
|
"\"new\nline\"",
|
||||||
|
] {
|
||||||
|
assert!(!valid_etag(etag), "{etag}");
|
||||||
|
}
|
||||||
|
assert!(valid_etag("\"opaque:tag/+,=?\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
|
||||||
|
let server = TestServer::production(None, false).await;
|
||||||
|
let client = server.connection();
|
||||||
|
assert!(client.catalog("", None).await.unwrap().items.is_empty());
|
||||||
|
let bundle = authored();
|
||||||
|
let original = client.publish(bundle.clone()).await.unwrap();
|
||||||
|
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
|
||||||
|
assert_eq!(original.compiled.bundle, bundle);
|
||||||
|
assert_eq!(original.revision, 1);
|
||||||
|
let catalog = client.catalog("Harbor &", None).await.unwrap();
|
||||||
|
assert_eq!(catalog.items.len(), 1);
|
||||||
|
assert_eq!(catalog.items[0].resource_id, original.resource_id);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.items[0].model_id,
|
||||||
|
original.compiled.decoded.project.id
|
||||||
|
);
|
||||||
|
assert_eq!(catalog.items[0].name, "Harbor & rescue");
|
||||||
|
assert_eq!(
|
||||||
|
(catalog.items[0].elements, catalog.items[0].relations),
|
||||||
|
(2, 0)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.catalog("?q=missing&limit=1000", None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.catalog("", Some(original.resource_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let same = client
|
||||||
|
.save(original.resource_id, &original.etag, bundle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(same.etag, original.etag);
|
||||||
|
assert_eq!(same.revision, original.revision);
|
||||||
|
let mut changed = bundle.clone();
|
||||||
|
changed
|
||||||
|
.files
|
||||||
|
.get_mut("work/operations.arcadia")
|
||||||
|
.unwrap()
|
||||||
|
.push_str("// Reviewed by the team\r\n");
|
||||||
|
let saved = client
|
||||||
|
.save(original.resource_id, &original.etag, changed.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved.resource_id, original.resource_id);
|
||||||
|
assert_eq!(saved.revision, 2);
|
||||||
|
assert_ne!(saved.etag, original.etag);
|
||||||
|
assert_eq!(saved.compiled.bundle, changed);
|
||||||
|
assert_eq!(
|
||||||
|
saved.compiled.decoded.project,
|
||||||
|
original.compiled.decoded.project
|
||||||
|
);
|
||||||
|
let conflict = client
|
||||||
|
.save(original.resource_id, &original.etag, bundle)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(conflict.status, Some(412));
|
||||||
|
assert_eq!(conflict.code, "revision_conflict");
|
||||||
|
assert!(!conflict.uncertain);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.open(original.resource_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.compiled
|
||||||
|
.bundle,
|
||||||
|
changed
|
||||||
|
);
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
|
||||||
|
let token = "hosted-test-token-with-at-least-32-characters";
|
||||||
|
let server = TestServer::production(Some(token.into()), true).await;
|
||||||
|
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
(unauthorized.status, unauthorized.code.as_str()),
|
||||||
|
(Some(401), "unauthorized")
|
||||||
|
);
|
||||||
|
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
|
||||||
|
let original = client.publish(authored()).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.catalog("Harbor &", None).await.unwrap().items.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let mut invalid = original.compiled.bundle.clone();
|
||||||
|
invalid
|
||||||
|
.files
|
||||||
|
.get_mut("work/operations.arcadia")
|
||||||
|
.unwrap()
|
||||||
|
.push_str("this is not valid source;");
|
||||||
|
let rejected = client
|
||||||
|
.save(original.resource_id, &original.etag, invalid)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(rejected.status, Some(422));
|
||||||
|
assert!(!rejected.uncertain);
|
||||||
|
assert!(rejected.detail.contains("work/operations.arcadia:"));
|
||||||
|
assert_eq!(
|
||||||
|
client.open(original.resource_id).await.unwrap().etag,
|
||||||
|
original.etag
|
||||||
|
);
|
||||||
|
let missing = client.open(Id::new_v4()).await.unwrap_err();
|
||||||
|
assert_eq!(missing.status, Some(404));
|
||||||
|
assert!(!missing.uncertain);
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
|
||||||
|
let redirected = Arc::new(AtomicUsize::new(0));
|
||||||
|
let calls = Arc::clone(&redirected);
|
||||||
|
let destination = TestServer::fixture(Router::new().fallback(move || {
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
let location = destination.base.clone();
|
||||||
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let seen = Arc::clone(&attempts);
|
||||||
|
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||||
|
let location = location.clone();
|
||||||
|
let seen = Arc::clone(&seen);
|
||||||
|
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
|
||||||
|
})).route("/api/v1/projects", get(|| async {
|
||||||
|
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
|
||||||
|
}))).await;
|
||||||
|
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
|
||||||
|
let failure = client.publish(authored()).await.unwrap_err();
|
||||||
|
assert_eq!(failure.code, "redirect_refused");
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(redirected.load(Ordering::SeqCst), 0);
|
||||||
|
let failure = client.catalog("", None).await.unwrap_err();
|
||||||
|
assert!(failure.detail.contains("[redacted]"));
|
||||||
|
assert!(!format!("{failure:?}").contains("private-test-token"));
|
||||||
|
server.finish().await;
|
||||||
|
destination.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let stable = authored();
|
||||||
|
let mut mismatched = envelope(stable.clone(), id, 1);
|
||||||
|
mismatched["model"]["name"] = json!("different model");
|
||||||
|
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
|
||||||
|
// Keep this independently useful even if the model's schema changes.
|
||||||
|
wrong_id["revision"] = json!(1);
|
||||||
|
let mut unstable = envelope(stable.clone(), id, 1);
|
||||||
|
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
|
||||||
|
for (body, etag) in [
|
||||||
|
(mismatched, Some("\"opaque\"")),
|
||||||
|
(wrong_id, Some("\"opaque\"")),
|
||||||
|
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
|
||||||
|
(envelope(stable, id, 1), None),
|
||||||
|
(unstable, Some("\"opaque\"")),
|
||||||
|
] {
|
||||||
|
let server = TestServer::fixture(Router::new().route(
|
||||||
|
"/api/v1/projects/{id}",
|
||||||
|
get(move || {
|
||||||
|
let body = body.clone();
|
||||||
|
async move {
|
||||||
|
let mut response = Json(body).into_response();
|
||||||
|
if let Some(etag) = etag {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ETAG, etag.parse().unwrap());
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let failure = server.connection().open(id).await.unwrap_err();
|
||||||
|
assert_eq!(failure.code, "invalid_response");
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let etag = "\"opaque:tag/+,=?\"";
|
||||||
|
let bundle = authored();
|
||||||
|
let response_bundle = bundle.clone();
|
||||||
|
let server = TestServer::fixture(Router::new().route(
|
||||||
|
"/api/v1/projects/{id}/source-project",
|
||||||
|
put(
|
||||||
|
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
|
||||||
|
let expected_bundle = response_bundle.clone();
|
||||||
|
async move {
|
||||||
|
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
|
||||||
|
assert_eq!(body, expected_bundle);
|
||||||
|
(
|
||||||
|
[(header::ETAG, "\"accepted:new-tag\"")],
|
||||||
|
Json(json!({"revision":8,"changed":false,"source_project":body})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = server.connection();
|
||||||
|
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
|
||||||
|
assert_eq!(saved.etag, "\"accepted:new-tag\"");
|
||||||
|
assert_eq!(saved.resource_id, id);
|
||||||
|
assert_eq!(saved.revision, 8);
|
||||||
|
assert_eq!(saved.compiled.bundle, bundle.clone());
|
||||||
|
assert_eq!(
|
||||||
|
client.save(id, "*", bundle).await.unwrap_err().code,
|
||||||
|
"invalid_etag"
|
||||||
|
);
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_publication_retains_known_resource_after_failed_followup() {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let calls = Arc::clone(&attempts);
|
||||||
|
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
|
||||||
|
}
|
||||||
|
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
|
||||||
|
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||||
|
assert_eq!(failure.published, Some(id));
|
||||||
|
assert!(failure.uncertain);
|
||||||
|
assert_eq!(failure.status, Some(503));
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the entire request before simulating a lost/malformed response. The
|
||||||
|
/// counter proves the production client never repeats an admitted publication.
|
||||||
|
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let attempts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let calls = Arc::clone(&attempts);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.unwrap();
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||||
|
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
|
||||||
|
let length = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.strip_prefix("content-length:")
|
||||||
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if request.len() >= end + 4 + length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let _ = stream.write_all(&reply).await;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(base, attempts, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
|
||||||
|
let (base, attempts, task) = raw_fixture(Vec::new()).await;
|
||||||
|
let client = Connection::new(&base, Secret::default()).unwrap();
|
||||||
|
let failure = client.publish(authored()).await.unwrap_err();
|
||||||
|
assert!(failure.uncertain);
|
||||||
|
assert_eq!(failure.published, None);
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
|
||||||
|
let declared = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
MAX_RESPONSE_BYTES + 1
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let mut chunked =
|
||||||
|
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
|
||||||
|
let chunk = vec![b' '; 64 * 1024];
|
||||||
|
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
|
||||||
|
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
|
||||||
|
chunked.extend_from_slice(&chunk);
|
||||||
|
chunked.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
chunked.extend_from_slice(b"0\r\n\r\n");
|
||||||
|
for reply in [declared, chunked] {
|
||||||
|
let (base, _, task) = raw_fixture(reply).await;
|
||||||
|
let failure = Connection::new(&base, Secret::default())
|
||||||
|
.unwrap()
|
||||||
|
.catalog("", None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(failure.code, "response_too_large");
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
|
||||||
|
let received = Arc::new(AtomicUsize::new(0));
|
||||||
|
let calls = Arc::clone(&received);
|
||||||
|
let server = TestServer::fixture(Router::new().route(
|
||||||
|
"/api/v1/projects/import-source-project",
|
||||||
|
post(move || {
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
StatusCode::CREATED
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client =
|
||||||
|
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
|
||||||
|
.unwrap();
|
||||||
|
let failure = client.publish(authored()).await.unwrap_err();
|
||||||
|
assert_eq!(failure.code, "timeout");
|
||||||
|
assert!(failure.uncertain);
|
||||||
|
assert_eq!(received.load(Ordering::SeqCst), 1);
|
||||||
|
let base = server.base.clone();
|
||||||
|
server.finish().await;
|
||||||
|
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.catalog("", None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
failure.code.as_str(),
|
||||||
|
"connection_failed" | "timeout"
|
||||||
|
));
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let bundle = authored();
|
||||||
|
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||||
|
.unwrap()
|
||||||
|
.decoded
|
||||||
|
.project
|
||||||
|
.id;
|
||||||
|
let mut changed = bundle.clone();
|
||||||
|
changed
|
||||||
|
.files
|
||||||
|
.get_mut("work/operations.arcadia")
|
||||||
|
.unwrap()
|
||||||
|
.push_str("// Concurrent external review\r\n");
|
||||||
|
let head = envelope(changed, id, 2);
|
||||||
|
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
|
||||||
|
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
|
||||||
|
})).route("/api/v1/projects/{id}", get(move || {
|
||||||
|
let head = head.clone();
|
||||||
|
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
|
||||||
|
}))).await;
|
||||||
|
let failure = server.connection().publish(bundle).await.unwrap_err();
|
||||||
|
assert!(failure.uncertain);
|
||||||
|
assert_eq!(failure.published, Some(id));
|
||||||
|
assert!(failure.detail.contains("changed before"));
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
|
||||||
|
for (status, code, uncertain) in [
|
||||||
|
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
|
||||||
|
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
|
||||||
|
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
|
||||||
|
] {
|
||||||
|
let server = TestServer::fixture(Router::new().route(
|
||||||
|
"/api/v1/projects/import-source-project",
|
||||||
|
post(move || async move {
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||||
|
assert_eq!(failure.status, Some(status.as_u16()));
|
||||||
|
assert_eq!(failure.code, code);
|
||||||
|
assert_eq!(failure.uncertain, uncertain, "{code}");
|
||||||
|
assert_eq!(failure.detail, "Service-specific actionable detail");
|
||||||
|
server.finish().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
|
||||||
|
let mut bundle = authored();
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.get_mut(&bundle.entry)
|
||||||
|
.unwrap()
|
||||||
|
.push_str(&" ".repeat(8 * 1024 * 1024));
|
||||||
|
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
|
||||||
|
.unwrap()
|
||||||
|
.publish(bundle)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(failure.code, "request_too_large");
|
||||||
|
assert!(!failure.uncertain);
|
||||||
|
assert!(failure.published.is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Optional hosted transport state. The Document remains the local editing history.
|
||||||
|
pub mod client;
|
||||||
|
pub mod view;
|
||||||
|
|
||||||
|
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
|
||||||
|
use oa_core::{Id, source_project::CompiledSourceProject};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Event {
|
||||||
|
Show,
|
||||||
|
Close,
|
||||||
|
Base(String),
|
||||||
|
Token(Secret),
|
||||||
|
Query(String),
|
||||||
|
Connect,
|
||||||
|
More,
|
||||||
|
Select(Id),
|
||||||
|
OpenSelected,
|
||||||
|
Publish,
|
||||||
|
Save,
|
||||||
|
Check,
|
||||||
|
Reload,
|
||||||
|
ReviewFile(String),
|
||||||
|
ReviewAction(iced::widget::text_editor::Action),
|
||||||
|
ReviewBack,
|
||||||
|
Local,
|
||||||
|
Forget,
|
||||||
|
Completed(Id, Id, Result<Outcome, Failure>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Catalog(Catalog),
|
||||||
|
Remote(Remote),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Operation {
|
||||||
|
Catalog(bool),
|
||||||
|
Open(Id),
|
||||||
|
Publish,
|
||||||
|
Save,
|
||||||
|
Check,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub connection: Connection,
|
||||||
|
pub resource_id: Id,
|
||||||
|
pub revision: u64,
|
||||||
|
pub etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Request {
|
||||||
|
pub id: Id,
|
||||||
|
pub document: Id,
|
||||||
|
pub revision: u64,
|
||||||
|
pub operation: Operation,
|
||||||
|
pub connection: Connection,
|
||||||
|
pub resource_id: Option<Id>,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
pub snapshot: Arc<CompiledSourceProject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Review {
|
||||||
|
pub connection: Connection,
|
||||||
|
pub remote: Remote,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct State {
|
||||||
|
pub visible: bool,
|
||||||
|
pub base: String,
|
||||||
|
pub token: Secret,
|
||||||
|
pub query: String,
|
||||||
|
pub catalog: Vec<Summary>,
|
||||||
|
pub catalog_stale: bool,
|
||||||
|
pub next: Option<Id>,
|
||||||
|
pub selected: Option<Id>,
|
||||||
|
pub connection: Option<Connection>,
|
||||||
|
pub session: Option<Session>,
|
||||||
|
pub request: Option<Request>,
|
||||||
|
pub uncertain: Option<Request>,
|
||||||
|
pub review: Option<Review>,
|
||||||
|
pub review_path: Option<String>,
|
||||||
|
pub review_editor: iced::widget::text_editor::Content,
|
||||||
|
pub error: Option<Failure>,
|
||||||
|
pub open_target: Option<(Connection, Id)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for State {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
visible: false,
|
||||||
|
base: "http://127.0.0.1:8787".into(),
|
||||||
|
token: Secret::default(),
|
||||||
|
query: String::new(),
|
||||||
|
catalog: vec![],
|
||||||
|
catalog_stale: true,
|
||||||
|
next: None,
|
||||||
|
selected: None,
|
||||||
|
connection: None,
|
||||||
|
session: None,
|
||||||
|
request: None,
|
||||||
|
uncertain: None,
|
||||||
|
review: None,
|
||||||
|
review_path: None,
|
||||||
|
review_editor: iced::widget::text_editor::Content::new(),
|
||||||
|
error: None,
|
||||||
|
open_target: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn invalidate_catalog(&mut self) {
|
||||||
|
self.catalog_stale = true;
|
||||||
|
self.next = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog_message(&self) -> Option<&'static str> {
|
||||||
|
if self.connection.is_none() {
|
||||||
|
Some("Connect / refresh to load models for this service.")
|
||||||
|
} else if self.catalog_stale {
|
||||||
|
Some("Catalog may be out of date. Connect / refresh to update these results.")
|
||||||
|
} else if self.catalog.is_empty() {
|
||||||
|
Some("No models found. Publish the current model or change the search.")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep explicit connection settings in memory, but never carry a resource
|
||||||
|
/// binding or in-flight result into another local document.
|
||||||
|
pub fn leave_document(&mut self) {
|
||||||
|
self.visible = false;
|
||||||
|
self.session = None;
|
||||||
|
self.request = None;
|
||||||
|
self.uncertain = None;
|
||||||
|
self.review = None;
|
||||||
|
self.review_path = None;
|
||||||
|
self.review_editor = iced::widget::text_editor::Content::new();
|
||||||
|
self.error = None;
|
||||||
|
self.open_target = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Hosted dialog. Main-header integration is a small caller-owned hook.
|
||||||
|
use super::{Event, Operation, client::Secret};
|
||||||
|
use crate::{
|
||||||
|
app::{App, Message},
|
||||||
|
style,
|
||||||
|
views::{action, label},
|
||||||
|
};
|
||||||
|
use iced::{
|
||||||
|
Element, Fill, Font,
|
||||||
|
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
|
||||||
|
};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||||
|
if !app.hosted.visible
|
||||||
|
|| app.pending.is_some()
|
||||||
|
|| app.file_prompt.is_some()
|
||||||
|
|| app.workflow.is_some()
|
||||||
|
|| app.delete_pending
|
||||||
|
{
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
widget::stack![
|
||||||
|
base,
|
||||||
|
widget::opaque(
|
||||||
|
container(
|
||||||
|
container(dialog(app))
|
||||||
|
.padding(22)
|
||||||
|
.width((app.window_size.width - 64.).clamp(680., 1000.))
|
||||||
|
.height((app.window_size.height - 80.).max(400.))
|
||||||
|
.style(style::card)
|
||||||
|
)
|
||||||
|
.center(Fill)
|
||||||
|
.style(|_| container::Style {
|
||||||
|
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
|
||||||
|
button(text(title).size(12))
|
||||||
|
.padding([9, 12])
|
||||||
|
.style(style::nav)
|
||||||
|
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog(app: &App) -> Element<'_, Message> {
|
||||||
|
let state = &app.hosted;
|
||||||
|
let enabled = !app.busy;
|
||||||
|
if let Some(path) = &state.review_path {
|
||||||
|
return column![
|
||||||
|
text("Server source - read only").size(22),
|
||||||
|
text(path).size(13),
|
||||||
|
text_editor(&state.review_editor)
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.size(13)
|
||||||
|
.height(Fill)
|
||||||
|
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
|
||||||
|
control("Back to review", Event::ReviewBack, enabled),
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
let mut content = column![
|
||||||
|
label("SERVICE ADDRESS"),
|
||||||
|
text_input("http://127.0.0.1:8787", &state.base)
|
||||||
|
.padding(10)
|
||||||
|
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
||||||
|
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
||||||
|
text_input("Optional for a loopback service", &state.token.0)
|
||||||
|
.secure(true)
|
||||||
|
.padding(10)
|
||||||
|
.on_input_maybe(
|
||||||
|
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
|
||||||
|
),
|
||||||
|
row![
|
||||||
|
control("Connect / refresh", Event::Connect, enabled),
|
||||||
|
control(
|
||||||
|
"Forget connection",
|
||||||
|
Event::Forget,
|
||||||
|
enabled && state.session.is_none()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
if let Some(session) = &state.session {
|
||||||
|
content = content.push(container(column![
|
||||||
|
text("Current hosted model").size(16),
|
||||||
|
text(session.connection.base()).size(12),
|
||||||
|
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
|
||||||
|
label("Edits and Undo stay local. Save sends the complete authored source project."),
|
||||||
|
row![
|
||||||
|
control("Check server", Event::Check, enabled),
|
||||||
|
control("Save as local...", Event::Local, enabled),
|
||||||
|
].spacing(8),
|
||||||
|
].spacing(8)).padding(12).style(style::panel));
|
||||||
|
}
|
||||||
|
if let Some(error) = &state.error {
|
||||||
|
content = content.push(
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text(if error.uncertain {
|
||||||
|
"The write outcome is unknown"
|
||||||
|
} else {
|
||||||
|
"Hosted action needs attention"
|
||||||
|
})
|
||||||
|
.size(16),
|
||||||
|
text(&error.detail).size(13),
|
||||||
|
label("Your local model and source history are retained."),
|
||||||
|
]
|
||||||
|
.spacing(7),
|
||||||
|
)
|
||||||
|
.padding(12)
|
||||||
|
.style(style::panel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(failed) = &state.uncertain {
|
||||||
|
if failed.operation == Operation::Publish {
|
||||||
|
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
|
||||||
|
}
|
||||||
|
if failed.resource_id.is_some() {
|
||||||
|
content = content.push(control("Check previous write", Event::Check, enabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(review) = &state.review {
|
||||||
|
let remote = &review.remote;
|
||||||
|
let local = &app.document.bundle().files;
|
||||||
|
let server = &remote.compiled.bundle.files;
|
||||||
|
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
|
||||||
|
let mut changes = column![
|
||||||
|
text(format!("Review server revision {}", remote.revision)).size(17),
|
||||||
|
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
|
||||||
|
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
|
||||||
|
].spacing(8);
|
||||||
|
if app.document.bundle().entry != remote.compiled.bundle.entry {
|
||||||
|
changes = changes.push(
|
||||||
|
text(format!(
|
||||||
|
"Entry: {} -> {}",
|
||||||
|
app.document.bundle().entry,
|
||||||
|
remote.compiled.bundle.entry
|
||||||
|
))
|
||||||
|
.size(12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for path in paths {
|
||||||
|
if local.get(path) != server.get(path) {
|
||||||
|
let change = match (local.contains_key(path), server.contains_key(path)) {
|
||||||
|
(false, true) => "Added on server",
|
||||||
|
(true, false) => "Removed on server",
|
||||||
|
_ => "Changed",
|
||||||
|
};
|
||||||
|
changes = changes.push(
|
||||||
|
row![
|
||||||
|
text(format!("{change}: {path}")).size(12).width(Fill),
|
||||||
|
control(
|
||||||
|
"View server text",
|
||||||
|
Event::ReviewFile(path.clone()),
|
||||||
|
enabled && server.contains_key(path)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Center),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes = changes.push(control("Reload server version", Event::Reload, enabled));
|
||||||
|
content = content.push(container(changes).padding(12).style(style::panel));
|
||||||
|
}
|
||||||
|
content = content.push(text("Hosted model catalog").size(17)).push(
|
||||||
|
row![
|
||||||
|
text_input("Search model names", &state.query)
|
||||||
|
.padding(9)
|
||||||
|
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
||||||
|
.on_submit(Message::Hosted(Event::Connect)),
|
||||||
|
control("Search", Event::Connect, enabled),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
);
|
||||||
|
if let Some(message) = state.catalog_message() {
|
||||||
|
content = content.push(label(message));
|
||||||
|
}
|
||||||
|
for item in &state.catalog {
|
||||||
|
let selected = state.selected == Some(item.resource_id);
|
||||||
|
content = content.push(
|
||||||
|
button(
|
||||||
|
column![
|
||||||
|
text(&item.name).size(14),
|
||||||
|
label(format!(
|
||||||
|
"{} - revision {} - {} objects / {} links",
|
||||||
|
item.resource_id, item.revision, item.elements, item.relations
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
.spacing(4),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Fill)
|
||||||
|
.style(if selected {
|
||||||
|
style::selected
|
||||||
|
} else {
|
||||||
|
style::nav
|
||||||
|
})
|
||||||
|
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !state.catalog_stale && state.next.is_some() {
|
||||||
|
content = content.push(control("More models", Event::More, enabled));
|
||||||
|
}
|
||||||
|
let publish_label = if state
|
||||||
|
.uncertain
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| r.operation == Operation::Publish)
|
||||||
|
{
|
||||||
|
"Publish another copy"
|
||||||
|
} else {
|
||||||
|
"Publish as new"
|
||||||
|
};
|
||||||
|
column![
|
||||||
|
text("Hosted models").size(24),
|
||||||
|
label("Optional shared storage for desktop, scripts and engineering integrations."),
|
||||||
|
scrollable(content.spacing(12)).height(Fill),
|
||||||
|
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
|
||||||
|
row![
|
||||||
|
control("Close", Event::Close, enabled),
|
||||||
|
control(publish_label, Event::Publish, enabled),
|
||||||
|
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
|
||||||
|
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
|
||||||
|
].spacing(8),
|
||||||
|
].spacing(12).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept here so a caller can integrate the feature without duplicating event wiring.
|
||||||
|
pub fn header_action() -> widget::Button<'static, Message> {
|
||||||
|
action("Hosted models...", Message::Hosted(Event::Show))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ mod explorer_view;
|
|||||||
mod forms;
|
mod forms;
|
||||||
mod guide;
|
mod guide;
|
||||||
mod highlight;
|
mod highlight;
|
||||||
|
mod hosted;
|
||||||
mod model_menu;
|
mod model_menu;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
mod port_edit;
|
mod port_edit;
|
||||||
|
|||||||
@@ -8,16 +8,28 @@ use oa_core::{transitions::*, *};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
mod constructors;
|
||||||
|
mod history;
|
||||||
|
mod keyed;
|
||||||
|
mod message_kind;
|
||||||
mod scalar;
|
mod scalar;
|
||||||
mod view;
|
mod view;
|
||||||
pub(crate) use view::summary;
|
pub(crate) use view::summary;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod constructor_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod keyed_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod message_kind_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
Facts,
|
Facts,
|
||||||
References,
|
References,
|
||||||
|
Members,
|
||||||
|
History,
|
||||||
Impacts,
|
Impacts,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +55,16 @@ pub enum Event {
|
|||||||
RemoveReference(Id),
|
RemoveReference(Id),
|
||||||
AcceptImpact(usize, bool),
|
AcceptImpact(usize, bool),
|
||||||
Preview,
|
Preview,
|
||||||
|
Reset(FacetSelector, bool),
|
||||||
|
Presence(FacetSelector, bool),
|
||||||
|
MemberFamily(keyed::Family),
|
||||||
|
MemberOa(Option<Choice>),
|
||||||
|
MemberSa(Option<Choice>),
|
||||||
|
Literal(String),
|
||||||
|
LiteralSide(Layer, bool),
|
||||||
|
EditPair(FacetSelector),
|
||||||
|
StageMember,
|
||||||
|
DropStaged(FacetSelector),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct State {
|
pub struct State {
|
||||||
@@ -53,7 +75,19 @@ pub struct State {
|
|||||||
pub selected: Option<FacetSelector>,
|
pub selected: Option<FacetSelector>,
|
||||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||||
pub references: BTreeMap<Id, Id>,
|
pub references: BTreeMap<Id, Id>,
|
||||||
|
pub resets: BTreeSet<FacetSelector>,
|
||||||
|
pub staged: BTreeSet<FacetSelector>,
|
||||||
|
pub presence: BTreeMap<FacetSelector, bool>,
|
||||||
|
pub member_family: keyed::Family,
|
||||||
|
pub member_oa: Option<Choice>,
|
||||||
|
pub member_sa: Option<Choice>,
|
||||||
|
pub literal: String,
|
||||||
|
pub literal_oa: bool,
|
||||||
|
pub literal_sa: bool,
|
||||||
pub draft: Option<Draft>,
|
pub draft: Option<Draft>,
|
||||||
|
// Each closed constructor has an independent draft, including nested kinds.
|
||||||
|
// Scalar and sibling drafts survive decision and constructor changes.
|
||||||
|
pub group_drafts: BTreeMap<FacetSelector, Draft>,
|
||||||
pub plan: Option<SemanticTransitionPlan>,
|
pub plan: Option<SemanticTransitionPlan>,
|
||||||
pub proposed: Option<Project>,
|
pub proposed: Option<Project>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -95,7 +129,17 @@ impl State {
|
|||||||
selected: None,
|
selected: None,
|
||||||
decisions: BTreeMap::new(),
|
decisions: BTreeMap::new(),
|
||||||
references: BTreeMap::new(),
|
references: BTreeMap::new(),
|
||||||
|
resets: BTreeSet::new(),
|
||||||
|
staged: BTreeSet::new(),
|
||||||
|
presence: BTreeMap::new(),
|
||||||
|
member_family: keyed::Family::Literal,
|
||||||
|
member_oa: None,
|
||||||
|
member_sa: None,
|
||||||
|
literal: String::new(),
|
||||||
|
literal_oa: true,
|
||||||
|
literal_sa: true,
|
||||||
draft: None,
|
draft: None,
|
||||||
|
group_drafts: BTreeMap::new(),
|
||||||
plan: None,
|
plan: None,
|
||||||
proposed: None,
|
proposed: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -122,15 +166,41 @@ impl State {
|
|||||||
fn load_trace(&mut self, project: &Project) {
|
fn load_trace(&mut self, project: &Project) {
|
||||||
self.facts.clear();
|
self.facts.clear();
|
||||||
self.decisions.clear();
|
self.decisions.clear();
|
||||||
|
self.resets.clear();
|
||||||
|
self.staged.clear();
|
||||||
|
self.presence.clear();
|
||||||
|
self.member_oa = None;
|
||||||
|
self.member_sa = None;
|
||||||
|
self.literal.clear();
|
||||||
|
self.literal_oa = true;
|
||||||
|
self.literal_sa = true;
|
||||||
self.selected = None;
|
self.selected = None;
|
||||||
self.accepted_impacts.clear();
|
self.accepted_impacts.clear();
|
||||||
self.references.clear();
|
self.references.clear();
|
||||||
self.draft = self
|
self.draft = self
|
||||||
.target(project)
|
.target(project)
|
||||||
.and_then(|id| Draft::from_project(project, id));
|
.and_then(|id| Draft::from_project(project, id));
|
||||||
|
self.group_drafts.clear();
|
||||||
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||||
|
for facet in constructors::selectors() {
|
||||||
|
if constructors::available(project, trace, &facet)
|
||||||
|
&& let Some(mut draft) = self
|
||||||
|
.target(project)
|
||||||
|
.and_then(|id| Draft::from_project(project, id))
|
||||||
|
{
|
||||||
|
draft.semantic_groups = true;
|
||||||
|
self.group_drafts.insert(facet, draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||||
self.references.insert(trace.oa, trace.sa);
|
self.references.insert(trace.oa, trace.sa);
|
||||||
}
|
}
|
||||||
|
self.member_family = self
|
||||||
|
.member_families(project)
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(keyed::Family::Literal);
|
||||||
self.refresh(project);
|
self.refresh(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +238,17 @@ impl State {
|
|||||||
{
|
{
|
||||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||||
} else {
|
} else {
|
||||||
|
if keyed::supported(&facet) {
|
||||||
|
let target_presence = self
|
||||||
|
.effective_trace(project)
|
||||||
|
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
|
||||||
|
.and_then(|f| scalar::value(&f, self.direction.target_layer()))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
self.presence
|
||||||
|
.entry(facet.clone())
|
||||||
|
.or_insert(target_presence);
|
||||||
|
}
|
||||||
self.decisions.insert(facet, decision);
|
self.decisions.insert(facet, decision);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -187,16 +268,9 @@ impl State {
|
|||||||
Event::Shape(path, value) => {
|
Event::Shape(path, value) => {
|
||||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||||
}
|
}
|
||||||
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
Event::Variant(path, value) => {
|
||||||
if d.value
|
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||||
.pointer(&path)
|
}
|
||||||
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
d.shape_scoped(&path, value)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Event::Array(path, index, direction) => {
|
Event::Array(path, index, direction) => {
|
||||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||||
}
|
}
|
||||||
@@ -258,6 +332,57 @@ impl State {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Event::Preview => Ok(()),
|
Event::Preview => Ok(()),
|
||||||
|
Event::Reset(facet, reset) => self.reset_history(project, facet, reset),
|
||||||
|
Event::Presence(facet, present) => {
|
||||||
|
if keyed::supported(&facet)
|
||||||
|
&& self.decisions.get(&facet) == Some(&SemanticDecision::Candidate)
|
||||||
|
{
|
||||||
|
self.presence.insert(facet, present);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Choose Set final value for this member before editing presence.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::MemberFamily(family) => {
|
||||||
|
self.member_family = family;
|
||||||
|
replan = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Event::MemberOa(choice) => {
|
||||||
|
self.member_oa = choice;
|
||||||
|
replan = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Event::MemberSa(choice) => {
|
||||||
|
self.member_sa = choice;
|
||||||
|
replan = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Event::Literal(value) => {
|
||||||
|
self.literal = value;
|
||||||
|
replan = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Event::LiteralSide(layer, present) => {
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
self.literal_oa = present;
|
||||||
|
} else {
|
||||||
|
self.literal_sa = present;
|
||||||
|
}
|
||||||
|
replan = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Event::EditPair(facet) => {
|
||||||
|
replan = false;
|
||||||
|
self.edit_pair(project, facet)
|
||||||
|
}
|
||||||
|
Event::StageMember => self.stage_member(project),
|
||||||
|
Event::DropStaged(facet) => {
|
||||||
|
if self.staged.remove(&facet) {
|
||||||
|
self.decisions.remove(&facet);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
@@ -281,15 +406,23 @@ impl State {
|
|||||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||||
return Err("Choose Set final value before editing this property.".into());
|
return Err("Choose Set final value before editing this property.".into());
|
||||||
}
|
}
|
||||||
let root = self
|
let roots = self
|
||||||
.target(project)
|
.target(project)
|
||||||
.and_then(|id| scalar::binding(project, id, facet))
|
.and_then(|id| constructors::roots(project, id, facet))
|
||||||
.ok_or("Selected scalar property is unavailable.")?;
|
.ok_or("Selected property is unavailable.")?;
|
||||||
if path != root && !path.starts_with(&format!("{root}/")) {
|
if !roots
|
||||||
return Err("Only the selected scalar property can change in this draft.".into());
|
.iter()
|
||||||
|
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||||
|
{
|
||||||
|
return Err("Only the selected review property can change in this draft.".into());
|
||||||
}
|
}
|
||||||
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
|
let draft = if scalar::grouped(facet) {
|
||||||
// The scoped view sends only descendants of the selected scalar property.
|
self.group_drafts.get_mut(facet)
|
||||||
|
} else {
|
||||||
|
self.draft.as_mut()
|
||||||
|
}
|
||||||
|
.ok_or("Choose an existing target.")?;
|
||||||
|
// The scoped view sends only the selected property or closed group roots.
|
||||||
// Protected identity and top-level body/perspective controls are never exposed.
|
// Protected identity and top-level body/perspective controls are never exposed.
|
||||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
||||||
@@ -298,17 +431,35 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||||
|
if keyed::supported(facet) {
|
||||||
|
return self
|
||||||
|
.effective_trace(project)
|
||||||
|
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
|
||||||
|
}
|
||||||
self.target(project)
|
self.target(project)
|
||||||
.and_then(|id| scalar::binding(project, id, facet))
|
.and_then(|id| scalar::binding(project, id, facet))
|
||||||
.is_some()
|
.is_some()
|
||||||
&& !scalar::grouped(facet)
|
&& (!scalar::grouped(facet)
|
||||||
|
|| (self.group_drafts.contains_key(facet)
|
||||||
|
&& self
|
||||||
|
.trace
|
||||||
|
.and_then(|id| project.traces.get(&id))
|
||||||
|
.is_some_and(|trace| constructors::available(project, trace, facet))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
|
||||||
|
if scalar::grouped(facet) {
|
||||||
|
self.group_drafts.get(facet)
|
||||||
|
} else {
|
||||||
|
self.draft.as_ref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||||
if !scalar::grouped(facet) {
|
if !scalar::grouped(facet) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
let Some(trace) = self.effective_trace(project) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if trace
|
if trace
|
||||||
@@ -319,13 +470,12 @@ impl State {
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.facts
|
capture_facet(project, trace.oa, trace.sa, facet)
|
||||||
.iter()
|
.ok()
|
||||||
.find(|f| f.selector == *facet)
|
.is_some_and(|current| {
|
||||||
.is_some_and(|f| {
|
scalar::value(¤t, Layer::Oa).is_some()
|
||||||
scalar::value(&f.current, Layer::Oa).is_some()
|
&& scalar::observation(¤t, Layer::Oa)
|
||||||
&& scalar::observation(&f.current, Layer::Oa)
|
== scalar::observation(¤t, Layer::Sa)
|
||||||
== scalar::observation(&f.current, Layer::Sa)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +508,7 @@ impl State {
|
|||||||
decision: *decision,
|
decision: *decision,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
reset: vec![],
|
reset: self.resets.iter().cloned().collect(),
|
||||||
});
|
});
|
||||||
request.accepted_impacts = self.accepted_impacts.clone();
|
request.accepted_impacts = self.accepted_impacts.clone();
|
||||||
if self
|
if self
|
||||||
@@ -383,14 +533,11 @@ impl State {
|
|||||||
Ok(request)
|
Ok(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compose selected scalar values for both the candidate and dependent form
|
/// Compose selected values for both the candidate and dependent form
|
||||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||||
/// request construction always requires every selected input to parse.
|
/// request construction always requires every selected input to parse.
|
||||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||||
let trace = self
|
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||||
.trace
|
|
||||||
.and_then(|id| project.traces.get(&id))
|
|
||||||
.ok_or("Choose a trace.")?;
|
|
||||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||||
(trace.oa, trace.sa)
|
(trace.oa, trace.sa)
|
||||||
} else {
|
} else {
|
||||||
@@ -399,7 +546,6 @@ impl State {
|
|||||||
let mut final_value = Draft::from_project(project, target)
|
let mut final_value = Draft::from_project(project, target)
|
||||||
.ok_or("Target has no editable record.")?
|
.ok_or("Target has no editable record.")?
|
||||||
.value;
|
.value;
|
||||||
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
|
|
||||||
let mut map = BTreeMap::new();
|
let mut map = BTreeMap::new();
|
||||||
for (oa, sa) in &self.references {
|
for (oa, sa) in &self.references {
|
||||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||||
@@ -412,33 +558,79 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.insert(source, target);
|
map.insert(source, target);
|
||||||
for (facet, decision) in &self.decisions {
|
let original = final_value.clone();
|
||||||
|
let mut group_candidates = vec![];
|
||||||
|
// Outer groups precede nested groups and leaves. The core rejects actual
|
||||||
|
// overlapping replacement scopes; this order also supports first-equal
|
||||||
|
// discriminator acknowledgment together with explicit descendant values.
|
||||||
|
let mut decisions: Vec<_> = self.decisions.iter().collect();
|
||||||
|
decisions.sort_by_key(|(facet, _)| match facet {
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
|
||||||
|
facet if scalar::grouped(facet) => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
|
for (facet, decision) in decisions {
|
||||||
if *decision == SemanticDecision::KeepTarget {
|
if *decision == SemanticDecision::KeepTarget {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = scalar::binding(project, target, facet)
|
if keyed::supported(facet) {
|
||||||
|
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let roots = constructors::roots(project, target, facet)
|
||||||
.ok_or("Selected property is not applicable to the target.")?;
|
.ok_or("Selected property is not applicable to the target.")?;
|
||||||
let value = match decision {
|
let supplied = match decision {
|
||||||
SemanticDecision::KeepTarget => continue,
|
SemanticDecision::KeepTarget => continue,
|
||||||
SemanticDecision::Candidate => {
|
SemanticDecision::Candidate => {
|
||||||
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
let draft = self
|
||||||
|
.candidate_draft(facet)
|
||||||
|
.ok_or("Target draft is unavailable.")?;
|
||||||
|
match draft.materialize_scopes(&roots) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(_) if form_context => draft.value.clone(),
|
Err(_) if form_context => draft.value.clone(),
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
}
|
||||||
value
|
}
|
||||||
.pointer(&path)
|
SemanticDecision::TakeSource if scalar::grouped(facet) => {
|
||||||
.cloned()
|
if self.can_acknowledge_constructor(project, facet) {
|
||||||
.ok_or("Candidate property is no longer available.")?
|
original.clone()
|
||||||
|
} else {
|
||||||
|
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SemanticDecision::TakeSource => {
|
SemanticDecision::TakeSource => {
|
||||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||||
scalar::source_value(&fact, self.direction.source_layer(), &map)?
|
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
|
||||||
|
let mut supplied = original.clone();
|
||||||
|
*supplied
|
||||||
|
.pointer_mut(&roots[0])
|
||||||
|
.ok_or("Source property is unavailable.")? = value;
|
||||||
|
supplied
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*final_value
|
for path in roots {
|
||||||
.pointer_mut(&path)
|
let value = supplied
|
||||||
.ok_or("Final target property is unavailable.")? = value;
|
.pointer(&path)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Candidate property is no longer available.")?;
|
||||||
|
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
|
||||||
|
group_candidates.push((path.clone(), value.clone()));
|
||||||
|
}
|
||||||
|
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !form_context {
|
||||||
|
for (path, authored) in group_candidates {
|
||||||
|
if !original
|
||||||
|
.pointer(&path)
|
||||||
|
.zip(final_value.pointer(&path))
|
||||||
|
.is_some_and(|(original, final_value)| {
|
||||||
|
constructors::authored_agrees(original, &authored, final_value)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(final_value)
|
Ok(final_value)
|
||||||
}
|
}
|
||||||
@@ -449,17 +641,48 @@ impl State {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
let context = self.combined_value(project, true).ok();
|
let context = self.combined_value(project, true).ok();
|
||||||
if let Some(draft) = &mut self.draft {
|
if let Some(draft) = &mut self.draft {
|
||||||
draft.set_context(context);
|
draft.set_context(context.clone());
|
||||||
}
|
}
|
||||||
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
for draft in self.group_drafts.values_mut() {
|
||||||
|
draft.set_context(context.clone());
|
||||||
|
}
|
||||||
|
let Some(trace) = self.effective_trace(project) else {
|
||||||
self.error = Some(
|
self.error = Some(
|
||||||
"Several traces are available. Choose the correspondence and review history above."
|
"Several traces are available. Choose the correspondence and review history above."
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match semantic_facets(project, trace, &self.references) {
|
match semantic_facets(project, &trace, &self.references) {
|
||||||
Ok(facts) => self.facts = facts,
|
Ok(mut facts) => {
|
||||||
|
// Explicit addresses survive catalog normalization, pairing changes and resets.
|
||||||
|
for selector in self.staged.iter().chain(self.decisions.keys()) {
|
||||||
|
if !facts.iter().any(|f| &f.selector == selector) {
|
||||||
|
match capture_facet(project, trace.oa, trace.sa, selector) {
|
||||||
|
Ok(current) => {
|
||||||
|
let previous = trace
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.semantic.as_ref())
|
||||||
|
.and_then(|b| {
|
||||||
|
b.facets.iter().find(|f| f.selector() == *selector)
|
||||||
|
});
|
||||||
|
facts.push(FacetDrift {
|
||||||
|
selector: selector.clone(),
|
||||||
|
drift: facet_drift(¤t, previous),
|
||||||
|
current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.error = Some(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
|
||||||
|
self.facts = facts;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
return;
|
return;
|
||||||
@@ -499,8 +722,11 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||||
if self.decisions.is_empty() {
|
if self.decisions.is_empty() && self.resets.is_empty() {
|
||||||
return Err("Choose at least one property to review.".into());
|
return Err(
|
||||||
|
"Choose at least one property to review or an exact history observation to reset."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(error) = &self.error {
|
if let Some(error) = &self.error {
|
||||||
return Err(error.clone());
|
return Err(error.clone());
|
||||||
@@ -514,7 +740,7 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_apply(&self) -> bool {
|
pub fn can_apply(&self) -> bool {
|
||||||
!self.decisions.is_empty()
|
(!self.decisions.is_empty() || !self.resets.is_empty())
|
||||||
&& self.error.is_none()
|
&& self.error.is_none()
|
||||||
&& self
|
&& self
|
||||||
.plan
|
.plan
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
|||||||
|
//! Closed constructor payload bindings. The core owns decisions and review scope;
|
||||||
|
//! this adapter supplies complete target records for its Candidate agreement check.
|
||||||
|
use super::{scalar, *};
|
||||||
|
|
||||||
|
pub(super) fn selectors() -> [FacetSelector; 5] {
|
||||||
|
[
|
||||||
|
FacetSelector::Data(DataFacet::Constructor),
|
||||||
|
FacetSelector::Port(PortFacet::Constructor),
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||||
|
FacetSelector::Event(EventFacet::Constructor),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn roots(project: &Project, target: Id, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||||
|
let root = scalar::binding(project, target, facet)?;
|
||||||
|
let mut roots = vec![root.clone()];
|
||||||
|
match facet {
|
||||||
|
FacetSelector::Data(DataFacet::Constructor) => {
|
||||||
|
roots.push(format!("{}/default", root.strip_suffix("/definition")?));
|
||||||
|
}
|
||||||
|
FacetSelector::Port(PortFacet::Constructor) => {
|
||||||
|
roots.push(format!("{}/legacy", root.strip_suffix("/contract")?));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn available(project: &Project, trace: &Trace, facet: &FacetSelector) -> bool {
|
||||||
|
roots(project, trace.oa, facet).is_some() && roots(project, trace.sa, facet).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used for explanation and explicit overlap controls, never for acknowledgment.
|
||||||
|
pub(super) fn contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
|
||||||
|
use FacetSelector as F;
|
||||||
|
match group {
|
||||||
|
F::Data(DataFacet::Constructor) => {
|
||||||
|
matches!(facet, F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
|
||||||
|
}
|
||||||
|
F::Port(PortFacet::Constructor) => matches!(
|
||||||
|
facet,
|
||||||
|
F::Port(_)
|
||||||
|
| F::Member {
|
||||||
|
collection: ReferenceCollection::FunctionInputItems
|
||||||
|
| ReferenceCollection::FunctionOutputItems,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
F::Occurrence(OccurrenceFacet::Constructor) => {
|
||||||
|
matches!(facet, F::Occurrence(f) if !matches!(f, OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|
||||||
|
|| matches!(
|
||||||
|
facet,
|
||||||
|
F::Annotation(_)
|
||||||
|
| F::Binding(_)
|
||||||
|
| F::Member {
|
||||||
|
collection: ReferenceCollection::Steps
|
||||||
|
| ReferenceCollection::FragmentBranches,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| F::Order(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||||
|
facet,
|
||||||
|
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
|
||||||
|
),
|
||||||
|
F::Event(EventFacet::Constructor) => matches!(
|
||||||
|
facet,
|
||||||
|
F::Event(_)
|
||||||
|
| F::Member {
|
||||||
|
collection: ReferenceCollection::SignalItems,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn description(facet: &FacetSelector) -> &'static str {
|
||||||
|
match facet {
|
||||||
|
FacetSelector::Data(_) => {
|
||||||
|
"Definition, bounds or literal members, record supertype and default. Unit stays independent."
|
||||||
|
}
|
||||||
|
FacetSelector::Port(_) => {
|
||||||
|
"Contract, item members and retained generic association. Owner stays independent."
|
||||||
|
}
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => {
|
||||||
|
"Occurrence body, including references, message annotations, bindings and branch or step order. Pre/postconditions stay independent."
|
||||||
|
}
|
||||||
|
FacetSelector::Occurrence(_) => {
|
||||||
|
"Message kind and Timer Expression. Exchange references and annotations stay independent."
|
||||||
|
}
|
||||||
|
FacetSelector::Event(_) => {
|
||||||
|
"Event body, including signal items or its complete time/change expression."
|
||||||
|
}
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chosen<T>(keys: &KeyPair<T>, layer: Layer) -> Option<&T> {
|
||||||
|
match layer {
|
||||||
|
Layer::Oa => keys.oa.as_ref(),
|
||||||
|
Layer::Sa => keys.sa.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source payload is mapped by the core's typed visitor, never by replacing
|
||||||
|
// UUID-looking strings. For a historical same-kind Take, retain only current
|
||||||
|
// target-present/source-absent rows with no prior source-present observation. The final core planner
|
||||||
|
// checks this assembled record against its own independent expected result.
|
||||||
|
pub(super) fn take_value(
|
||||||
|
project: &Project,
|
||||||
|
trace: &Trace,
|
||||||
|
direction: TransitionDirection,
|
||||||
|
facet: &FacetSelector,
|
||||||
|
map: &BTreeMap<Id, Id>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (source, target) = if direction == TransitionDirection::OaToSa {
|
||||||
|
(trace.oa, trace.sa)
|
||||||
|
} else {
|
||||||
|
(trace.sa, trace.oa)
|
||||||
|
};
|
||||||
|
let mut element = project
|
||||||
|
.elements
|
||||||
|
.get(&source)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Missing constructor source.")?;
|
||||||
|
if !message_kind::handles(facet) {
|
||||||
|
element.parent = None;
|
||||||
|
let mut missing = BTreeSet::new();
|
||||||
|
oa_core::references::visit_element_refs(&mut element, |id| {
|
||||||
|
if let Some(mapped) = map.get(id) {
|
||||||
|
*id = *mapped;
|
||||||
|
} else {
|
||||||
|
missing.insert(*id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Missing explicit reference correspondence: {missing:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let supplied = serde_json::to_value(element).map_err(|e| e.to_string())?;
|
||||||
|
let mut value = Draft::from_project(project, target)
|
||||||
|
.ok_or("Missing target draft.")?
|
||||||
|
.value;
|
||||||
|
for (from, to) in roots(project, source, facet)
|
||||||
|
.ok_or("Source group is unavailable.")?
|
||||||
|
.iter()
|
||||||
|
.zip(roots(project, target, facet).ok_or("Target group is unavailable.")?)
|
||||||
|
{
|
||||||
|
*value
|
||||||
|
.pointer_mut(&to)
|
||||||
|
.ok_or("Target group payload is unavailable.")? = supplied
|
||||||
|
.pointer(from)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Source group payload is unavailable.")?;
|
||||||
|
}
|
||||||
|
let current = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||||
|
if scalar::observation(¤t, Layer::Oa) != scalar::observation(¤t, Layer::Sa) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
let references = if direction == TransitionDirection::OaToSa {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
map.iter().map(|(sa, oa)| (*oa, *sa)).collect()
|
||||||
|
};
|
||||||
|
let old = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref());
|
||||||
|
for fact in semantic_facets(project, trace, &references)? {
|
||||||
|
if !contains(facet, &fact.selector) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((path, key)) =
|
||||||
|
target_row(project, target, &fact.selector, direction.target_layer())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if row_present(&fact.current, direction.source_layer())
|
||||||
|
|| !row_present(&fact.current, direction.target_layer())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let was_paired = old.is_some_and(|b| {
|
||||||
|
b.facets.iter().any(|previous| {
|
||||||
|
row_present(previous, direction.source_layer())
|
||||||
|
&& target_row(
|
||||||
|
project,
|
||||||
|
target,
|
||||||
|
&previous.selector(),
|
||||||
|
direction.target_layer(),
|
||||||
|
)
|
||||||
|
.as_ref()
|
||||||
|
== Some(&(path.clone(), key.clone()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if was_paired {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row = scalar::value(&fact.current, direction.target_layer())
|
||||||
|
.ok_or("Target row is inapplicable.")?;
|
||||||
|
let items = value
|
||||||
|
.pointer_mut(&path)
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.ok_or("Target collection is unavailable.")?;
|
||||||
|
match &fact.selector {
|
||||||
|
FacetSelector::Member { .. } | FacetSelector::Literal(_) => {
|
||||||
|
if row == Value::Bool(true) && !items.contains(&key) {
|
||||||
|
items.push(key);
|
||||||
|
} else if row == Value::Bool(false) {
|
||||||
|
items.retain(|v| v != &key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FacetSelector::Annotation(_) => {
|
||||||
|
items.retain(|v| {
|
||||||
|
serde_json::from_value::<MessageValue>(v.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|v| serde_json::to_value(AnnotationKey::from(&v)).unwrap())
|
||||||
|
.as_ref()
|
||||||
|
!= Some(&key)
|
||||||
|
});
|
||||||
|
if !row.is_null() {
|
||||||
|
items.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FacetSelector::Binding(_) => {
|
||||||
|
items.retain(|v| v.get("referenced") != Some(&key));
|
||||||
|
if !row.is_null() {
|
||||||
|
items.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_present(fact: &FacetReview, layer: Layer) -> bool {
|
||||||
|
match fact {
|
||||||
|
FacetReview::Member { .. } | FacetReview::Literal { .. } => {
|
||||||
|
scalar::value(fact, layer) == Some(Value::Bool(true))
|
||||||
|
}
|
||||||
|
FacetReview::Annotation { .. } | FacetReview::Binding { .. } => {
|
||||||
|
scalar::value(fact, layer).is_some_and(|value| !value.is_null())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
|
||||||
|
match facet {
|
||||||
|
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
|
||||||
|
chosen(keys, layer).map(|v| serde_json::json!(v))
|
||||||
|
}
|
||||||
|
FacetSelector::Literal(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
|
||||||
|
FacetSelector::Annotation(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn target_row(
|
||||||
|
project: &Project,
|
||||||
|
target: Id,
|
||||||
|
facet: &FacetSelector,
|
||||||
|
layer: Layer,
|
||||||
|
) -> Option<(String, Value)> {
|
||||||
|
let key = row_key(facet, layer)?;
|
||||||
|
let (group, suffix) = match facet {
|
||||||
|
FacetSelector::Literal(_) => (
|
||||||
|
FacetSelector::Data(DataFacet::Constructor),
|
||||||
|
"/Enumeration/literals",
|
||||||
|
),
|
||||||
|
FacetSelector::Binding(_) => (
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||||
|
"/ScenarioUse/bindings",
|
||||||
|
),
|
||||||
|
FacetSelector::Annotation(_) => (
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||||
|
"/Exchange/values",
|
||||||
|
),
|
||||||
|
FacetSelector::Member { collection, .. } => match collection {
|
||||||
|
ReferenceCollection::FunctionInputItems => (
|
||||||
|
FacetSelector::Port(PortFacet::Constructor),
|
||||||
|
"/FunctionInput/incoming_items",
|
||||||
|
),
|
||||||
|
ReferenceCollection::FunctionOutputItems => (
|
||||||
|
FacetSelector::Port(PortFacet::Constructor),
|
||||||
|
"/FunctionOutput/outgoing_items",
|
||||||
|
),
|
||||||
|
ReferenceCollection::SignalItems => (
|
||||||
|
FacetSelector::Event(EventFacet::Constructor),
|
||||||
|
"/Signal/items",
|
||||||
|
),
|
||||||
|
ReferenceCollection::Steps => (
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||||
|
"/Branch/occurrences",
|
||||||
|
),
|
||||||
|
ReferenceCollection::FragmentBranches => (
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||||
|
"/Fragment/branches",
|
||||||
|
),
|
||||||
|
_ => return None,
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
format!("{}{suffix}", scalar::binding(project, target, &group)?),
|
||||||
|
key,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A selected descendant may fill untouched group fields during first-equal
|
||||||
|
/// acknowledgment, but cannot silently replace an authored group field.
|
||||||
|
pub(super) fn authored_agrees(original: &Value, authored: &Value, final_value: &Value) -> bool {
|
||||||
|
if original == authored || authored == final_value {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match (original, authored, final_value) {
|
||||||
|
(Value::Object(a), Value::Object(b), Value::Object(c)) if a.keys().eq(b.keys()) => {
|
||||||
|
b.iter().all(|(key, v)| {
|
||||||
|
c.get(key)
|
||||||
|
.is_some_and(|final_value| authored_agrees(&a[key], v, final_value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn resettable(facet: &FacetSelector) -> bool {
|
||||||
|
!matches!(facet, FacetSelector::Artifact { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub(super) fn effective_trace(&self, project: &Project) -> Option<Trace> {
|
||||||
|
let mut trace = project.traces.get(&self.trace?)?.clone();
|
||||||
|
if let Some(baseline) = trace.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||||
|
baseline
|
||||||
|
.facets
|
||||||
|
.retain(|f| !self.resets.contains(&f.selector()));
|
||||||
|
}
|
||||||
|
Some(trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reset_history(
|
||||||
|
&mut self,
|
||||||
|
project: &Project,
|
||||||
|
facet: FacetSelector,
|
||||||
|
reset: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !reset && self.resets.remove(&facet) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let exists = self
|
||||||
|
.trace
|
||||||
|
.and_then(|id| project.traces.get(&id))
|
||||||
|
.and_then(|t| t.baseline.as_ref())
|
||||||
|
.and_then(|b| b.semantic.as_ref())
|
||||||
|
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == facet));
|
||||||
|
if !exists || !resettable(&facet) {
|
||||||
|
return Err("Choose an exact existing non-artifact observation to reset.".into());
|
||||||
|
}
|
||||||
|
if reset {
|
||||||
|
self.resets.insert(facet);
|
||||||
|
} else {
|
||||||
|
self.resets.remove(&facet);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//! Closed presence-only adapter. The core independently validates the full final record.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Family {
|
||||||
|
Member(ReferenceCollection),
|
||||||
|
Literal,
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for Family {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
|
||||||
|
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
|
||||||
|
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
|
||||||
|
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
|
||||||
|
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
|
||||||
|
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
|
||||||
|
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
|
||||||
|
Self::Literal => "Enumeration literals",
|
||||||
|
_ => "Ordered membership (read-only)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) const FAMILIES: [Family; 8] = [
|
||||||
|
Family::Member(ReferenceCollection::FunctionInputItems),
|
||||||
|
Family::Member(ReferenceCollection::FunctionOutputItems),
|
||||||
|
Family::Member(ReferenceCollection::SignalItems),
|
||||||
|
Family::Member(ReferenceCollection::ExchangeItems),
|
||||||
|
Family::Member(ReferenceCollection::TransitionTriggers),
|
||||||
|
Family::Member(ReferenceCollection::TransitionEffects),
|
||||||
|
Family::Member(ReferenceCollection::Constrained),
|
||||||
|
Family::Literal,
|
||||||
|
];
|
||||||
|
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
||||||
|
let family = match facet {
|
||||||
|
FacetSelector::Member { collection, .. } => Family::Member(*collection),
|
||||||
|
FacetSelector::Literal(_) => Family::Literal,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
FAMILIES.contains(&family).then_some(family)
|
||||||
|
}
|
||||||
|
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||||
|
family(facet).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed paths for the seven unordered collections and the exact enum literal set.
|
||||||
|
// They are selected by the typed facet; callers cannot supply arbitrary paths.
|
||||||
|
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
|
||||||
|
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
|
||||||
|
let tails: &[&str] = match family {
|
||||||
|
Family::Member(ReferenceCollection::FunctionInputItems) => {
|
||||||
|
&["Port/contract/FunctionInput/incoming_items"]
|
||||||
|
}
|
||||||
|
Family::Member(ReferenceCollection::FunctionOutputItems) => {
|
||||||
|
&["Port/contract/FunctionOutput/outgoing_items"]
|
||||||
|
}
|
||||||
|
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
|
||||||
|
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
|
||||||
|
Family::Literal => &["DataType/definition/Enumeration/literals"],
|
||||||
|
Family::Member(ReferenceCollection::ExchangeItems) => {
|
||||||
|
return ["Interaction", "FunctionalExchange", "BoundaryExchange"]
|
||||||
|
.iter()
|
||||||
|
.map(|kind| format!("/body/{kind}/items"))
|
||||||
|
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||||
|
}
|
||||||
|
Family::Member(ReferenceCollection::TransitionTriggers) => {
|
||||||
|
return value
|
||||||
|
.pointer("/body/StateTransition/triggers")
|
||||||
|
.is_some_and(Value::is_array)
|
||||||
|
.then(|| "/body/StateTransition/triggers".into());
|
||||||
|
}
|
||||||
|
Family::Member(ReferenceCollection::TransitionEffects) => {
|
||||||
|
return value
|
||||||
|
.pointer("/body/StateTransition/effects")
|
||||||
|
.is_some_and(Value::is_array)
|
||||||
|
.then(|| "/body/StateTransition/effects".into());
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
prefixes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
|
||||||
|
.find(|p| value.pointer(p).is_some_and(Value::is_array))
|
||||||
|
}
|
||||||
|
fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
|
||||||
|
match facet {
|
||||||
|
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||||
|
.map(|id| serde_json::to_value(id).expect("Id")),
|
||||||
|
FacetSelector::Literal(keys) => if layer == Layer::Oa {
|
||||||
|
keys.oa.as_ref()
|
||||||
|
} else {
|
||||||
|
keys.sa.as_ref()
|
||||||
|
}
|
||||||
|
.map(|v| Value::String(v.clone())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||||
|
if family(a).is_none() || family(a) != family(b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
[Layer::Oa, Layer::Sa]
|
||||||
|
.into_iter()
|
||||||
|
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
|
||||||
|
}
|
||||||
|
pub(super) fn project_presence(
|
||||||
|
value: &mut Value,
|
||||||
|
facet: &FacetSelector,
|
||||||
|
layer: Layer,
|
||||||
|
present: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let family = family(facet).ok_or("This keyed family remains read-only.")?;
|
||||||
|
let key = key(facet, layer)
|
||||||
|
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
|
||||||
|
let path =
|
||||||
|
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
|
||||||
|
let list = value
|
||||||
|
.pointer_mut(&path)
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.ok_or("Destination collection is unavailable.")?;
|
||||||
|
if present {
|
||||||
|
if !list.contains(&key) {
|
||||||
|
list.push(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.retain(|v| v != &key);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub(super) fn keyed_value(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
trace: &Trace,
|
||||||
|
value: &mut Value,
|
||||||
|
facet: &FacetSelector,
|
||||||
|
decision: SemanticDecision,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let present = match decision {
|
||||||
|
SemanticDecision::KeepTarget => return Ok(()),
|
||||||
|
SemanticDecision::TakeSource => {
|
||||||
|
// A missing source identity is a target-local row, not a removal.
|
||||||
|
if key(facet, self.direction.source_layer()).is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||||
|
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
|
||||||
|
}
|
||||||
|
SemanticDecision::Candidate => *self
|
||||||
|
.presence
|
||||||
|
.get(facet)
|
||||||
|
.ok_or("Choose final membership presence.")?,
|
||||||
|
};
|
||||||
|
project_presence(value, facet, self.direction.target_layer(), present)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
|
||||||
|
let Some(trace) = self.effective_trace(project) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let values: Vec<_> = [trace.oa, trace.sa]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
|
||||||
|
.collect();
|
||||||
|
FAMILIES
|
||||||
|
.into_iter()
|
||||||
|
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||||
|
let Some(trace) = self.effective_trace(project) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let id = if layer == Layer::Oa {
|
||||||
|
trace.oa
|
||||||
|
} else {
|
||||||
|
trace.sa
|
||||||
|
};
|
||||||
|
let Some(mut draft) = Draft::from_project(project, id) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
if layer == self.direction.target_layer() {
|
||||||
|
draft.set_context(self.combined_value(project, true).ok());
|
||||||
|
}
|
||||||
|
let selected = if layer == Layer::Oa {
|
||||||
|
&self.member_oa
|
||||||
|
} else {
|
||||||
|
&self.member_sa
|
||||||
|
};
|
||||||
|
let Some(path) = path(&draft.context_value(), self.member_family) else {
|
||||||
|
return selected.iter().cloned().collect();
|
||||||
|
};
|
||||||
|
crate::forms::reference_choices(
|
||||||
|
&draft,
|
||||||
|
project,
|
||||||
|
&format!("{path}/0"),
|
||||||
|
selected.as_ref().map_or(Id::nil(), |c| c.id),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.id.is_nil())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn edit_pair(
|
||||||
|
&mut self,
|
||||||
|
project: &Project,
|
||||||
|
facet: FacetSelector,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
|
||||||
|
match facet {
|
||||||
|
FacetSelector::Member { keys, .. } => {
|
||||||
|
self.member_oa = keys.oa.map(|id| view::choice(project, id));
|
||||||
|
self.member_sa = keys.sa.map(|id| view::choice(project, id));
|
||||||
|
}
|
||||||
|
FacetSelector::Literal(keys) => {
|
||||||
|
self.literal_oa = keys.oa.is_some();
|
||||||
|
self.literal_sa = keys.sa.is_some();
|
||||||
|
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
self.tab = Tab::Members;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
|
||||||
|
if !FAMILIES.contains(&self.member_family) {
|
||||||
|
return Err("This keyed family remains read-only.".into());
|
||||||
|
}
|
||||||
|
let facet = match self.member_family {
|
||||||
|
Family::Member(collection) => FacetSelector::Member {
|
||||||
|
collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: self.member_oa.as_ref().map(|c| c.id),
|
||||||
|
sa: self.member_sa.as_ref().map(|c| c.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Family::Literal => FacetSelector::Literal(KeyPair {
|
||||||
|
oa: self.literal_oa.then(|| self.literal.clone()),
|
||||||
|
sa: self.literal_sa.then(|| self.literal.clone()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||||
|
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
||||||
|
if let FacetSelector::Member {
|
||||||
|
keys:
|
||||||
|
KeyPair {
|
||||||
|
oa: Some(oa),
|
||||||
|
sa: Some(sa),
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} = &facet
|
||||||
|
{
|
||||||
|
self.references.insert(*oa, *sa);
|
||||||
|
}
|
||||||
|
self.staged.insert(facet.clone());
|
||||||
|
self.selected = Some(facet);
|
||||||
|
self.tab = Tab::Facts;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,782 @@
|
|||||||
|
use super::{tests::*, *};
|
||||||
|
use crate::{app::App, document::Document};
|
||||||
|
|
||||||
|
fn errors(s: &State) -> String {
|
||||||
|
s.error
|
||||||
|
.iter()
|
||||||
|
.chain(s.plan.iter().flat_map(|p| &p.conflicts))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
fn put(p: &mut Project, owner: Id, facet: &FacetSelector, present: bool) {
|
||||||
|
let layer = p.layer_of(owner).unwrap();
|
||||||
|
let mut value = Draft::from_project(p, owner).unwrap().value;
|
||||||
|
keyed::project_presence(&mut value, facet, layer, present).unwrap();
|
||||||
|
if let Some(element) = p.elements.get_mut(&owner) {
|
||||||
|
*element = serde_json::from_value(value).unwrap();
|
||||||
|
} else {
|
||||||
|
p.relations
|
||||||
|
.insert(owner, serde_json::from_value(value).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn contains(p: &Project, owner: Id, facet: &FacetSelector, oa: Id, sa: Id) -> bool {
|
||||||
|
scalar::value(
|
||||||
|
&capture_facet(p, oa, sa, facet).unwrap(),
|
||||||
|
p.layer_of(owner).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.as_bool()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn history(p: &mut Project, trace: Id, selectors: &[FacetSelector]) {
|
||||||
|
let t = &p.traces[&trace];
|
||||||
|
let baseline = TraceBaseline {
|
||||||
|
oa: p.properties(t.oa).unwrap(),
|
||||||
|
sa: p.properties(t.sa).unwrap(),
|
||||||
|
semantic: Some(SemanticBaseline {
|
||||||
|
facets: selectors
|
||||||
|
.iter()
|
||||||
|
.map(|s| capture_facet(p, t.oa, t.sa, s).unwrap())
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
p.traces.get_mut(&trace).unwrap().baseline = Some(baseline);
|
||||||
|
}
|
||||||
|
fn literal(name: &str) -> FacetSelector {
|
||||||
|
FacetSelector::Literal(KeyPair {
|
||||||
|
oa: Some(name.into()),
|
||||||
|
sa: Some(name.into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn enum_pair() -> (Project, Id, Id, Id) {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let mut ids = vec![];
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let id = p.add_element(Kind::DataType, layer, "Enum", None).unwrap();
|
||||||
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||||
|
definition: DataTypeDefinition::Enumeration {
|
||||||
|
literals: vec!["retained".into()],
|
||||||
|
},
|
||||||
|
default: Some(DataValue::Enumeration("retained".into())),
|
||||||
|
unit: String::new(),
|
||||||
|
};
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
let trace = add_trace(&mut p, ids[0], ids[1], "Literal review");
|
||||||
|
(p, ids[0], ids[1], trace)
|
||||||
|
}
|
||||||
|
fn member_pair(collection: ReferenceCollection) -> (Project, Id, Id, Id, FacetSelector, [Id; 2]) {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let mut owners = vec![];
|
||||||
|
let mut keys = vec![];
|
||||||
|
let mut locals = vec![];
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let work = if layer == Layer::Oa {
|
||||||
|
Kind::Activity
|
||||||
|
} else {
|
||||||
|
Kind::Function
|
||||||
|
};
|
||||||
|
let kind = match collection {
|
||||||
|
ReferenceCollection::TransitionTriggers => Kind::Event,
|
||||||
|
ReferenceCollection::TransitionEffects | ReferenceCollection::Constrained => work,
|
||||||
|
_ => Kind::ExchangeItem,
|
||||||
|
};
|
||||||
|
let key = p.add_element(kind, layer, "Mapped member", None).unwrap();
|
||||||
|
let local = p.add_element(kind, layer, "Local member", None).unwrap();
|
||||||
|
let owner = match collection {
|
||||||
|
ReferenceCollection::SignalItems => {
|
||||||
|
let id = p.add_element(Kind::Event, layer, "Signal", None).unwrap();
|
||||||
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event {
|
||||||
|
body: EventBody::Signal { items: vec![] },
|
||||||
|
};
|
||||||
|
id
|
||||||
|
}
|
||||||
|
ReferenceCollection::Constrained => p
|
||||||
|
.add_element(Kind::Constraint, layer, "Constraint", None)
|
||||||
|
.unwrap(),
|
||||||
|
ReferenceCollection::ExchangeItems => {
|
||||||
|
let a = p.add_element(work, layer, "From", None).unwrap();
|
||||||
|
let b = p.add_element(work, layer, "To", None).unwrap();
|
||||||
|
let id = p.relate(
|
||||||
|
if layer == Layer::Oa {
|
||||||
|
RelationKind::Interaction
|
||||||
|
} else {
|
||||||
|
RelationKind::FunctionalExchange
|
||||||
|
},
|
||||||
|
"Exchange",
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
);
|
||||||
|
if let RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} = p.relations[&id].body
|
||||||
|
{
|
||||||
|
for port in [source_port, target_port] {
|
||||||
|
if let Some(SharedBody::Port { contract, .. }) =
|
||||||
|
p.elements.get_mut(&port).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
match contract {
|
||||||
|
PortContract::FunctionInput { incoming_items } => {
|
||||||
|
*incoming_items = vec![key, local]
|
||||||
|
}
|
||||||
|
PortContract::FunctionOutput { outgoing_items } => {
|
||||||
|
*outgoing_items = vec![key, local]
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
ReferenceCollection::TransitionTriggers | ReferenceCollection::TransitionEffects => {
|
||||||
|
let machine = p
|
||||||
|
.add_element(Kind::StateMachine, layer, "Machine", None)
|
||||||
|
.unwrap();
|
||||||
|
let region = p
|
||||||
|
.add_element(Kind::Region, layer, "Region", Some(machine))
|
||||||
|
.unwrap();
|
||||||
|
let state = p
|
||||||
|
.add_element(Kind::State, layer, "State", Some(region))
|
||||||
|
.unwrap();
|
||||||
|
p.relate(RelationKind::StateTransition, "Transition", state, state)
|
||||||
|
}
|
||||||
|
ReferenceCollection::FunctionInputItems | ReferenceCollection::FunctionOutputItems => {
|
||||||
|
let parent = p.add_element(work, layer, "Owner", None).unwrap();
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let contract = if layer == Layer::Oa {
|
||||||
|
PortContract::Legacy
|
||||||
|
} else if collection == ReferenceCollection::FunctionInputItems {
|
||||||
|
PortContract::FunctionInput {
|
||||||
|
incoming_items: vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut port = ports::make_port(&p, id, parent, "Port", contract).unwrap();
|
||||||
|
if layer == Layer::Oa
|
||||||
|
&& let Some(SharedBody::Port { legacy, .. }) = port.shared_mut()
|
||||||
|
{
|
||||||
|
*legacy = Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
p.elements.insert(id, port);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
owners.push(owner);
|
||||||
|
keys.push(key);
|
||||||
|
locals.push(local);
|
||||||
|
}
|
||||||
|
let trace = add_trace(&mut p, owners[0], owners[1], "Membership review");
|
||||||
|
let facet = FacetSelector::Member {
|
||||||
|
collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: Some(keys[0]),
|
||||||
|
sa: Some(keys[1]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(
|
||||||
|
p,
|
||||||
|
owners[0],
|
||||||
|
owners[1],
|
||||||
|
trace,
|
||||||
|
facet,
|
||||||
|
[locals[0], locals[1]],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn stage(app: &mut App, facet: &FacetSelector) {
|
||||||
|
send(app, Event::EditPair(facet.clone()));
|
||||||
|
send(app, Event::StageMember);
|
||||||
|
assert_eq!(
|
||||||
|
state(app).selected.as_ref(),
|
||||||
|
Some(facet),
|
||||||
|
"{}",
|
||||||
|
errors(state(app))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unordered_members_keep_take_candidate_preserve_other_rows_both_directions() {
|
||||||
|
for collection in [
|
||||||
|
ReferenceCollection::SignalItems,
|
||||||
|
ReferenceCollection::ExchangeItems,
|
||||||
|
ReferenceCollection::TransitionTriggers,
|
||||||
|
ReferenceCollection::TransitionEffects,
|
||||||
|
ReferenceCollection::Constrained,
|
||||||
|
] {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
for decision in [
|
||||||
|
SemanticDecision::KeepTarget,
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
] {
|
||||||
|
let (mut p, oa, sa, trace, facet, locals) = member_pair(collection);
|
||||||
|
let (source, target, local) = if reverse {
|
||||||
|
(sa, oa, locals[0])
|
||||||
|
} else {
|
||||||
|
(oa, sa, locals[1])
|
||||||
|
};
|
||||||
|
put(&mut p, source, &facet, true);
|
||||||
|
let local_facet = FacetSelector::Member {
|
||||||
|
collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: reverse.then_some(local),
|
||||||
|
sa: (!reverse).then_some(local),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
put(&mut p, target, &local_facet, true);
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
start(&mut app, source);
|
||||||
|
stage(&mut app, &facet);
|
||||||
|
decide(&mut app, facet.clone(), decision);
|
||||||
|
if decision == SemanticDecision::Candidate {
|
||||||
|
send(&mut app, Event::Presence(facet.clone(), true));
|
||||||
|
}
|
||||||
|
// A target-only Take is an explicit acknowledgment, never implicit removal.
|
||||||
|
decide(&mut app, local_facet.clone(), SemanticDecision::TakeSource);
|
||||||
|
assert!(
|
||||||
|
state(&app).can_apply(),
|
||||||
|
"{collection:?} reverse={reverse} {decision:?}: {}",
|
||||||
|
errors(state(&app))
|
||||||
|
);
|
||||||
|
let proposed = state(&app).proposed.clone().unwrap();
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(app.workflow.is_none(), "{}", app.status);
|
||||||
|
assert_eq!(app.project(), &proposed);
|
||||||
|
assert_eq!(
|
||||||
|
contains(app.project(), target, &facet, oa, sa),
|
||||||
|
decision != SemanticDecision::KeepTarget
|
||||||
|
);
|
||||||
|
assert!(contains(app.project(), target, &local_facet, oa, sa));
|
||||||
|
assert!(app.project().elements.contains_key(&local));
|
||||||
|
assert_eq!(
|
||||||
|
app.project().traces[&trace]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
|
||||||
|
for collection in [
|
||||||
|
ReferenceCollection::FunctionInputItems,
|
||||||
|
ReferenceCollection::FunctionOutputItems,
|
||||||
|
] {
|
||||||
|
let (p, oa, sa, _, facet, _) = member_pair(collection);
|
||||||
|
let FacetSelector::Member { keys, .. } = &facet else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).member_families(app.project()),
|
||||||
|
vec![keyed::Family::Member(collection)]
|
||||||
|
);
|
||||||
|
send(&mut app, Event::EditPair(facet.clone()));
|
||||||
|
assert!(
|
||||||
|
state(&app)
|
||||||
|
.member_choices(app.project(), Layer::Sa)
|
||||||
|
.iter()
|
||||||
|
.any(|c| Some(c.id) == keys.sa)
|
||||||
|
);
|
||||||
|
send(&mut app, Event::StageMember);
|
||||||
|
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(facet.clone(), true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(contains(app.project(), sa, &facet, oa, sa));
|
||||||
|
assert!(matches!(
|
||||||
|
app.project().elements[&oa].shared(),
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
start(&mut app, sa);
|
||||||
|
stage(&mut app, &facet);
|
||||||
|
decide(&mut app, facet.clone(), SemanticDecision::KeepTarget);
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(facet, true));
|
||||||
|
assert!(
|
||||||
|
!state(&app).can_apply(),
|
||||||
|
"OA typed membership must remain inapplicable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_literal_new_rows_and_removals_commit_both_directions_without_default_loss() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, _) = enum_pair();
|
||||||
|
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||||
|
let incoming = literal(" New value ");
|
||||||
|
put(&mut p, source, &incoming, true);
|
||||||
|
let removed = literal("old");
|
||||||
|
put(&mut p, target, &removed, true);
|
||||||
|
let created = literal("new value");
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, source);
|
||||||
|
for (facet, decision) in [
|
||||||
|
(&incoming, SemanticDecision::TakeSource),
|
||||||
|
(&removed, SemanticDecision::Candidate),
|
||||||
|
(&created, SemanticDecision::Candidate),
|
||||||
|
] {
|
||||||
|
stage(&mut app, facet);
|
||||||
|
decide(&mut app, facet.clone(), decision);
|
||||||
|
if decision == SemanticDecision::Candidate {
|
||||||
|
send(&mut app, Event::Presence(facet.clone(), facet == &created));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(contains(app.project(), target, &incoming, oa, sa));
|
||||||
|
assert!(contains(app.project(), target, &created, oa, sa));
|
||||||
|
assert!(!contains(app.project(), target, &removed, oa, sa));
|
||||||
|
assert!(
|
||||||
|
matches!(app.project().elements[&target].shared(),Some(SharedBody::DataType { default: Some(DataValue::Enumeration(value)), .. }) if value=="retained")
|
||||||
|
);
|
||||||
|
let reopened = Document::from_bundle(
|
||||||
|
app.document.bundle().clone(),
|
||||||
|
source_project::ProjectLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reopened.project(), app.project());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, trace) = enum_pair();
|
||||||
|
let reset = literal("retained");
|
||||||
|
let kept = FacetSelector::Header(HeaderFacet::Name);
|
||||||
|
history(&mut p, trace, &[reset.clone(), kept.clone()]);
|
||||||
|
let previous = p.traces[&trace].baseline.clone().unwrap();
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let mut bundle = app.document.bundle().clone();
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.get_mut(&bundle.entry)
|
||||||
|
.unwrap()
|
||||||
|
.insert_str(0, "// Exact review rationale survives reset.\n");
|
||||||
|
app.document.apply_bundle(bundle).unwrap();
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
let revision = app.document.revision();
|
||||||
|
start(&mut app, if reverse { sa } else { oa });
|
||||||
|
send(&mut app, Event::Reset(reset.clone(), true));
|
||||||
|
assert!(state(&app).decisions.is_empty());
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.facts
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.selector == reset)
|
||||||
|
.unwrap()
|
||||||
|
.drift,
|
||||||
|
Drift::Unreviewed
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Reset(reset.clone(), false));
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.facts
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.selector == reset)
|
||||||
|
.unwrap()
|
||||||
|
.drift,
|
||||||
|
Drift::Unchanged
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Reset(reset, true));
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(app.document.revision(), revision + 1);
|
||||||
|
assert_eq!(app.project().elements, before.decoded.project.elements);
|
||||||
|
let after = &app.project().traces[&trace].baseline.as_ref().unwrap();
|
||||||
|
assert_eq!(after.oa, previous.oa);
|
||||||
|
assert_eq!(after.sa, previous.sa);
|
||||||
|
assert_eq!(
|
||||||
|
after
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets
|
||||||
|
.iter()
|
||||||
|
.map(FacetReview::selector)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![kept]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.document.bundle().files[&app.document.bundle().entry]
|
||||||
|
.contains("// Exact review rationale survives reset.")
|
||||||
|
);
|
||||||
|
let accepted = app.document.snapshot();
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
let _ = app.update(Message::Redo);
|
||||||
|
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap_resolution() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, trace, paired, _) = member_pair(ReferenceCollection::SignalItems);
|
||||||
|
let FacetSelector::Member { collection, keys } = &paired else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||||
|
let previous = FacetSelector::Member {
|
||||||
|
collection: *collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: if reverse { None } else { keys.oa },
|
||||||
|
sa: if reverse { keys.sa } else { None },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
put(&mut p, source, &paired, true);
|
||||||
|
history(&mut p, trace, std::slice::from_ref(&previous));
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, source);
|
||||||
|
decide(&mut app, previous.clone(), SemanticDecision::KeepTarget);
|
||||||
|
stage(&mut app, &paired);
|
||||||
|
decide(&mut app, paired.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(paired.clone(), true));
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert_eq!(state(&app).decisions.len(), 2);
|
||||||
|
send(&mut app, Event::Decide(previous.clone(), None));
|
||||||
|
assert!(
|
||||||
|
!state(&app).can_apply(),
|
||||||
|
"Historic pair cannot silently migrate"
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Reset(previous.clone(), true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
send(&mut app, Event::Reset(previous, false));
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(state(&app).presence[&paired]);
|
||||||
|
let old = state(&app).resets.clone();
|
||||||
|
assert!(old.is_empty());
|
||||||
|
let historic = app.project().traces[&trace]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets[0]
|
||||||
|
.selector();
|
||||||
|
send(&mut app, Event::Reset(historic, true));
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(contains(app.project(), target, &paired, oa, sa));
|
||||||
|
assert_eq!(
|
||||||
|
app.project().traces[&trace]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets[0]
|
||||||
|
.selector(),
|
||||||
|
paired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_draft() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, trace) = enum_pair();
|
||||||
|
let ctor = FacetSelector::Data(DataFacet::Constructor);
|
||||||
|
history(&mut p, trace, std::slice::from_ref(&ctor));
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, if reverse { sa } else { oa });
|
||||||
|
let created = literal("separate");
|
||||||
|
stage(&mut app, &created);
|
||||||
|
decide(&mut app, created.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(created.clone(), true));
|
||||||
|
send(&mut app, Event::Reset(ctor.clone(), true));
|
||||||
|
decide(&mut app, ctor.clone(), SemanticDecision::Candidate);
|
||||||
|
assert!(state(&app).can_acknowledge_constructor(app.project(), &ctor));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
let reviewed = &state(&app).plan.as_ref().unwrap().reviews[0];
|
||||||
|
assert_eq!(
|
||||||
|
reviewed
|
||||||
|
.acknowledged
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([ctor.clone(), created.clone()])
|
||||||
|
);
|
||||||
|
assert!(reviewed.retired.is_empty());
|
||||||
|
send(&mut app, Event::Reset(ctor.clone(), false));
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(state(&app).presence[&created]);
|
||||||
|
send(&mut app, Event::Reset(ctor, true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repairable() {
|
||||||
|
let (p, oa, _, _) = enum_pair();
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
let unit = FacetSelector::Data(DataFacet::Unit);
|
||||||
|
decide(&mut app, unit.clone(), SemanticDecision::Candidate);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(
|
||||||
|
"/body/Sa/Shared/DataType/unit".into(),
|
||||||
|
" untouched unit ".into(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
decide(&mut app, unit.clone(), SemanticDecision::KeepTarget);
|
||||||
|
let retained = literal("retained");
|
||||||
|
decide(&mut app, retained.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(retained.clone(), false));
|
||||||
|
assert!(
|
||||||
|
!state(&app).can_apply(),
|
||||||
|
"Existing default must prevent removal"
|
||||||
|
);
|
||||||
|
assert!(!state(&app).presence[&retained]);
|
||||||
|
send(&mut app, Event::Presence(retained.clone(), true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).draft.as_ref().unwrap().inputs["/body/Sa/Shared/DataType/unit"].raw,
|
||||||
|
" untouched unit "
|
||||||
|
);
|
||||||
|
decide(&mut app, unit, SemanticDecision::Candidate);
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_independent_families_and_missing_identities_do_not_gain_edit_authority() {
|
||||||
|
let (p, oa, _, _) = enum_pair();
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
let unsupported = FacetSelector::Member {
|
||||||
|
collection: ReferenceCollection::Steps,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: Some(oa),
|
||||||
|
sa: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
decide(&mut app, unsupported.clone(), SemanticDecision::Candidate);
|
||||||
|
assert!(!state(&app).decisions.contains_key(&unsupported));
|
||||||
|
send(&mut app, Event::MemberFamily(keyed::Family::Literal));
|
||||||
|
send(&mut app, Event::Literal("unpaired".into()));
|
||||||
|
send(&mut app, Event::LiteralSide(Layer::Sa, false));
|
||||||
|
send(&mut app, Event::StageMember);
|
||||||
|
let facet = state(&app).selected.clone().unwrap();
|
||||||
|
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
send(&mut app, Event::LiteralSide(Layer::Oa, false));
|
||||||
|
send(&mut app, Event::StageMember);
|
||||||
|
assert!(state(&app).error.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).decisions.get(&facet),
|
||||||
|
Some(&SemanticDecision::Candidate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_member_source_observation_removes_only_mapped_membership_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, trace, facet, locals) = member_pair(ReferenceCollection::SignalItems);
|
||||||
|
let FacetSelector::Member { keys, .. } = &facet else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let (source, target, deleted, local) = if reverse {
|
||||||
|
(sa, oa, keys.sa.unwrap(), locals[0])
|
||||||
|
} else {
|
||||||
|
(oa, sa, keys.oa.unwrap(), locals[1])
|
||||||
|
};
|
||||||
|
let target_only = FacetSelector::Member {
|
||||||
|
collection: ReferenceCollection::SignalItems,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: reverse.then_some(local),
|
||||||
|
sa: (!reverse).then_some(local),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
put(&mut p, source, &facet, true);
|
||||||
|
put(&mut p, target, &facet, true);
|
||||||
|
put(&mut p, target, &target_only, true);
|
||||||
|
history(&mut p, trace, std::slice::from_ref(&facet));
|
||||||
|
p = prepare_command(&p, &Command::Delete(deleted))
|
||||||
|
.unwrap()
|
||||||
|
.project;
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, source);
|
||||||
|
stage(&mut app, &facet);
|
||||||
|
decide(&mut app, facet.clone(), SemanticDecision::TakeSource);
|
||||||
|
// Force mixed final-record composition, as well as the core Take projection.
|
||||||
|
let name = FacetSelector::Header(HeaderFacet::Name);
|
||||||
|
decide(&mut app, name, SemanticDecision::Candidate);
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(!contains(app.project(), target, &facet, oa, sa));
|
||||||
|
assert!(contains(app.project(), target, &target_only, oa, sa));
|
||||||
|
let retained = if reverse {
|
||||||
|
keys.oa.unwrap()
|
||||||
|
} else {
|
||||||
|
keys.sa.unwrap()
|
||||||
|
};
|
||||||
|
assert!(app.project().elements.contains_key(&retained));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
|
||||||
|
let (mut p, oa, _, trace) = enum_pair();
|
||||||
|
let reset = FacetSelector::Header(HeaderFacet::Name);
|
||||||
|
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||||
|
let mut app = App::with_project(p.clone());
|
||||||
|
start(&mut app, oa);
|
||||||
|
send(&mut app, Event::Reset(reset.clone(), true));
|
||||||
|
app.document
|
||||||
|
.execute(Command::Rename(oa, "Changed after preview".into()))
|
||||||
|
.unwrap();
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
|
||||||
|
assert!(state(&app).resets.contains(&reset));
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let bundle = app.document.bundle().clone();
|
||||||
|
let mut limits = source_project::ProjectLimits::default();
|
||||||
|
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
|
||||||
|
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||||
|
start(&mut app, oa);
|
||||||
|
send(&mut app, Event::Reset(reset.clone(), true));
|
||||||
|
let added = literal(&" exact long literal ".repeat(100));
|
||||||
|
stage(&mut app, &added);
|
||||||
|
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(added.clone(), true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(state(&app).error.is_some());
|
||||||
|
assert!(state(&app).resets.contains(&reset));
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).decisions.get(&added),
|
||||||
|
Some(&SemanticDecision::Candidate)
|
||||||
|
);
|
||||||
|
assert!(state(&app).presence[&added]);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys() {
|
||||||
|
let (mut p, oa, _, trace, facet, _) = member_pair(ReferenceCollection::SignalItems);
|
||||||
|
let FacetSelector::Member { collection, keys } = &facet else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
p.elements.get_mut(&keys.oa.unwrap()).unwrap().name = "Absent intentionally in name".into();
|
||||||
|
let name = view::title(&p, &facet);
|
||||||
|
assert!(name.contains("Absent intentionally in name"));
|
||||||
|
let deleted1 = Id::new_v4();
|
||||||
|
let deleted2 = Id::new_v4();
|
||||||
|
let old1 = FacetSelector::Member {
|
||||||
|
collection: *collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: Some(deleted1),
|
||||||
|
sa: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let old2 = FacetSelector::Member {
|
||||||
|
collection: *collection,
|
||||||
|
keys: KeyPair {
|
||||||
|
oa: Some(deleted2),
|
||||||
|
sa: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
history(&mut p, trace, &[old1.clone(), old2.clone()]);
|
||||||
|
assert!(view::title(&p, &old1).contains(&deleted1.to_string()));
|
||||||
|
assert_ne!(view::title(&p, &old1), view::title(&p, &old2));
|
||||||
|
assert_ne!(
|
||||||
|
view::title(&p, &literal("Absent")),
|
||||||
|
view::title(
|
||||||
|
&p,
|
||||||
|
&FacetSelector::Literal(KeyPair {
|
||||||
|
oa: None,
|
||||||
|
sa: Some("Absent".into())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
for tab in [Tab::History, Tab::Members, Tab::Facts] {
|
||||||
|
send(&mut app, Event::Tab(tab));
|
||||||
|
let _ = state(&app).view(app.project());
|
||||||
|
}
|
||||||
|
send(&mut app, Event::Reset(old1.clone(), true));
|
||||||
|
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||||
|
assert!(!state(&app).facts.iter().any(|f| f.selector == old1));
|
||||||
|
assert!(state(&app).facts.iter().any(|f| f.selector == old2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_presence() {
|
||||||
|
let (mut p, oa, _, trace) = enum_pair();
|
||||||
|
let reset = FacetSelector::Header(HeaderFacet::Name);
|
||||||
|
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||||
|
let mut app = App::with_project(p.clone());
|
||||||
|
start(&mut app, oa);
|
||||||
|
send(&mut app, Event::Reset(reset.clone(), true));
|
||||||
|
let added = literal("staged draft");
|
||||||
|
stage(&mut app, &added);
|
||||||
|
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||||
|
send(&mut app, Event::Presence(added.clone(), true));
|
||||||
|
send(&mut app, Event::DropStaged(added.clone()));
|
||||||
|
assert!(state(&app).decisions.is_empty());
|
||||||
|
assert!(!state(&app).staged.contains(&added));
|
||||||
|
assert!(state(&app).presence[&added]);
|
||||||
|
assert!(state(&app).can_apply());
|
||||||
|
stage(&mut app, &added);
|
||||||
|
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||||
|
assert!(state(&app).presence[&added]);
|
||||||
|
p.traces
|
||||||
|
.get_mut(&trace)
|
||||||
|
.unwrap()
|
||||||
|
.baseline
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.facets
|
||||||
|
.clear();
|
||||||
|
let crate::workflow::Workflow::Semantic(s) = app.workflow.as_mut().unwrap() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
s.refresh(&p);
|
||||||
|
assert!(!s.can_apply());
|
||||||
|
s.tab = Tab::History;
|
||||||
|
let _ = s.view(&p);
|
||||||
|
s.update(&p, Event::Reset(reset, false));
|
||||||
|
assert!(s.can_apply(), "{}", errors(s));
|
||||||
|
assert_eq!(s.decisions.get(&added), Some(&SemanticDecision::Candidate));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! The first supported constructor group is the closed nested MessageKind.
|
||||||
|
//! The core owns its acknowledgment/retirement and validation policies.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn selector() -> FacetSelector {
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn timer_selector() -> FacetSelector {
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression)
|
||||||
|
}
|
||||||
|
pub(super) fn handles(facet: &FacetSelector) -> bool {
|
||||||
|
*facet == selector()
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> {
|
||||||
|
match project.elements.get(&id)?.shared()? {
|
||||||
|
SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||||
|
..
|
||||||
|
} => Some(message_kind),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
use super::{tests::*, *};
|
||||||
|
use crate::{app::App, document::Document};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn fixture(oa_kind: MessageKind, sa_kind: MessageKind) -> (Project, Id, Id, Id) {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let mut ids = vec![];
|
||||||
|
for (layer, message_kind) in [(Layer::Oa, oa_kind), (Layer::Sa, sa_kind)] {
|
||||||
|
let scenario = p
|
||||||
|
.add_element(Kind::Scenario, layer, "Scenario", None)
|
||||||
|
.unwrap();
|
||||||
|
let id = p
|
||||||
|
.add_element(Kind::Occurrence, layer, "Message", Some(scenario))
|
||||||
|
.unwrap();
|
||||||
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
exchange: None,
|
||||||
|
sender: None,
|
||||||
|
receiver: None,
|
||||||
|
message_kind,
|
||||||
|
reply_to: None,
|
||||||
|
values: vec![],
|
||||||
|
},
|
||||||
|
precondition: "retain precondition".into(),
|
||||||
|
postcondition: "retain postcondition".into(),
|
||||||
|
};
|
||||||
|
p.elements
|
||||||
|
.get_mut(&scenario)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
let trace = add_trace(&mut p, ids[0], ids[1], "Message-kind review");
|
||||||
|
(p, ids[0], ids[1], trace)
|
||||||
|
}
|
||||||
|
fn timer(value: &str) -> MessageKind {
|
||||||
|
MessageKind::Timer {
|
||||||
|
expression: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn path(layer: &str) -> String {
|
||||||
|
format!("/body/{layer}/Shared/Occurrence/body/Exchange/message_kind")
|
||||||
|
}
|
||||||
|
fn set_kind(p: &mut Project, id: Id, kind: MessageKind) {
|
||||||
|
let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||||
|
..
|
||||||
|
}) = p.elements.get_mut(&id).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
*message_kind = kind;
|
||||||
|
}
|
||||||
|
fn seed_history(p: &mut Project, trace: Id) {
|
||||||
|
let t = &p.traces[&trace];
|
||||||
|
let baseline = TraceBaseline {
|
||||||
|
oa: p.properties(t.oa).unwrap(),
|
||||||
|
sa: p.properties(t.sa).unwrap(),
|
||||||
|
semantic: Some(SemanticBaseline {
|
||||||
|
facets: [message_kind::selector(), message_kind::timer_selector()]
|
||||||
|
.iter()
|
||||||
|
.map(|selector| capture_facet(p, t.oa, t.sa, selector).unwrap())
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
p.traces.get_mut(&trace).unwrap().baseline = Some(baseline);
|
||||||
|
}
|
||||||
|
fn acknowledged(app: &App) -> BTreeSet<FacetSelector> {
|
||||||
|
state(app).plan.as_ref().unwrap().reviews[0]
|
||||||
|
.acknowledged
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
fn failures(app: &App) -> String {
|
||||||
|
state(app)
|
||||||
|
.error
|
||||||
|
.iter()
|
||||||
|
.chain(state(app).plan.iter().flat_map(|p| &p.conflicts))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_take_full_timer_and_scalar_candidate_are_one_source_history_change_both_directions()
|
||||||
|
{
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let expression = " after β ≥ 5 s\n\tkeep exact whitespace ";
|
||||||
|
let (p, oa, sa, trace) = if reverse {
|
||||||
|
fixture(MessageKind::Asynchronous, timer(expression))
|
||||||
|
} else {
|
||||||
|
fixture(timer(expression), MessageKind::Asynchronous)
|
||||||
|
};
|
||||||
|
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let mut bundle = app.document.bundle().clone();
|
||||||
|
bundle
|
||||||
|
.files
|
||||||
|
.get_mut(&bundle.entry)
|
||||||
|
.unwrap()
|
||||||
|
.insert_str(0, "// Keep the engineering review rationale.\r\n");
|
||||||
|
app.document.apply_bundle(bundle).unwrap();
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
let revision = app.document.revision();
|
||||||
|
start(&mut app, source);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
FacetSelector::Header(HeaderFacet::Name),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input("/name".into(), "Reviewed message".into()),
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app),
|
||||||
|
BTreeSet::from([
|
||||||
|
message_kind::selector(),
|
||||||
|
message_kind::timer_selector(),
|
||||||
|
FacetSelector::Header(HeaderFacet::Name)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state(&app).plan.as_ref().unwrap().reviews[0]
|
||||||
|
.retired
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
|
||||||
|
let expected = state(&app).proposed.clone().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
message_kind::value(&expected, target),
|
||||||
|
Some(&timer(expression))
|
||||||
|
);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(app.project(), &expected);
|
||||||
|
assert_eq!(app.document.revision(), revision + 1);
|
||||||
|
assert_eq!(
|
||||||
|
app.project().elements[&source],
|
||||||
|
before.decoded.project.elements[&source]
|
||||||
|
);
|
||||||
|
let saved = app.document.bundle().clone();
|
||||||
|
assert!(saved.files[&saved.entry].starts_with("// Keep the engineering review rationale."));
|
||||||
|
assert_eq!(
|
||||||
|
Document::from_bundle(saved.clone(), source_project::ProjectLimits::default())
|
||||||
|
.unwrap()
|
||||||
|
.snapshot()
|
||||||
|
.decoded
|
||||||
|
.project,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let facts = &app.project().traces[&trace]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets;
|
||||||
|
assert_eq!(facts.len(), 3);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
let _ = app.update(Message::Redo);
|
||||||
|
assert_eq!(app.document.bundle(), &saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_explicit() {
|
||||||
|
let (p, oa, _, _) = fixture(MessageKind::Asynchronous, timer("original target"));
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
let kind = path("Sa");
|
||||||
|
let expression = format!("{kind}/Timer/expression");
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::timer_selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(expression.clone(), "independent scalar timer".into()),
|
||||||
|
);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
assert_eq!(state(&app).decisions.len(), 2);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(expression.clone(), " group β timer\n\t ".into()),
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Text(expression.clone(), text_editor::Action::SelectAll),
|
||||||
|
);
|
||||||
|
let cursor = state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.editor
|
||||||
|
.cursor();
|
||||||
|
let selection = state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.editor
|
||||||
|
.selection();
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Variant(kind.clone(), json!("Asynchronous")),
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Variant(kind, json!({"Timer":{"expression":""}})),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.raw,
|
||||||
|
" group β timer\n\t "
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.editor
|
||||||
|
.cursor(),
|
||||||
|
cursor
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.editor
|
||||||
|
.selection(),
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).draft.as_ref().unwrap().inputs[&expression].raw,
|
||||||
|
"independent scalar timer"
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
// The visible overlap-resolution actions only change the explicitly chosen
|
||||||
|
// decision. Neither independent candidate buffer is discarded.
|
||||||
|
send(&mut app, Event::Decide(message_kind::selector(), None));
|
||||||
|
assert!(state(&app).can_apply());
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Decide(message_kind::timer_selector(), None),
|
||||||
|
);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app),
|
||||||
|
BTreeSet::from([message_kind::selector(), message_kind::timer_selector()])
|
||||||
|
);
|
||||||
|
let _ = state(&app).view(app.project());
|
||||||
|
let expected = state(&app).proposed.clone().unwrap();
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(app.project(), &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_keep_historical_switch_retires_timer_and_completed_no_switch_rejects() {
|
||||||
|
let (mut p, oa, sa, trace) = fixture(timer("old OA"), timer("old SA"));
|
||||||
|
seed_history(&mut p, trace);
|
||||||
|
set_kind(&mut p, oa, MessageKind::Asynchronous);
|
||||||
|
set_kind(&mut p, sa, MessageKind::Asynchronous);
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::KeepTarget,
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app),
|
||||||
|
BTreeSet::from([message_kind::selector()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app).plan.as_ref().unwrap().reviews[0].retired,
|
||||||
|
vec![message_kind::timer_selector()]
|
||||||
|
);
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
app.project().traces[&trace]
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.semantic
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.facets
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::KeepTarget,
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(
|
||||||
|
failures(&app).contains("No constructor switch"),
|
||||||
|
"{}",
|
||||||
|
failures(&app)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut p, oa, sa, _) = if reverse {
|
||||||
|
fixture(MessageKind::Asynchronous, MessageKind::Reply)
|
||||||
|
} else {
|
||||||
|
fixture(MessageKind::Reply, MessageKind::Asynchronous)
|
||||||
|
};
|
||||||
|
let (source, target, layer) = if reverse {
|
||||||
|
(sa, oa, "Oa")
|
||||||
|
} else {
|
||||||
|
(oa, sa, "Sa")
|
||||||
|
};
|
||||||
|
let mut calls = vec![];
|
||||||
|
for message in [oa, sa] {
|
||||||
|
let scenario = p.elements[&message].parent.unwrap();
|
||||||
|
let layer = p.elements[&message].layer();
|
||||||
|
let call = p
|
||||||
|
.add_element(Kind::Occurrence, layer, "Correlated call", Some(scenario))
|
||||||
|
.unwrap();
|
||||||
|
*p.elements.get_mut(&call).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
exchange: None,
|
||||||
|
sender: None,
|
||||||
|
receiver: None,
|
||||||
|
message_kind: MessageKind::Call,
|
||||||
|
reply_to: None,
|
||||||
|
values: vec![],
|
||||||
|
},
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
};
|
||||||
|
p.elements
|
||||||
|
.get_mut(&scenario)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(0, call);
|
||||||
|
if message == source {
|
||||||
|
let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { reply_to, .. },
|
||||||
|
..
|
||||||
|
}) = p.elements.get_mut(&message).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
*reply_to = Some(call);
|
||||||
|
}
|
||||||
|
calls.push(call);
|
||||||
|
}
|
||||||
|
let target_call = if reverse { calls[0] } else { calls[1] };
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
start(&mut app, source);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Variant(path(layer), json!("Reply")));
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo),
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(
|
||||||
|
failures(&app).contains("reference counterparts"),
|
||||||
|
"{}",
|
||||||
|
failures(&app)
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::ReferenceOa(Choice {
|
||||||
|
id: calls[0],
|
||||||
|
label: "OA call".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::ReferenceSa(Choice {
|
||||||
|
id: calls[1],
|
||||||
|
label: "SA call".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
send(&mut app, Event::AddReference);
|
||||||
|
assert!(state(&app).can_apply(), "{}", failures(&app));
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app),
|
||||||
|
BTreeSet::from([
|
||||||
|
message_kind::selector(),
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
let final_value =
|
||||||
|
serde_json::to_value(&state(&app).proposed.as_ref().unwrap().elements[&target])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
final_value.pointer(&format!(
|
||||||
|
"/body/{layer}/Shared/Occurrence/body/Exchange/reply_to"
|
||||||
|
)),
|
||||||
|
Some(&json!(target_call))
|
||||||
|
);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
message_kind::value(app.project(), target),
|
||||||
|
Some(&MessageKind::Reply)
|
||||||
|
);
|
||||||
|
for call in calls {
|
||||||
|
assert_eq!(
|
||||||
|
app.project().elements[&call],
|
||||||
|
before.decoded.project.elements[&call]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
for kind in [
|
||||||
|
MessageKind::Asynchronous,
|
||||||
|
MessageKind::Call,
|
||||||
|
MessageKind::Reply,
|
||||||
|
MessageKind::Create,
|
||||||
|
MessageKind::Delete,
|
||||||
|
timer("after 2 s"),
|
||||||
|
] {
|
||||||
|
let (p, oa, sa, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
|
||||||
|
let (source, target, layer) = if reverse {
|
||||||
|
(sa, oa, "Oa")
|
||||||
|
} else {
|
||||||
|
(oa, sa, "Sa")
|
||||||
|
};
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, source);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{kind:?}: {}", failures(&app));
|
||||||
|
assert_eq!(
|
||||||
|
message_kind::value(state(&app).proposed.as_ref().unwrap(), target),
|
||||||
|
Some(&kind)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app).contains(&message_kind::timer_selector()),
|
||||||
|
matches!(kind, MessageKind::Timer { .. })
|
||||||
|
);
|
||||||
|
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut p, oa, _, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
|
||||||
|
let mut review = State::new(&p, oa).unwrap();
|
||||||
|
let Some(SharedBody::Occurrence { body, .. }) = p.elements.get_mut(&oa).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
*body = OccurrenceBody::Action {
|
||||||
|
action: None,
|
||||||
|
lifeline: None,
|
||||||
|
};
|
||||||
|
review.refresh(&p);
|
||||||
|
assert!(
|
||||||
|
!review.editable(&p, &message_kind::selector()),
|
||||||
|
"An externally changed outer occurrence requires its own constructor review"
|
||||||
|
);
|
||||||
|
review.update(
|
||||||
|
&p,
|
||||||
|
Event::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
|
||||||
|
);
|
||||||
|
assert!(review.decisions.is_empty());
|
||||||
|
assert!(!review.can_apply());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision() {
|
||||||
|
let (mut p, oa, sa, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
|
||||||
|
let sender = p
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Sender", None)
|
||||||
|
.unwrap();
|
||||||
|
let receiver = p
|
||||||
|
.add_element(Kind::Function, Layer::Sa, "Receiver", None)
|
||||||
|
.unwrap();
|
||||||
|
let exchange = p.relate(
|
||||||
|
RelationKind::FunctionalExchange,
|
||||||
|
"Message exchange",
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
);
|
||||||
|
let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
exchange: selected, ..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
}) = p.elements.get_mut(&sa).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
*selected = Some(exchange);
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(
|
||||||
|
failures(&app).contains("timer_contract"),
|
||||||
|
"{}",
|
||||||
|
failures(&app)
|
||||||
|
);
|
||||||
|
assert_eq!(state(&app).decisions.len(), 1);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Exchange),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Shape(
|
||||||
|
"/body/Sa/Shared/Occurrence/body/Exchange/exchange".into(),
|
||||||
|
Value::Null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{}", failures(&app));
|
||||||
|
assert_eq!(
|
||||||
|
acknowledged(&app),
|
||||||
|
BTreeSet::from([
|
||||||
|
message_kind::selector(),
|
||||||
|
message_kind::timer_selector(),
|
||||||
|
FacetSelector::Occurrence(OccurrenceFacet::Exchange)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
|
||||||
|
apply(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
app.project().relations[&exchange],
|
||||||
|
before.decoded.project.relations[&exchange]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_timer_agreement() {
|
||||||
|
let (p, oa, sa, _) = fixture(timer("source timer"), timer("target timer"));
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::TakeSource,
|
||||||
|
);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
FacetSelector::Header(HeaderFacet::Name),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Input("/name".into(), "Final name".into()));
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
assert_eq!(
|
||||||
|
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
|
||||||
|
Some(&timer("target timer"))
|
||||||
|
);
|
||||||
|
assert!(!acknowledged(&app).contains(&message_kind::timer_selector()));
|
||||||
|
let expression = format!("{}/Timer/expression", path("Sa"));
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(expression.clone(), "authored group timer".into()),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state(&app).can_apply(),
|
||||||
|
"An unchanged initial kind cannot silently authorize a timer edit"
|
||||||
|
);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::timer_selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(expression.clone(), "different scalar timer".into()),
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(
|
||||||
|
state(&app)
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("different final values")
|
||||||
|
);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input(expression, "authored group timer".into()),
|
||||||
|
);
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
assert!(acknowledged(&app).contains(&message_kind::timer_selector()));
|
||||||
|
assert_eq!(
|
||||||
|
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
|
||||||
|
Some(&timer("authored group timer"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_losing_timer() {
|
||||||
|
for source_failure in [false, true] {
|
||||||
|
let (p, oa, _, _) = fixture(MessageKind::Asynchronous, timer("target"));
|
||||||
|
let mut app = App::with_project(p);
|
||||||
|
if source_failure {
|
||||||
|
let bundle = app.document.bundle().clone();
|
||||||
|
let mut limits = source_project::ProjectLimits::default();
|
||||||
|
limits.model.max_source_bytes =
|
||||||
|
bundle.files.values().map(String::len).sum::<usize>() + 10;
|
||||||
|
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||||
|
}
|
||||||
|
start(&mut app, oa);
|
||||||
|
decide(
|
||||||
|
&mut app,
|
||||||
|
message_kind::selector(),
|
||||||
|
SemanticDecision::Candidate,
|
||||||
|
);
|
||||||
|
let expression = format!("{}/Timer/expression", path("Sa"));
|
||||||
|
let raw = "retain exact timer β\n".repeat(40);
|
||||||
|
send(&mut app, Event::Input(expression.clone(), raw.clone()));
|
||||||
|
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Event::Input("/name".into(), "Unselected protected scope".into()),
|
||||||
|
);
|
||||||
|
assert!(!state(&app).can_apply());
|
||||||
|
assert!(
|
||||||
|
state(&app)
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("selected review")
|
||||||
|
);
|
||||||
|
send(&mut app, Event::Preview);
|
||||||
|
assert!(state(&app).can_apply());
|
||||||
|
if !source_failure {
|
||||||
|
app.document
|
||||||
|
.execute(Command::Rename(oa, "External source edit".into()))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
let revision = app.document.revision();
|
||||||
|
apply(&mut app);
|
||||||
|
assert!(state(&app).error.is_some());
|
||||||
|
assert_eq!(state(&app).decisions.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
state(&app)
|
||||||
|
.group_drafts
|
||||||
|
.get(&message_kind::selector())
|
||||||
|
.unwrap()
|
||||||
|
.inputs[&expression]
|
||||||
|
.raw,
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
assert_eq!(app.document.bundle(), &before.bundle);
|
||||||
|
assert_eq!(app.document.revision(), revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ use super::*;
|
|||||||
use crate::{app::App, document::Document, workflow::Workflow};
|
use crate::{app::App, document::Document, workflow::Workflow};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn send(app: &mut App, event: Event) {
|
pub(super) fn send(app: &mut App, event: Event) {
|
||||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||||
}
|
}
|
||||||
fn apply(app: &mut App) {
|
pub(super) fn apply(app: &mut App) {
|
||||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||||
}
|
}
|
||||||
fn state(app: &App) -> &State {
|
pub(super) fn state(app: &App) -> &State {
|
||||||
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
||||||
panic!("Semantic workflow missing: {}", app.status)
|
panic!("Semantic workflow missing: {}", app.status)
|
||||||
};
|
};
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> (Project, Id, Id, Id) {
|
|||||||
let trace = add_trace(&mut p, oa, sa, "Range rationale");
|
let trace = add_trace(&mut p, oa, sa, "Range rationale");
|
||||||
(p, oa, sa, trace)
|
(p, oa, sa, trace)
|
||||||
}
|
}
|
||||||
fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
pub(super) fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
||||||
let id = Id::new_v4();
|
let id = Id::new_v4();
|
||||||
p.traces.insert(
|
p.traces.insert(
|
||||||
id,
|
id,
|
||||||
@@ -52,7 +52,7 @@ fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
|||||||
);
|
);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
fn start(app: &mut App, source: Id) {
|
pub(super) fn start(app: &mut App, source: Id) {
|
||||||
let _ = app.update(Message::StartSemanticReview(source));
|
let _ = app.update(Message::StartSemanticReview(source));
|
||||||
}
|
}
|
||||||
fn facet(value: DataFacet) -> FacetSelector {
|
fn facet(value: DataFacet) -> FacetSelector {
|
||||||
@@ -262,7 +262,7 @@ fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity
|
|||||||
assert!(second_title.contains(&b.to_string()));
|
assert!(second_title.contains(&b.to_string()));
|
||||||
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
|
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
|
||||||
}
|
}
|
||||||
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
pub(super) fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
||||||
send(app, Event::Select(selected.clone()));
|
send(app, Event::Select(selected.clone()));
|
||||||
send(app, Event::Decide(selected, Some(decision)));
|
send(app, Event::Decide(selected, Some(decision)));
|
||||||
}
|
}
|
||||||
@@ -386,7 +386,7 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
|||||||
.error
|
.error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("selected scalar")
|
.contains("selected review")
|
||||||
);
|
);
|
||||||
assert_eq!(app.project(), &before.decoded.project);
|
assert_eq!(app.project(), &before.decoded.project);
|
||||||
send(&mut app, Event::Preview);
|
send(&mut app, Event::Preview);
|
||||||
@@ -568,7 +568,10 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
|
|||||||
send(
|
send(
|
||||||
&mut app,
|
&mut app,
|
||||||
Event::Decide(
|
Event::Decide(
|
||||||
facet(DataFacet::Constructor),
|
FacetSelector::Literal(KeyPair {
|
||||||
|
oa: Some("Unpaired".into()),
|
||||||
|
sa: None,
|
||||||
|
}),
|
||||||
Some(SemanticDecision::TakeSource),
|
Some(SemanticDecision::TakeSource),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,27 +71,37 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||||
let keys = |keys: &KeyPair<Id>| {
|
let keys = |keys: &KeyPair<Id>, missing: &str| {
|
||||||
format!(
|
format!(
|
||||||
"{} ↔ {}",
|
"{} ↔ {}",
|
||||||
keys.oa
|
keys.oa
|
||||||
.map(|id| choice(project, id).label)
|
.map(|id| choice(project, id).label)
|
||||||
.unwrap_or_else(|| "Absent".into()),
|
.unwrap_or_else(|| missing.into()),
|
||||||
keys.sa
|
keys.sa
|
||||||
.map(|id| choice(project, id).label)
|
.map(|id| choice(project, id).label)
|
||||||
.unwrap_or_else(|| "Absent".into())
|
.unwrap_or_else(|| missing.into())
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
match facet {
|
match facet {
|
||||||
FacetSelector::Member {
|
FacetSelector::Member {
|
||||||
collection,
|
collection,
|
||||||
keys: pair,
|
keys: pair,
|
||||||
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
|
} => format!(
|
||||||
|
"{} · {}",
|
||||||
|
words(&format!("{collection:?}")),
|
||||||
|
keys(pair, "No identity")
|
||||||
|
),
|
||||||
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
||||||
FacetSelector::Literal(pair) => format!(
|
FacetSelector::Literal(pair) => format!(
|
||||||
"Literal · {} ↔ {}",
|
"Literal · {} ↔ {}",
|
||||||
pair.oa.as_deref().unwrap_or("Absent"),
|
pair.oa
|
||||||
pair.sa.as_deref().unwrap_or("Absent")
|
.as_ref()
|
||||||
|
.map(|s| format!("{s:?}"))
|
||||||
|
.unwrap_or_else(|| "No identity".into()),
|
||||||
|
pair.sa
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| format!("{s:?}"))
|
||||||
|
.unwrap_or_else(|| "No identity".into())
|
||||||
),
|
),
|
||||||
FacetSelector::Annotation(pair) => format!(
|
FacetSelector::Annotation(pair) => format!(
|
||||||
"Message value · {} ↔ {}",
|
"Message value · {} ↔ {}",
|
||||||
@@ -104,11 +114,17 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|||||||
.map(|key| annotation_title(project, key))
|
.map(|key| annotation_title(project, key))
|
||||||
.unwrap_or_else(|| "Absent".into())
|
.unwrap_or_else(|| "Absent".into())
|
||||||
),
|
),
|
||||||
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
|
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")),
|
||||||
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")),
|
||||||
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
|
FacetSelector::Incident { keys: pair, end } => {
|
||||||
|
format!("Incident {end:?} · {}", keys(pair, "Absent"))
|
||||||
|
}
|
||||||
FacetSelector::Artifact { keys: pair, facet } => {
|
FacetSelector::Artifact { keys: pair, facet } => {
|
||||||
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
|
format!(
|
||||||
|
"Artifact {} · {}",
|
||||||
|
words(&format!("{facet:?}")),
|
||||||
|
keys(pair, "Absent")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let json = serde_json::to_value(facet).expect("Typed selector");
|
let json = serde_json::to_value(facet).expect("Typed selector");
|
||||||
@@ -199,6 +215,12 @@ fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> Stri
|
|||||||
let value = scalar::observation(fact, layer);
|
let value = scalar::observation(fact, layer);
|
||||||
if value.as_str() == Some("NotApplicable") {
|
if value.as_str() == Some("NotApplicable") {
|
||||||
"Not applicable".into()
|
"Not applicable".into()
|
||||||
|
} else if keyed::supported(&fact.selector()) {
|
||||||
|
if value["Value"].as_bool() == Some(true) {
|
||||||
|
"Present".into()
|
||||||
|
} else {
|
||||||
|
"Absent".into()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
formatted(project, &value["Value"], &scalar::references(fact))
|
formatted(project, &value["Value"], &scalar::references(fact))
|
||||||
}
|
}
|
||||||
@@ -237,11 +259,13 @@ impl State {
|
|||||||
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
||||||
].spacing(8),
|
].spacing(8),
|
||||||
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
||||||
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
|
row![action("Properties", Event::Tab(Tab::Facts)), action("Members / literals", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6).wrap(),
|
||||||
].spacing(8);
|
].spacing(8);
|
||||||
let body = match self.tab {
|
let body = match self.tab {
|
||||||
Tab::Facts => self.facts_view(project),
|
Tab::Facts => self.facts_view(project),
|
||||||
Tab::References => self.references_view(project),
|
Tab::References => self.references_view(project),
|
||||||
|
Tab::Members => self.members_view(project),
|
||||||
|
Tab::History => self.history_view(project),
|
||||||
Tab::Impacts => self.impacts_view(project),
|
Tab::Impacts => self.impacts_view(project),
|
||||||
};
|
};
|
||||||
content = content.push(body);
|
content = content.push(body);
|
||||||
@@ -266,7 +290,7 @@ impl State {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.drift == Drift::Unreviewed)
|
.filter(|f| f.drift == Drift::Unreviewed)
|
||||||
.count();
|
.count();
|
||||||
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
|
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Ordered, annotation, binding and attached-record decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
|
||||||
if let Some(trace) = trace {
|
if let Some(trace) = trace {
|
||||||
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
||||||
}
|
}
|
||||||
@@ -364,7 +388,43 @@ impl State {
|
|||||||
.and_then(|t| t.baseline.as_ref())
|
.and_then(|t| t.baseline.as_ref())
|
||||||
.and_then(|b| b.semantic.as_ref())
|
.and_then(|b| b.semantic.as_ref())
|
||||||
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
|
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
|
||||||
|
let is_group = scalar::grouped(&fact.selector);
|
||||||
|
let group_observations = |project: &Project, facts: Vec<&FacetReview>, layer| {
|
||||||
|
facts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| constructors::contains(&fact.selector, &f.selector()))
|
||||||
|
.map(|f| {
|
||||||
|
format!(
|
||||||
|
"{}: {}",
|
||||||
|
title(project, &f.selector()),
|
||||||
|
observed(project, Some(f), layer)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
};
|
||||||
let side = |layer, label: &str| {
|
let side = |layer, label: &str| {
|
||||||
|
let mut before = observed(project, previous, layer);
|
||||||
|
let mut current = observed(project, Some(&fact.current), layer);
|
||||||
|
if is_group {
|
||||||
|
before = group_observations(
|
||||||
|
project,
|
||||||
|
trace
|
||||||
|
.and_then(|t| t.baseline.as_ref())
|
||||||
|
.and_then(|b| b.semantic.as_ref())
|
||||||
|
.map(|b| b.facets.iter().collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
layer,
|
||||||
|
);
|
||||||
|
if before.is_empty() {
|
||||||
|
before = "No previous group observations".into();
|
||||||
|
}
|
||||||
|
current = group_observations(
|
||||||
|
project,
|
||||||
|
self.facts.iter().map(|f| &f.current).collect(),
|
||||||
|
layer,
|
||||||
|
);
|
||||||
|
}
|
||||||
column![
|
column![
|
||||||
text(format!(
|
text(format!(
|
||||||
"{label}{}",
|
"{label}{}",
|
||||||
@@ -377,9 +437,9 @@ impl State {
|
|||||||
.size(12)
|
.size(12)
|
||||||
.color(style::TEAL),
|
.color(style::TEAL),
|
||||||
text("Previously reviewed").size(10).color(style::MUTED),
|
text("Previously reviewed").size(10).color(style::MUTED),
|
||||||
text(observed(project, previous, layer)).size(12),
|
text(before).size(12),
|
||||||
text("Current").size(10).color(style::MUTED),
|
text("Current").size(10).color(style::MUTED),
|
||||||
text(observed(project, Some(&fact.current), layer)).size(12),
|
text(current).size(12),
|
||||||
]
|
]
|
||||||
.spacing(5)
|
.spacing(5)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
@@ -390,6 +450,38 @@ impl State {
|
|||||||
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
||||||
]
|
]
|
||||||
.spacing(10);
|
.spacing(10);
|
||||||
|
if previous.is_some() && history::resettable(&fact.selector) {
|
||||||
|
let facet = fact.selector.clone();
|
||||||
|
detail = detail.push(
|
||||||
|
checkbox(self.resets.contains(&facet))
|
||||||
|
.label("Reset this exact previous observation before review")
|
||||||
|
.text_size(11)
|
||||||
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if keyed::supported(&fact.selector) {
|
||||||
|
detail = detail.push(text("Presence changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry.").size(11))
|
||||||
|
.push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone())));
|
||||||
|
if let Some(baseline) = trace
|
||||||
|
.and_then(|t| t.baseline.as_ref())
|
||||||
|
.and_then(|b| b.semantic.as_ref())
|
||||||
|
{
|
||||||
|
for prior in &baseline.facets {
|
||||||
|
let prior = prior.selector();
|
||||||
|
if prior != fact.selector && keyed::overlap(&prior, &fact.selector) {
|
||||||
|
detail = detail.push(
|
||||||
|
checkbox(self.resets.contains(&prior))
|
||||||
|
.label(format!(
|
||||||
|
"Reset overlapping history: {}",
|
||||||
|
title(project, &prior)
|
||||||
|
))
|
||||||
|
.text_size(11)
|
||||||
|
.on_toggle(move |v| event(Event::Reset(prior.clone(), v))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(proposed) = &self.proposed
|
if let Some(proposed) = &self.proposed
|
||||||
&& let Some(trace) = trace
|
&& let Some(trace) = trace
|
||||||
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
||||||
@@ -401,11 +493,20 @@ impl State {
|
|||||||
.color(style::TEAL),
|
.color(style::TEAL),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
text(observed(
|
text(if is_group {
|
||||||
proposed,
|
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
|
||||||
Some(&after),
|
.ok()
|
||||||
self.direction.target_layer(),
|
.map(|facts| {
|
||||||
))
|
group_observations(
|
||||||
|
proposed,
|
||||||
|
facts.iter().map(|f| &f.current).collect(),
|
||||||
|
self.direction.target_layer(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Group payload is unavailable.".into())
|
||||||
|
} else {
|
||||||
|
observed(proposed, Some(&after), self.direction.target_layer())
|
||||||
|
})
|
||||||
.size(12),
|
.size(12),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -416,6 +517,13 @@ impl State {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if self.editable(project, &fact.selector) {
|
if self.editable(project, &fact.selector) {
|
||||||
|
if is_group {
|
||||||
|
detail = detail.push(text(constructors::description(&fact.selector)).size(12));
|
||||||
|
detail = detail.push(text("Replacing a kind reviews its complete payload. Review every removed reference, row and prior observation in the exact scope below; changing kind retains your draft for switching back.").size(11));
|
||||||
|
if self.can_acknowledge_constructor(project, &fact.selector) {
|
||||||
|
detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged Candidate kind needs independent descendant decisions for payload edits.").size(11).color(style::MUTED));
|
||||||
|
}
|
||||||
|
}
|
||||||
let destination = if self.direction.target_layer() == Layer::Sa {
|
let destination = if self.direction.target_layer() == Layer::Sa {
|
||||||
"System"
|
"System"
|
||||||
} else {
|
} else {
|
||||||
@@ -438,30 +546,120 @@ impl State {
|
|||||||
]
|
]
|
||||||
.spacing(5);
|
.spacing(5);
|
||||||
buttons = buttons.push(action(
|
buttons = buttons.push(action(
|
||||||
"Set final value…",
|
if is_group {
|
||||||
|
"Set final kind and payload…"
|
||||||
|
} else {
|
||||||
|
"Set final value…"
|
||||||
|
},
|
||||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
||||||
));
|
));
|
||||||
detail = detail.push(buttons).push(action(
|
detail = detail.push(buttons.wrap()).push(action(
|
||||||
"Leave unselected",
|
"Leave unselected",
|
||||||
Event::Decide(fact.selector.clone(), None),
|
Event::Decide(fact.selector.clone(), None),
|
||||||
));
|
));
|
||||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||||
&& let Some(draft) = &self.draft
|
&& keyed::supported(&fact.selector)
|
||||||
&& let Some(path) = self
|
|
||||||
.target(project)
|
|
||||||
.and_then(|id| scalar::binding(project, id, &fact.selector))
|
|
||||||
{
|
{
|
||||||
detail = detail.push(text("Final destination value").size(12)).push(
|
let facet = fact.selector.clone();
|
||||||
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
|
detail = detail.push(
|
||||||
.map(form_event),
|
checkbox(self.presence.get(&facet).copied().unwrap_or(false))
|
||||||
|
.label(
|
||||||
|
"Include in destination collection (unchecked removes only this entry)",
|
||||||
|
)
|
||||||
|
.text_size(12)
|
||||||
|
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||||
|
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||||
|
&& let Some(paths) = self
|
||||||
|
.target(project)
|
||||||
|
.and_then(|id| constructors::roots(project, id, &fact.selector))
|
||||||
|
{
|
||||||
|
detail = detail.push(text("Final destination value").size(12));
|
||||||
|
for path in paths {
|
||||||
|
let label = if path.ends_with("/default") {
|
||||||
|
"Default"
|
||||||
|
} else if path.ends_with("/legacy") {
|
||||||
|
"Generic association"
|
||||||
|
} else {
|
||||||
|
"Kind and payload"
|
||||||
|
};
|
||||||
|
detail = detail.push(
|
||||||
|
crate::forms::scoped(
|
||||||
|
draft,
|
||||||
|
project,
|
||||||
|
&path,
|
||||||
|
if is_group { label } else { "Final value" },
|
||||||
|
)
|
||||||
|
.map(form_event),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if self.can_acknowledge_constructor(project, &fact.selector) {
|
} else if self.can_acknowledge_constructor(project, &fact.selector) {
|
||||||
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
|
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
|
||||||
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
||||||
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
||||||
} else {
|
} else {
|
||||||
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
||||||
|
}
|
||||||
|
if self.staged.contains(&fact.selector) {
|
||||||
|
detail = detail.push(action(
|
||||||
|
"Remove staged address and its decision",
|
||||||
|
Event::DropStaged(fact.selector.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let overlaps: Vec<_> = self
|
||||||
|
.decisions
|
||||||
|
.keys()
|
||||||
|
.filter(|other| {
|
||||||
|
**other != fact.selector
|
||||||
|
&& (constructors::contains(&fact.selector, other)
|
||||||
|
|| constructors::contains(other, &fact.selector)
|
||||||
|
|| keyed::overlap(other, &fact.selector))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
|
||||||
|
detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11));
|
||||||
|
for other in overlaps {
|
||||||
|
detail = detail.push(action(
|
||||||
|
format!("Leave {} unselected", title(project, other)),
|
||||||
|
Event::Decide(other.clone(), None),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
detail = detail.push(action(
|
||||||
|
"Leave this decision unselected; use the related decisions",
|
||||||
|
Event::Decide(fact.selector.clone(), None),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (is_group
|
||||||
|
|| keyed::supported(&fact.selector)
|
||||||
|
|| constructors::selectors().iter().any(|group| {
|
||||||
|
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
|
||||||
|
}))
|
||||||
|
&& let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first())
|
||||||
|
{
|
||||||
|
detail = detail.push(text("Exact review scope from preview").size(13));
|
||||||
|
for (label, selectors) in [
|
||||||
|
("Acknowledge", &review.acknowledged),
|
||||||
|
("Retire previous observation", &review.retired),
|
||||||
|
] {
|
||||||
|
detail = detail.push(
|
||||||
|
text(format!(
|
||||||
|
"{label}: {}",
|
||||||
|
if selectors.is_empty() {
|
||||||
|
"None".into()
|
||||||
|
} else {
|
||||||
|
selectors
|
||||||
|
.iter()
|
||||||
|
.map(|s| title(project, s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.size(11),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
fact.selector,
|
fact.selector,
|
||||||
@@ -474,10 +672,106 @@ impl State {
|
|||||||
detail.into()
|
detail.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||||
|
let families = self.member_families(project);
|
||||||
|
let available = families.contains(&self.member_family);
|
||||||
|
let mut content = column![
|
||||||
|
text("Review an individual member or literal").size(16),
|
||||||
|
text("Choose an address, then review its presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
|
||||||
|
pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7),
|
||||||
|
].spacing(9);
|
||||||
|
if !available {
|
||||||
|
content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12));
|
||||||
|
} else if self.member_family == keyed::Family::Literal {
|
||||||
|
content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11))
|
||||||
|
.push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7))
|
||||||
|
.push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Sa,v)))].spacing(12));
|
||||||
|
} else {
|
||||||
|
content = content.push(text("Identities may exist outside the collection. Choosing both explicitly sets their OA → SA correspondence. No identity means an unpaired side, distinct from an absent member.").size(11))
|
||||||
|
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7))
|
||||||
|
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7));
|
||||||
|
}
|
||||||
|
content = content.push(button(text("Use these identities and review presence").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
|
||||||
|
.push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED));
|
||||||
|
if !self.staged.is_empty() {
|
||||||
|
content = content.push(text("Staged addresses in this draft").size(13));
|
||||||
|
for facet in &self.staged {
|
||||||
|
content = content.push(
|
||||||
|
row![
|
||||||
|
text(title(project, facet)).size(11).width(Fill),
|
||||||
|
action(
|
||||||
|
"Remove address / decision",
|
||||||
|
Event::DropStaged(facet.clone())
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.spacing(7),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrollable(content).height(305).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||||
|
let mut content = column![text("Exact previous observations").size(16), text("Check only the observations to forget. Resets run before this review's decisions; reset alone changes history, not model values. Uncheck to restore the pending observation. Prose history remains separate.").size(12)].spacing(9);
|
||||||
|
let baseline = self
|
||||||
|
.trace
|
||||||
|
.and_then(|id| project.traces.get(&id))
|
||||||
|
.and_then(|t| t.baseline.as_ref())
|
||||||
|
.and_then(|b| b.semantic.as_ref());
|
||||||
|
if baseline.is_none_or(|b| b.facets.is_empty()) {
|
||||||
|
content =
|
||||||
|
content.push(text("No previous semantic observations on this trace.").size(12));
|
||||||
|
}
|
||||||
|
if let Some(baseline) = baseline {
|
||||||
|
for fact in &baseline.facets {
|
||||||
|
let facet = fact.selector();
|
||||||
|
let mut item = column![
|
||||||
|
text(title(project, &facet)).size(13),
|
||||||
|
text(format!(
|
||||||
|
"Operational: {}\nSystem: {}",
|
||||||
|
observed(project, Some(fact), Layer::Oa),
|
||||||
|
observed(project, Some(fact), Layer::Sa)
|
||||||
|
))
|
||||||
|
.size(11)
|
||||||
|
]
|
||||||
|
.spacing(4);
|
||||||
|
if history::resettable(&facet) {
|
||||||
|
item = item.push(
|
||||||
|
checkbox(self.resets.contains(&facet))
|
||||||
|
.label("Reset this exact observation")
|
||||||
|
.text_size(11)
|
||||||
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
item = item.push(
|
||||||
|
text("Artifact history reset remains pending with artifact review.")
|
||||||
|
.size(11),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
content = content.push(container(item).padding(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pending in &self.resets {
|
||||||
|
if baseline.is_none_or(|b| !b.facets.iter().any(|f| f.selector() == *pending)) {
|
||||||
|
let facet = pending.clone();
|
||||||
|
content = content.push(
|
||||||
|
checkbox(true)
|
||||||
|
.label(format!(
|
||||||
|
"Pending reset no longer in current history: {}",
|
||||||
|
title(project, &facet)
|
||||||
|
))
|
||||||
|
.text_size(11)
|
||||||
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrollable(content).height(305).into()
|
||||||
|
}
|
||||||
|
|
||||||
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||||
let mut content = column![
|
let mut content = column![
|
||||||
text("Reference correspondence · always Operational → System").size(15),
|
text("Reference correspondence · always Operational → System").size(15),
|
||||||
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
|
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires an exact reset in History. Existing decisions are retained; resolve their overlapping row identities explicitly.").size(12),
|
||||||
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
||||||
row![
|
row![
|
||||||
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
||||||
|
|||||||
@@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(220),
|
.width(220),
|
||||||
column![
|
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||||
text(short(&p.name, 46)).size(17),
|
.spacing(3)
|
||||||
label(if app.dirty() {
|
.width(Fill),
|
||||||
"Unsaved changes"
|
|
||||||
} else if app.path.is_some() {
|
|
||||||
"All changes saved"
|
|
||||||
} else {
|
|
||||||
"Local project"
|
|
||||||
})
|
|
||||||
]
|
|
||||||
.spacing(3)
|
|
||||||
.width(Fill),
|
|
||||||
action("New", Message::Request(Pending::New)),
|
action("New", Message::Request(Pending::New)),
|
||||||
|
crate::hosted::view::header_action(),
|
||||||
action("Open", Message::Request(Pending::Open)),
|
action("Open", Message::Request(Pending::Open)),
|
||||||
action("Save as", Message::Save(true)),
|
action("Save as", Message::Save(true)),
|
||||||
button(text("Save all").size(12))
|
button(text(app.save_label()).size(12))
|
||||||
.padding([10, 16])
|
.padding([10, 16])
|
||||||
.on_press(Message::Save(false))
|
.on_press(Message::Save(false))
|
||||||
]
|
]
|
||||||
@@ -744,6 +736,7 @@ fn traces(app: &App) -> View<'_, Message> {
|
|||||||
action("+ Artifact", Message::AddArtifact)
|
action("+ Artifact", Message::AddArtifact)
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
.wrap()
|
||||||
]
|
]
|
||||||
.spacing(16);
|
.spacing(16);
|
||||||
for t in p.traces.values() {
|
for t in p.traces.values() {
|
||||||
|
|||||||
+4
-4
@@ -14,7 +14,7 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|
|||||||
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
||||||
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
|
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
|
||||||
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
|
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
|
||||||
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
|
| Automation and hosted editing | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows; optional desktop catalog/open/publish, explicit conditional Save, server review and local Save as; ETag preconditions, bounded requests and atomic current-revision storage |
|
||||||
|
|
||||||
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
|
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
|
||||||
|
|
||||||
@@ -41,18 +41,18 @@ These are concrete design responses, not claims that every Capella user experien
|
|||||||
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
|
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
|
||||||
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
|
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
|
||||||
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
|
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
|
||||||
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
|
| UI-coupled automation | Renderer-independent core, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
|
||||||
|
|
||||||
## Required first-release work
|
## Required first-release work
|
||||||
|
|
||||||
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Hosted desktop editing is implemented; its remaining native acceptance is tracked in the release plan and current state. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
||||||
|
|
||||||
## Remaining boundaries
|
## Remaining boundaries
|
||||||
|
|
||||||
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
||||||
- Complete Capella communication/scenario variants beyond the specified Studio profile.
|
- Complete Capella communication/scenario variants beyond the specified Studio profile.
|
||||||
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
||||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
|
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. Desktop edits and Undo remain local until Save; revision conflicts require explicit review, and Reload is an undoable local operation. Credentials stay in memory and remote desktop connections require HTTPS.
|
||||||
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
||||||
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
|
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -85,9 +85,41 @@ For a native Linux installation, create an `arcadia` service account, install th
|
|||||||
|
|
||||||
Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe.
|
Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe.
|
||||||
|
|
||||||
## Desktop and automation integration
|
## Edit hosted models in the desktop
|
||||||
|
|
||||||
A desktop or automation client can query current JSON/source, edit with shared typed commands, validate/dry-run, and commit using the original ETag. For hosted editing, keep the user's draft until the service acknowledges the new revision. On `412`, preserve the draft and reload server state for review; a blind replacement retry would discard another client's work.
|
Start a local service from the repository root, then leave it running while using the desktop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --locked -p oa-server --bin arcadia-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The same command works in PowerShell and a Linux shell and uses the platform-local data directory described above. For a custom store, append `-- --data-dir PATH`; choose a local directory outside a synchronized folder. For an installed release, run `.\arcadia-server.exe --data-dir PATH` on Windows or `./arcadia-server --data-dir PATH` on Linux.
|
||||||
|
|
||||||
|
1. In the desktop top bar, choose **Hosted models...**. Enter `http://127.0.0.1:8787` as **Service address**, or your deployed service's HTTPS address. Use the service root, without `/api/v1`; a reverse-proxy path prefix is supported.
|
||||||
|
2. Enter the configured secret in **Bearer token** if required, then choose **Connect / refresh**. Tokens stay in application memory; they are not saved in model/source files. Leave the token empty for an unauthenticated loopback service.
|
||||||
|
3. To share the current local model, choose **Publish as new**. This uploads the complete authored source project, including its entry file, included files and comments, and creates a separate service resource. After acknowledgement, the current project's Save destination becomes that hosted resource; an existing local file is not overwritten.
|
||||||
|
4. To work on an existing resource, search the **Hosted model catalog**, select a model, and choose **Open selected**. **More models** loads another catalog page. Unsaved local work uses the normal save/discard/cancel prompt before opening.
|
||||||
|
5. Edit through diagrams, properties or **Textual model**. Choose **Save to server**, or press Ctrl+S, to validate/apply drafts and send the complete source project. The header shows the last acknowledged hosted revision and whether local changes remain. Edits and Undo/Redo stay local until an explicit Save.
|
||||||
|
|
||||||
|
An invalid source or property draft prevents publication/save and remains available to correct or revert. A rejected server write or failed connection retains the local model and source history. The application does not silently publish the last valid model while dropping an invalid draft. Detached source drafts must be reattached or reverted before hosted Save.
|
||||||
|
|
||||||
|
### Review a newer server version
|
||||||
|
|
||||||
|
An API client or another desktop can edit the same resource. Each Save includes the exact ETag of the desktop's last acknowledged revision. If the server has changed, it rejects that stale Save with `412 revision_conflict`; the desktop retains local work and checks the latest server version for review. Choose **Check server** to perform the same read explicitly, or retry the check after restoring a failed connection.
|
||||||
|
|
||||||
|
The review lists changed, added and removed source files. **View server text** opens a read-only file; **Back to review** returns to the list. Reading the server does not replace local content. **Reload server version** adopts the reviewed bundle as one undoable local change after checking unapplied drafts. Undo can restore the previous accepted local version, but it does not undo the server's revision. There is no automatic merge: inspect and reconcile the intended edits before saving again.
|
||||||
|
|
||||||
|
For a connection failure with an unknown write outcome, use **Check previous write** when offered before another Save. If the server holds exactly the submitted bundle, the desktop acknowledges that save. If it differs, review it. An uncertain publication may already have created a resource; refresh and inspect the catalog before choosing **Publish another copy**.
|
||||||
|
|
||||||
|
### Save locally and manage the connection
|
||||||
|
|
||||||
|
Choose **Save as local...** in Hosted models, or Ctrl+Shift+S, and select an `.arcadia` entry file to retain included files and comments. A successful source-project save switches future Ctrl+S operations to that local destination and leaves the hosted resource unchanged. Choosing JSON exports the model only and keeps the hosted Save destination and unsaved state. Canceling or failing the local save also keeps the hosted destination. **Forget connection** is available after leaving the hosted session and clears the remembered connection/token in memory.
|
||||||
|
|
||||||
|
The desktop accepts HTTPS service URLs and HTTP only for `localhost` or a loopback IP address. Credentials, query parameters and fragments in the URL are rejected; put the bearer token in its own field. Redirects are not followed. For remote access, configure a TLS reverse proxy as described above; the server itself does not provide TLS. To replace an expired token, enter the new value and choose **Connect / refresh** for the same service, then retry the check or Save. Connection settings and tokens are not persisted across application restarts.
|
||||||
|
|
||||||
|
## Automation integration
|
||||||
|
|
||||||
|
Automation clients use the same typed model, validation, authored source and revision preconditions as the desktop. Preserve the submitted draft and original ETag until a write is acknowledged; on `412`, read and reconcile the current version instead of blindly replacing it.
|
||||||
|
|
||||||
Use `POST /api/v1/projects/import-source-project` to import `{"entry":"model.arcadia","files":{"model.arcadia":"..."}}`. Use `GET` and conditional `PUT /api/v1/projects/{id}/source-project` to export or edit that complete authored workspace. These routes preserve comments, line endings, entry names and module layout. Missing declaration IDs are inserted once; keep the accepted bundle returned by PUT or subsequent GET. Candidate validation accepts `{"format":"source-project","source_project":{...}}` and reports diagnostics without adoption.
|
Use `POST /api/v1/projects/import-source-project` to import `{"entry":"model.arcadia","files":{"model.arcadia":"..."}}`. Use `GET` and conditional `PUT /api/v1/projects/{id}/source-project` to export or edit that complete authored workspace. These routes preserve comments, line endings, entry names and module layout. Missing declaration IDs are inserted once; keep the accepted bundle returned by PUT or subsequent GET. Candidate validation accepts `{"format":"source-project","source_project":{...}}` and reports diagnostics without adoption.
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -27,12 +27,13 @@ forms, folding, placement and correct camera/tool lifetime. Windows default/mini
|
|||||||
and Linux native acceptance plus independent reviews are recorded in
|
and Linux native acceptance plus independent reviews are recorded in
|
||||||
release-state-editing.md. Direct scenario/process gestures remain required.
|
release-state-editing.md. Direct scenario/process gestures remain required.
|
||||||
|
|
||||||
R6 data contracts are active in the isolated data integration checkout. Freeze the
|
R6 data contracts and direct scenario/process gestures are integrated; native
|
||||||
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
|
acceptance continues on both platforms. Typed R3 core/source5/REST and scalar review
|
||||||
older formats explicitly while preserving lexical ownership/comments, then verify
|
milestones1–2 are integrated and pass full platform checks. Close independent scalar
|
||||||
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
|
fixes and native review, then finish constructor/keyed/order/reset/artifact and
|
||||||
and the implementation plan in that checkout. R3 semantic drift/reconciliation
|
multi-source graphical authoring using the existing shared core planner. Source5
|
||||||
follows the R6 freeze; its current prose-only behavior is insufficient.
|
migration retains lexical ownership/comments; no flat source rewrite is allowed.
|
||||||
|
See release-data-contract.md, release-semantic-integration.md and release-semantic-gui.md.
|
||||||
|
|
||||||
Continue source authoring improvements only through the production parser/updater.
|
Continue source authoring improvements only through the production parser/updater.
|
||||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||||
|
|||||||
+77
-113
@@ -1,128 +1,92 @@
|
|||||||
# First release — current engineering state
|
# Current release state — 2026-09-06
|
||||||
|
|
||||||
## Objective and current phase
|
The first-release goal remains active and incomplete. The authoritative objective
|
||||||
|
is attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md. PLAN and
|
||||||
|
TASK_LEDGER retain release scope. Do not equate the accepted0.2 prototype or scoped
|
||||||
|
verification with complete OA/SA/source/REST/UI parity.
|
||||||
|
|
||||||
The first-release goal remains active and incomplete: usable Rust/Iced OA/SA,
|
## Checkouts and ownership
|
||||||
equivalent graphical/source/REST semantics, artifact traceability, direct behavior
|
|
||||||
editing, offline guidance, lifecycle/hosted workflows and Windows/Linux evidence.
|
|
||||||
Authoritative objective reread2026-09-06: attachment10134df9-bea2-4c02-a820-67b53d6b86f7.
|
|
||||||
PLAN retains full scope. No subsystem checkpoint completes the release.
|
|
||||||
|
|
||||||
Main is4101434 (documentation pointer on productiona8f451e). Active integration is
|
- Main C:/Users/sorok/OneDrive/Documents/ChatGPT/Capella 2 is the continuation pointer
|
||||||
D:/CodexBuilds/Capella2-data-integration, branchcodex/data-contract-integration,
|
checkout, branch codex/release1-engineering, last pointer04316a7.
|
||||||
production merge60d162c. Root owns this checkout and native input. Its target is
|
- Active integration D:/CodexBuilds/Capella2-data-integration,
|
||||||
D:/CodexBuilds/Capella2-data-target. Linux verification checkout is
|
codex/data-contract-integration, production67ed8e0. Root owns index and data-target.
|
||||||
D:/CodexBuilds/Capella2-data-linux-review at60d162c, targetCapella2-data-linux-target.
|
- Linux D:/CodexBuilds/Capella2-data-linux-review, codex/data-linux-review,
|
||||||
Both targets are currently free; frozen native binaries are separate from targets.
|
43dd566 equivalent source. Root owns index and data-linux-target; currently idle.
|
||||||
|
- SEM D:/CodexBuilds/Capella2-semantic-review, codex/semantic-review,
|
||||||
|
f73551f. semantic_gui_design owns remaining constructor families in semantic_review,
|
||||||
|
minimal forms adapters and its GUI ledger; semantic-desktop-target is exclusive.
|
||||||
|
- HOST D:/CodexBuilds/Capella2-hosted-desktop, codex/hosted-desktop, base1086e54.
|
||||||
|
semantic_integration owns hosted client/lifecycle, Cargo/index and exactly three
|
||||||
|
views.rs header hooks. Its child owns client.rs only. scenario-target is exclusive.
|
||||||
|
- semantic_http_review closed MessageKind review; Temp target and SEM target released.
|
||||||
|
Root is sole native operator. No worker may issue native input.
|
||||||
|
|
||||||
## Previously accepted foundations
|
## Integrated behavior and evidence
|
||||||
|
|
||||||
- Typed OA/SA core, atomic commands/history and explicit typed port migration:
|
R6 data contracts, direct scenario/process editing, source5 typed core reconciliation,
|
||||||
release-port-{contract,core,migration}.md and release-capella-workflow-benchmark.md.
|
REST typed preview/apply and scalar GUI are integrated. Milestone3 has only the
|
||||||
- Explorer grouping/deep hierarchy, contextual port creation, authored routing,
|
MessageKind/TimerExpression constructor group complete; other constructor/keyed/order/
|
||||||
movable labels/anchors, direct exchange drawing, context menus and source tree:
|
reset/artifact/multi-source graphical decisions remain required.
|
||||||
release-explorer-usability.md, release-routing-{verification,linux}.md,
|
|
||||||
release-port-drawing.md and release-context-menus.md.
|
|
||||||
- Modular source/includes with provenance-aware writes, exact source/model history,
|
|
||||||
retained drafts, recovery and coupled hosted revisions: release-source-integration.md.
|
|
||||||
- Direct state creation/movement/transitions/reconnect, typed forms, folding and
|
|
||||||
per-view camera/tool behavior, accepted through316dc55 on Windows/Linux:
|
|
||||||
release-state-editing.md. Earlier full551/554 workspace and strict checks passed;
|
|
||||||
final scoped241 desktop tests/Clippy/build passed on both platforms.
|
|
||||||
|
|
||||||
## Active integrated DATA/scenario/process work
|
Full source5 baseline passed Windows718 and Linux721 workspace tests, one intentional
|
||||||
|
benchmark ignored on each; strict workspace Clippy and optimized desktop/server builds.
|
||||||
|
Real optimized loopback REST checked184 schemas,19 actual typed values and9 malformed
|
||||||
|
rejections. Current67ed8e0/43dd566 adds desktop-only changes:335 complete desktop tests,
|
||||||
|
strict all-target Clippy, whole-workspace fmt and optimized builds pass on both.
|
||||||
|
Linux sessions/logs are recorded in release-semantic-native.md.
|
||||||
|
|
||||||
R6 core/source/API contracts and bounded GUI are integrated with direct scenario
|
Independent scalar review closed3 P2 findings (scoped invalid buffers, combined
|
||||||
and process editors. Data payloads, defaults, inheritance, associations, identified
|
candidate context, historical identity). MessageKind review closed22 tests plus288
|
||||||
interface allocations and qualified message-value paths use source/schema4.
|
independent differential cases with no material finding. Process camera/form-scroll
|
||||||
Source3 native migration preserves comments/nesting until explicit Save All.
|
fixcf33785 and mounted-owner reset8c60008 passed actual Iced red/green regressions,
|
||||||
Evidence: release-data-{contract,implementation,verification}.md.
|
327 desktop platform gates and independent26 observer/process checks. Native final
|
||||||
|
camera/owner verification remains pending; see release-process-scroll-identity.md.
|
||||||
|
|
||||||
ab661f2 full Windows607/Linux610 workspace tests and strict workspace Clippy passed
|
## Native evidence and environment
|
||||||
(one explicit inheritance benchmark ignored per platform). Actual42 server tests
|
|
||||||
and OpenAPI/reST checks passed at R6. Later Windows native data editing, invalid
|
|
||||||
default repair, allocation edits, recursive qualified values, Undo/Redo and clean
|
|
||||||
reopen passed at1440x900 and1100x700. Bounded one-record property browser b35dede
|
|
||||||
passed264 desktop tests/Clippy/build on both platforms and Windows native retest.
|
|
||||||
Linux native R6 acceptance is now underway, not yet passed.
|
|
||||||
|
|
||||||
Direct scenarios: middle and ALT-operand Call/Reply creation, qualified values,
|
Windows scalar e7fa061 trial62786 closed0 normally, empty stderr.1440x900 and1100x700:
|
||||||
valid/invalid reorder, selection, scroll/Escape cancellation, pending-draft guards,
|
mixed Keep/Take/Candidate, invalid default99 retention/repair7, exact source/comment,
|
||||||
Undo/Redo/save/reopen have Windows native evidence. c6c07cc fixes mode feedback,
|
Undo/Redo/Save; Boolean type+default candidate; missing reference mapping rejection,
|
||||||
occurrence-first labels and pinned Create/Cancel; normal/compact native passed.
|
explicit named-type pair and valid Integer1 default; distinct deleted UUID headings;
|
||||||
ae1602a stabilizes the canvas widget position across palette/form replacement.
|
saved reopen; reverse System→OA minimum/default and atomic Undo. See
|
||||||
Root native lower-operand drag opened a form retaining that viewed guard/call;
|
release-semantic-native.md. Working file windows-scalar.arcadia contains the three
|
||||||
Cancel restored the same region and clean model. Logs empty, exec86766 exited0.
|
forward reviews; reverse was undone. Linux and scoped raw-bound clearing remain.
|
||||||
Full evidence and outstanding ALT/reuse/resize/save-recovery tasks:
|
|
||||||
release-scenario-editing.md. A62-object guarded model is saved in the trial copies.
|
|
||||||
|
|
||||||
Direct processes: production8bc564b + evidence1a3164b merged into60d162c. Existing
|
Prior Linux Wayland60d162c actual1440x900: numeric constraints/defaults, recursive
|
||||||
work/exchange/reuse/control contributions, guarded exact-link insertion, reconnect,
|
qualified value browser, middle Call/Reply scenario and direct SA chain, Undo/Redo,
|
||||||
peer reorder, bounded pickers and reusable-source navigation are implemented.
|
source/includes/Save. Windows reopened Linux69-object chain, added named Choice and
|
||||||
Independent Exchange-first continuation defect fixed with explicit Append exchange;
|
Two paths at1100x700, Undo/Redo/Save74objects/13links/5 incomplete-model checks. Details
|
||||||
focused recheck closed. Worker305 desktop tests,19 core checks and strict Clippy/
|
in release-data-verification.md, release-scenario-editing.md, release-process-editing.md.
|
||||||
build passed. Native Windows/Linux process acceptance has not begun. See
|
|
||||||
release-process-editing.md; cross-reference inner links/disconnected exchange-only
|
|
||||||
topology remain explicit current-model boundaries, not full Capella parity claims.
|
|
||||||
|
|
||||||
Combined60d162c: Windows306 desktop tests, strict all-target Clippy and optimized
|
New frozen ui-semantic-groups-{windows,linux}-67ed8e0 include current process fixes
|
||||||
build passed (exec44082 exit0); Linux306/Clippy/release passed (exec83320 exit0).
|
and MessageKind slice. Hashes and source commits in release-semantic-native.md.
|
||||||
Frozen artifacts under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
Windows freeze/model is an isolated74-object process copy. Neither new binary has
|
||||||
- ui-behavior-windows-60d162c/arcadia-studio.exe SHA256
|
been natively operated yet. Re-inventory windows; scalar2099614 is now closed.
|
||||||
0CC1097701B2D750DBF7E50F5CF02F7F7D8F96476047569D0BD684D60BBF4379
|
Original Capella395654 remains available. Do not touch protected user windows.
|
||||||
- ui-behavior-linux-60d162c/arcadia-studio SHA256
|
|
||||||
8CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A
|
|
||||||
Both have model/project.arcadia copied from the saved62-object Windows trial.
|
|
||||||
|
|
||||||
## Isolated semantic review work and important new finding
|
Wayland works with WGPU_BACKEND=vulkan and
|
||||||
|
VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json. Compact WSLg frame resizing
|
||||||
|
is unverified. Optional X11 is blocked by missing libxkbcommon-x11-0; sudo requires
|
||||||
|
user password. User was told to run sudo apt-get install libxkbcommon-x11-0 in Ubuntu.
|
||||||
|
No authentication bypass; Windows/Wayland/REST progress is not blocked.
|
||||||
|
|
||||||
D:/CodexBuilds/Capella2-semantic-review, branchcodex/semantic-review, checkpoint
|
## Exact next actions
|
||||||
d130013. Core346fb24 passed345 core tests (one intentional ignored probe), strict
|
|
||||||
Clippy and independent nine-finding recheck. Source5 persists finite typed facts,
|
|
||||||
historical references, mappings/reset, constructor groups, canonical slot agreement
|
|
||||||
and exact prepared impacts. Normative spec02f99a9 plus clarification29df024 passed
|
|
||||||
strict generation,7 valid/11 invalid example checks and12 source/migration tests.
|
|
||||||
|
|
||||||
User explicitly reconfirmed REST after automatic approval review rejected scope
|
1. Launch current frozen native build; verify preserved Fit/manual scale, remote
|
||||||
twice. The retry succeeded. d130013 implements typed query/preview/apply with
|
scroll through forms, owner-to-owner reset, compact branch labels and toolbar.
|
||||||
source-valid no-write preview, stable generated identities and ETag/base/fingerprint
|
2. Finish Linux scalar and native MessageKind/TimerExpression acceptance, then
|
||||||
checks. All48 server tests/strict Clippy pass;184 schemas,19 actual/typed values
|
remaining advanced OA/process/scenario cases and pending raw-bound cases.
|
||||||
against served/local contracts,9 invalid shapes rejected. Independent optional-
|
3. Integrate independently reviewed remaining constructor/keyed/order/reset/artifact/
|
||||||
input schema mismatch fixed/rechecked. API work is committed; no deployment done.
|
multi-source GUI slices; preserve core prepared plans and exact source history.
|
||||||
|
4. Integrate HOST open/publish/save/conflict lifecycle and operate actual
|
||||||
|
desktop→loopback API→desktop including offline retention and Save as local.
|
||||||
|
5. Complete remaining coverage/filter, Home/recent/recovery, offline help and guided
|
||||||
|
engineering task requirements; fresh final reviews, full platform/MSRV and packaging.
|
||||||
|
|
||||||
New GUI design investigation reproduced a core coherence defect: initially equal
|
Build workflow: cargo test --locked --offline --workspace;
|
||||||
never-reviewed constructors cannot be acknowledged, leaving review permanently
|
cargo clippy --locked --offline --workspace --all-targets -- -D warnings;
|
||||||
Unreviewed after all leaves are accepted. constructor_ack_review now owns a narrow
|
cargo build --locked --offline --release -p oa-desktop -p oa-server.
|
||||||
core/spec fix: first-time equal discriminator acknowledgment only, no descendant
|
Use dedicated targets. WSL Ubuntu24.04 Rust1.98.1 PATH starts
|
||||||
group acknowledgment; true/historical switches retain groups and repeat rejection.
|
/home/sorokinoleg/.cache/arcadia/rust/bin; add --config target/linux-cargo.toml.
|
||||||
No public schema/request change planned. Core targetsemantic-target is worker-owned.
|
No remote push/publication, usage reset or broad target deletion is authorized.
|
||||||
semantic_gui_design owns desktop milestone1–2 and new release-semantic-gui.md in
|
|
||||||
SEM, with dedicated semantic-desktop-target: existing-pair grouped review, exact
|
|
||||||
scalar Keep/Take/Candidate batch and source/history. Structured groups/keyed facts/
|
|
||||||
multi-source/reset/artifacts remain milestone3. Root has not integrated source5 yet.
|
|
||||||
|
|
||||||
## Runtime, environment and next actions
|
|
||||||
|
|
||||||
User reauthorized Computer Use; it works on Windows and WSLg. Root exclusively
|
|
||||||
operates native apps. Current Linux trial exec61870, WSLg window4852212, binary
|
|
||||||
ui-behavior-linux-60d162c. Initial render and model-path entry work; reobserve before
|
|
||||||
input. Window title has WSLg COPY MODE warning; app stderr must be checked before
|
|
||||||
attributing it to application code. Protected old user windows7210312/6228978 were
|
|
||||||
absent; do not modify them if they return. Original Capella395654 remains available.
|
|
||||||
|
|
||||||
1. Complete Linux R6/scenario native tasks and integrated Windows/Linux process
|
|
||||||
tasks, including compact layouts, source save/reopen, failure and cancellation.
|
|
||||||
2. Finish/review equal-discriminator correction; finish scalar GUI and structured
|
|
||||||
GUI milestone3. Integrate source5/core/API/spec into DATA; full platform checks.
|
|
||||||
3. Complete remaining coverage/filters, Home/recent/recovery, hosted ETag desktop
|
|
||||||
workflows, offline help and Capella semantic gaps identified in PLAN.
|
|
||||||
4. Fresh cross-disciplinary end-state review, full Windows/Linux/MSRV/build/runtime
|
|
||||||
verification and reproducible packaging. Update main only with accepted work.
|
|
||||||
|
|
||||||
Meaningful native, implementation and semantic progress continues; no goal blocked
|
|
||||||
or complete status is justified. No remote push/publication/usage reset authorized.
|
|
||||||
No broad recursive target deletion. Use distinct targets and frozen runtime copies.
|
|
||||||
Windows checks: cargo test --locked --offline --workspace; cargo clippy --locked
|
|
||||||
--offline --workspace --all-targets -- -D warnings; cargo build --locked --offline
|
|
||||||
-p oa-desktop -p oa-server. WSL Ubuntu24.04 uses target/linux-cargo.toml, Rust1.98.1
|
|
||||||
in /home/sorokinoleg/.cache/arcadia/rust/bin and Mesa lavapipe for native WSLg.
|
|
||||||
|
|||||||
@@ -86,3 +86,23 @@ validation and independent review after direct user confirmation resolved approv
|
|||||||
New constructor-acknowledgment reachability defect was independently reproduced;
|
New constructor-acknowledgment reachability defect was independently reproduced;
|
||||||
narrow core fix and separate scalar GUI implementation are active in SEM. These
|
narrow core fix and separate scalar GUI implementation are active in SEM. These
|
||||||
are current unresolved work, not accepted release limitations. See current STATE.
|
are current unresolved work, not accepted release limitations. See current STATE.
|
||||||
|
|
||||||
|
## Current evidence superseding earlier in-progress notes — 2026-09-06
|
||||||
|
|
||||||
|
Semantic source5/core/REST and scalar GUI are integrated at d114a5d. Full Windows718/
|
||||||
|
Linux721 tests, strict Clippy, optimized builds and live semantic REST contracts pass.
|
||||||
|
The equal initial discriminator acknowledgment defect is fixed and independently
|
||||||
|
closed. Three scalar GUI P2 fixes5a51a75 are under fresh recheck/integration; structured
|
||||||
|
authoring remains required. Basic Linux R6, middle scenario and guarded chain insertion
|
||||||
|
have actual native evidence in their ledgers; advanced and compact cases remain.
|
||||||
|
X11 lacks libxkbcommon-x11-0, installation requires user's sudo password. User has
|
||||||
|
the exact fix command; Windows/Wayland progress is independent. This environment
|
||||||
|
issue is not evidence of a semantic defect or reason to mark the release blocked.
|
||||||
|
|
||||||
|
2026-09-06: DATA67ed8e0/Linux43dd566 integrates bounded MessageKind/TimerExpression
|
||||||
|
review.335 desktop tests and strict checks/build pass per platform; independent22
|
||||||
|
module tests+288 differential cases close no findings. Windows native scalar now
|
||||||
|
includes named mapping/default, historical identities, reopen and reverse Undo;
|
||||||
|
trial closed0/empty stderr. Exact scope and pending native cases:
|
||||||
|
release-semantic-native.md. Hosted desktop implementation is isolated in
|
||||||
|
Capella2-hosted-desktop; all release completion claims remain pending.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Constructor history correction
|
||||||
|
|
||||||
|
Scope: `oa-core` only, based on HOST `f6a59de85b815fbea5ec578bb79d0a15819c42b4`. No desktop, source grammar, API shape, or OA port semantics changed by the keyed closure correction.
|
||||||
|
|
||||||
|
## Reproduction and decision
|
||||||
|
|
||||||
|
The original independent Boolean-to-Enumeration probe passes OA to SA on an unmodified HOST core. Extending the same fixture to SA to OA reproduces the duplicate `Literal(incoming,incoming)` conflict. New production-core regressions reproduce both the literal and `Event.Signal` member failures before the implementation change; `constructor-closure-red.log` records two failures. This is directional catalog projection, not UUID ordering: a live SA-only selector survives alongside the newly paired selector after a constructor copy.
|
||||||
|
|
||||||
|
Constructor closure now removes that redundant transient one-sided projection when a pair covers it. Established historical one-sided selectors remain, so changing their correspondence still requires an explicit reset. Both-sided historical pairs and removed branch observations retain their existing rules.
|
||||||
|
|
||||||
|
Suppressing the duplicate exposed another reverse-direction defect: target-local literals were lost because their implicit canonical key looked paired even when the source had no member. Preservation now uses observed membership (`true` or an optional row containing a value), with the existing prior source-membership guard preventing resurrection of reviewed removals. This implements the existing language.rst requirement to preserve target-only members when a historical switch reaches the target's current constructor; no normative contract was relaxed.
|
||||||
|
|
||||||
|
The intermediate normalization-only run (`constructor-closure-normalized.log`) passes Signal pairing but fails reverse literal preservation. Three final regressions cover both directions, no-history paired Signal insertion, established one-sided history requiring reset, local literal retention, and previously corresponding removed members staying removed. A separate read-only review checked selector normalization and presence semantics; its deletion-guard test recommendation was implemented.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Dedicated target: `D:/CodexBuilds/Capella2-scenario-target`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||||
|
cargo test --locked --offline -p oa-core --test r3_semantic
|
||||||
|
cargo test --locked --offline -p oa-core
|
||||||
|
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo run --offline --manifest-path C:/Users/sorok/AppData/Local/Temp/capella2-constructor-literal-host-probe/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
All 31 semantic tests and all 356 Windows core tests pass. Strict core all-target Clippy and workspace formatting pass. The independently adapted probe passes both directions against the final implementation (`constructor-independent-green.log`). Full logs: `constructor-core-test.log`, `constructor-core-clippy.log`, `constructor-r3-semantic-green.log` in the dedicated target. Actual GUI/API acceptance and Linux integration remain with the root engineer; this core-only milestone makes no native acceptance claim.
|
||||||
|
|
||||||
|
|
||||||
|
## Separate follow-up: review an existing OA Legacy port
|
||||||
|
|
||||||
|
The shared transition planner previously removed every typed SA port before resolving explicit existing-target mappings. Consequently an existing trace could not Keep or supply a Legacy Candidate in SA-to-OA semantic review: it failed with `Review source is outside selected transition mappings`. Two focused regressions reproduced that boundary on the committed keyed correction (`existing-port-red.log`).
|
||||||
|
|
||||||
|
The language contract permits same-kind Port traces and reviewed updates from either layer, while retaining OA Legacy-only compatibility. The planner now retains only explicitly mapped SA ports whose selected existing OA target is Legacy. It does not synthesize a target, change a port contract, or un-omit dependent SA implementation records. Structural validation still rejects typed OA Take/Candidate proposals. Automatic/new-counterpart omissions remain unchanged.
|
||||||
|
|
||||||
|
Three regressions exercise Keep and Candidate retaining Legacy, unchanged SA source, typed history source/JSON round trips, typed OA proposals failing without mutation, and an explicit new Port counterpart remaining an SA-only omission. A separate read-only review found no scope/invariant defect. Focused red-to-green result: all three pass (`existing-port-green.log`). This change uses the common planner consumed by desktop and REST; it adds no endpoint or transport-specific behavior.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||||
|
cargo test --locked --offline -p oa-core --test r3_semantic reverse_ -- --nocapture
|
||||||
|
cargo test --locked --offline -p oa-core
|
||||||
|
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
All 359 Windows core tests pass, including all 34 semantic tests and existing portless-OA transition coverage. Strict core all-target Clippy and workspace formatting pass. Logs: `existing-port-core-test.log` and `existing-port-core-clippy.log` in the dedicated target. REST preview uses the corrected core planner at `oa-server/src/routes.rs` and commit uses the shared core prepare path; those handlers were inspected, not changed. GUI constructor tests and actual API/native acceptance remain with root integration; no native input or desktop code was changed here.
|
||||||
@@ -135,3 +135,18 @@ launched in WSLg but capture timed out awaiting app access. No Linux native acce
|
|||||||
The user subsequently stopped Computer Use with Escape on the combined Windows
|
The user subsequently stopped Computer Use with Escape on the combined Windows
|
||||||
scenario build's initial capture. All native input stopped; code integration/gates
|
scenario build's initial capture. All native input stopped; code integration/gates
|
||||||
continue. R6 is not accepted until Linux native gates close.
|
continue. R6 is not accepted until Linux native gates close.
|
||||||
|
|
||||||
|
## Root Linux native data trial — 2026-09-06
|
||||||
|
|
||||||
|
Frozen60d162c, Wayland1440x900, ui-behavior-linux-60d162c/model/project.arcadia.
|
||||||
|
ELF SHA2568CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A.
|
||||||
|
Created Retry budget (7fa58d09-f9b4-4ab9-9114-1cadc67bf6e9), Integer0..4.
|
||||||
|
Default9 was rejected with invalid_data_default and retained the draft; correction2
|
||||||
|
saved to the new included sa/types.arcadia. Undo/Redo restored the entire edit.
|
||||||
|
Original Navigation request call8888395e retained Origin/Next/Latitude expression
|
||||||
|
origin.next.latitude +0.5. Destination paging stayed clean and retained its value;
|
||||||
|
browsed Next coordinate, selected Latitude and saved destination.next.latitude +0.25.
|
||||||
|
Exact source contains both independent qualified paths. Native reopen loaded clean65
|
||||||
|
objects after the scenario additions, including Retry budget and recursive values.
|
||||||
|
No Linux association/allocation trial or compact layout acceptance is claimed.
|
||||||
|
Wayland process61870 subsequently closed0 without serious runtime output.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Hosted desktop editing
|
||||||
|
|
||||||
|
Status: implementation, 24 focused tests, all 351 Windows desktop tests, strict desktop all-target Clippy, formatting and optimized build pass. Native and Linux integration acceptance remain with the lead. Base: 1086e54. The lead owns integration, Linux and native acceptance.
|
||||||
|
|
||||||
|
## Contract and ownership
|
||||||
|
|
||||||
|
The optional desktop adapter uses the existing hosted envelope and source-project REST routes. Open compiles and checks the coupled source/model; Publish creates a resource; Save uses the captured opaque ETag. Document remains the sole accepted local history. Complete authored bundles retain comments/includes and Undo stays local until an explicit save. No server, metamodel, persistence or general synchronization framework was added.
|
||||||
|
|
||||||
|
A hosted handle is separate from disk ownership. Successful publication/open attaches it; successful source Save As switches to local ownership. Failed saves and JSON exports preserve the active destination. Requests carry a unique ticket, desktop document identity, local revision and exact compiled snapshot. Stale replies are ignored and late receipts cannot mark newer edits saved or close them.
|
||||||
|
|
||||||
|
Conflicts keep local content and fetch a separate server review. Reload is one undoable accepted change. Readback can acknowledge an uncertain write only when the exact submitted bundle matches; publication is never automatically replayed. Known publication IDs survive lost follow-up reads. Review and reconciliation carry their own connection, including when publishing to a different server. Corrected credentials replace only matching-base handles.
|
||||||
|
|
||||||
|
Credentials are masked, redacted from Debug/errors and kept in memory. URLs reject credentials/query/fragments; remote connections require HTTPS, with HTTP supported on loopback. Redirects/retries are disabled. Request, response, source and time budgets are bounded. The implementation reuses already locked reqwest/Rustls dependencies.
|
||||||
|
|
||||||
|
Owned code: hosted/{mod,client,view}.rs, transport tests, app/hosted.rs and lifecycle tests; narrow App/main/Cargo hooks. The lead explicitly authorized the three views.rs header hooks: hosted status, Hosted models action and active Save label. Other shared UI/property/harness files were untouched.
|
||||||
|
|
||||||
|
## Focused evidence
|
||||||
|
|
||||||
|
Windows, dedicated target D:/CodexBuilds/Capella2-scenario-target:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||||
|
cargo test --locked --offline -p oa-desktop hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
24 tests pass: 13 transport tests and 11 lifecycle tests. Transport uses actual loopback sockets, production oa-server fixtures and bounded fault fixtures. The actual App test consumes Iced Tasks and routes real HTTP completions through App::update: Publish, graphical edit/Save, competing API edit/412, corrected credentials, direct review Reload, Undo/Save, uncertain-write Check, catalog/Open and Save & continue to New. These are runtime/model tests, not native GUI acceptance.
|
||||||
|
|
||||||
|
Fresh independent lifecycle review found stale captured credentials after reconnect. The production fix refreshes matching-base session, uncertain-write and review connections. The actual App regression fails when that refresh is removed and passes when restored; hosted-credentials-red.log records the expected failure. A restored-source timestamp initially reused Cargo's red binary; advancing the source modification time forced the verified rebuild. hosted-focused-test.log records the passing corrected suite. Reviewer re-review found no remaining production defect; its test-coverage suggestion was incorporated with separate direct Reload and uncertain Check paths.
|
||||||
|
|
||||||
|
A first build caught adapter visibility and test cursor API issues, both fixed. A network test originally required connection_failed for a closed Windows port; Windows can instead hit the bounded timeout. The assertion now accepts either certain read failure while checking that the result is not an uncertain mutation.
|
||||||
|
|
||||||
|
## Remaining acceptance
|
||||||
|
|
||||||
|
The final Windows desktop test run passed 351 tests; strict all-target Clippy and formatting passed. Clippy caught an unnecessary test-only PathBuf allocation, which was removed. Hosted inputs now show their disabled state while requests run. The optimized desktop build passed in 2m 46s. Freeze a separate native trial executable with commit/hash. The lead must exercise default/minimum Windows and Linux dialogs, publish/open, graphical and textual save, API artifact changes, conflict/reload/Undo, offline recovery and local Save As. Update guide/server/scope claims that hosted desktop editing is unavailable after integration; this worker does not own those shared documents. Hosted editing remains explicit save/reload, without automatic merges, server history or collaborative synchronization.
|
||||||
|
|
||||||
|
|
||||||
|
## Verified Windows artifact and commands
|
||||||
|
|
||||||
|
Production commit: `6fcc238c5240d35be5e2ef828b16fbf1bd01bdbd` (feature `53a509553daa297a8f602262d0498142010829d4`). The isolated worktree is `D:/CodexBuilds/Capella2-hosted-desktop` on `codex/hosted-desktop`.
|
||||||
|
|
||||||
|
From that checkout, the completed gates were:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||||
|
cargo test --locked --offline -p oa-desktop
|
||||||
|
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo build --locked --offline -p oa-desktop --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The target contains `hosted-desktop-test.log` (351 passed, zero failures/ignored), `hosted-desktop-clippy.log`, `hosted-fmt.log` and `hosted-desktop-build.log`. The transport tests exercised local sockets only; no external deployment or native input occurred.
|
||||||
|
|
||||||
|
Frozen executable: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hosted-windows-6fcc238/arcadia-studio.exe`.
|
||||||
|
|
||||||
|
SHA-256: `51E4C02F1265D3E9B140C2BCF34A8B11E25E5B0D1D32C83E987FEA4BEB4FC061`.
|
||||||
|
|
||||||
|
The release output and frozen copy hashes match. A sibling `manifest.json` records the production commit, gates and pending acceptance. No existing trial artifact was replaced. The assigned scenario target and HOST index are idle and returned to the lead after this evidence checkpoint.
|
||||||
@@ -298,3 +298,14 @@ pass306 desktop tests, strict all-target Clippy and optimized build. New frozen
|
|||||||
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
|
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
|
||||||
exec61870/window4852212 launched and is now under root native test; acceptance
|
exec61870/window4852212 launched and is now under root native test; acceptance
|
||||||
remains pending. Full release scope is unchanged.
|
remains pending. Full release scope is unchanged.
|
||||||
|
|
||||||
|
## Root Linux native scenario trial — 2026-09-06
|
||||||
|
|
||||||
|
Frozen60d162c Wayland1440x900. Native drag in the gap after old reply6 and before
|
||||||
|
Navigation outcome ALT opened Call+correlated Reply while preserving that region.
|
||||||
|
Named Validated route and chose existing Navigation request. Create63→65, Undo65→63,
|
||||||
|
Redo63→65; execution bar ends at its reply. Save All and native reopen passed.
|
||||||
|
Call ef0ef967-041b-4bf2-86b8-977047a3ca90 and correlated reply
|
||||||
|
0005a94e-49c1-41e2-82f9-5a6f285dc604 appear before ALT in saved steps.
|
||||||
|
Model: ui-behavior-linux-60d162c/model/project.arcadia. Linux ALT editing/reuse/
|
||||||
|
reorder/cancellation/minimum layout remain required. Wayland61870 closed0 normally.
|
||||||
|
|||||||
+207
-19
@@ -1,7 +1,8 @@
|
|||||||
# Semantic desktop review — milestones 1 and 2
|
# Semantic desktop review — scalars and closed constructor groups
|
||||||
|
|
||||||
Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI
|
Status: milestones 1–2 and the closed constructor group forms are implemented.
|
||||||
parity, native acceptance or release acceptance.
|
Independent keyed/ordered review, historical pairing reset, multi-source review,
|
||||||
|
full GUI parity, native acceptance and release acceptance remain outstanding.
|
||||||
Base: `d130013`. Dedicated build target:
|
Base: `d130013`. Dedicated build target:
|
||||||
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
|
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
|
||||||
|
|
||||||
@@ -16,9 +17,10 @@ Base: `d130013`. Dedicated build target:
|
|||||||
- Support every applicable scalar leaf through Keep, Take and typed final-value
|
- Support every applicable scalar leaf through Keep, Take and typed final-value
|
||||||
forms. Candidate records contain the complete combined selected result. Identity,
|
forms. Candidate records contain the complete combined selected result. Identity,
|
||||||
alias and layer are protected. The closed core remains the validation boundary.
|
alias and layer are protected. The closed core remains the validation boundary.
|
||||||
- State.Kind is a single structural scalar. Other constructor switches, keyed
|
- State.Kind is a single structural scalar. Data, Port, Occurrence, nested
|
||||||
membership/order/annotation/binding/attachment decisions and pairing resets belong
|
MessageKind and Event support their closed constructor groups and complete
|
||||||
to milestone 3; show them read-only with an honest explanation.
|
typed payloads. Independent keyed membership/order/annotation/binding/attachment
|
||||||
|
decisions and pairing resets remain visibly read-only outside a replacing group.
|
||||||
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
|
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
|
||||||
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
|
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
|
||||||
previously reviewed constructors remain governed by the core's switch rules.
|
previously reviewed constructors remain governed by the core's switch rules.
|
||||||
@@ -36,14 +38,16 @@ Base: `d130013`. Dedicated build target:
|
|||||||
2. Scalar decisions and typed candidates; immutable initial observations; combined
|
2. Scalar decisions and typed candidates; immutable initial observations; combined
|
||||||
final records; exact impact acceptance; atomic document/source commit. Focused
|
final records; exact impact acceptance; atomic document/source commit. Focused
|
||||||
App event/source/history tests, strict Clippy and a dedicated desktop build.
|
App event/source/history tests, strict Clippy and a dedicated desktop build.
|
||||||
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
|
3. All closed constructor forms are implemented, including nested MessageKind.
|
||||||
multi-source review and remaining source/REST parity cases. Still outstanding.
|
Independent keyed and ordered facts, reset, complete artifact lifecycle,
|
||||||
|
multi-source review and remaining source/REST parity cases are outstanding.
|
||||||
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
|
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Verified after the independent scalar review fixes on SEM `6ad3cb`, using only the
|
Scalar fixes were verified on SEM `6ad3cb`; the nested message-kind slice builds
|
||||||
dedicated desktop target (core behavior remains based on `6bb2583`):
|
on scalar commit `5a51a75`. Verification uses only the dedicated desktop target
|
||||||
|
(core behavior remains based on `6bb2583`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
|
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
|
||||||
@@ -52,8 +56,8 @@ cargo clippy --locked -p oa-desktop --all-targets -- -D warnings
|
|||||||
cargo build --locked -p oa-desktop
|
cargo build --locked -p oa-desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
All **277 desktop tests passed**, including **11 original semantic review tests**
|
All **285 desktop tests passed**, including **11 original semantic review tests**,
|
||||||
and **5 scalar review regression tests**.
|
**5 scalar review regression tests**, and **8 nested message-kind tests**.
|
||||||
Strict Clippy and the desktop build passed. The existing migration test's header
|
Strict Clippy and the desktop build passed. The existing migration test's header
|
||||||
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
|
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
|
||||||
|
|
||||||
@@ -85,19 +89,203 @@ The independent scalar review found three gaps, now corrected:
|
|||||||
The regression tests exercise App events, the actual default-form helpers and
|
The regression tests exercise App events, the actual default-form helpers and
|
||||||
button availability, exact source retention before Apply, and one-step undo.
|
button availability, exact source retention before Apply, and one-step undo.
|
||||||
|
|
||||||
|
## Bounded constructor slice
|
||||||
|
|
||||||
|
Occurrence.MessageKind is a complete closed nested family: Asynchronous, Call,
|
||||||
|
Reply, Create, Delete and Timer. It is available only when both traced counterparts
|
||||||
|
are Exchange occurrences. The only constructor-specific payload is the timer
|
||||||
|
expression. This first slice left outer occurrence replacement read-only; the later closed
|
||||||
|
constructor slice below now covers it.
|
||||||
|
|
||||||
|
- Keep/Take/Candidate use the core's switch and first-equal acknowledgment policy.
|
||||||
|
Taking a replacing group copies the typed MessageKind, including its expression.
|
||||||
|
Taking an initial equal discriminator does not copy an unselected timer value.
|
||||||
|
- Candidate MessageKind has its own scoped draft. The independent TimerExpression
|
||||||
|
scalar draft survives group decisions; the group timer's exact raw text, editor
|
||||||
|
cursor and selection survive switching away from Timer and back.
|
||||||
|
- An overlapping kind/timer draft retains both decisions and blocks Apply until
|
||||||
|
explicitly resolved. The dialog offers **Replace timer decision with message-kind
|
||||||
|
group** and **Leave message kind unselected; use timer decision**. First equal-kind
|
||||||
|
acknowledgment can coexist with an independent timer decision. Divergent authored
|
||||||
|
candidate values reject; neither candidate silently overwrites the other.
|
||||||
|
- The kind detail includes baseline/current timer observations and the complete
|
||||||
|
proposed kind. It displays the exact plan-provided acknowledged/retired lists.
|
||||||
|
Exchange, reply-to, lifelines, annotations and pre/postconditions stay separately
|
||||||
|
scoped. Incompatible Timer contracts remain core errors; no dependency or payload
|
||||||
|
is silently removed. Existing additional-impact approval remains in force.
|
||||||
|
- Group changes compose with scalar choices and execute the exact prepared Document
|
||||||
|
batch. Tests cover both directions, all six kinds, mapped reply correlation,
|
||||||
|
historical retirement, repeated no-switch rejection, invalid combinations,
|
||||||
|
protected scopes, stale preview, source failure, comments and one-step undo/redo.
|
||||||
|
- The Traceability contextual toolbar now wraps at the available width so +Artifact
|
||||||
|
remains reachable in the compact layout. This is a single `row.wrap()` change in
|
||||||
|
`views.rs`; other toolbars are outside this slice.
|
||||||
|
|
||||||
|
Root reported native scalar coverage on frozen integrated `e7fa061`: mixed numeric
|
||||||
|
Keep/Take/Candidate, invalid default retention/repair, zero additional changes,
|
||||||
|
atomic Apply, comment/value inspection, Undo/Redo and Save All at 1440×900 and
|
||||||
|
1100×700. Reverse/type/reference/history/reopen native checks were still continuing
|
||||||
|
when this slice was implemented. This task launched no native application.
|
||||||
|
|
||||||
Built executable:
|
Built executable:
|
||||||
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
||||||
The build target is idle. No native application was launched by this task.
|
The build target is idle. No native application was launched by this task.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
Milestone 3 must add explicit constructor closure/replacement, keyed and ordered
|
Milestone 3 must add independent keyed and ordered decisions, historical pairing
|
||||||
decisions, historical pairing reset, complete artifact lifecycle and multi-source
|
reset, complete artifact lifecycle and multi-source review. These facts are currently inspectable, with unsupported writes visibly
|
||||||
review. These facts are currently inspectable, with unsupported writes visibly
|
|
||||||
identified. The reference table supplies explicit mappings for scalar reference
|
identified. The reference table supplies explicit mappings for scalar reference
|
||||||
Take; it does not claim to complete a reset or establish keyed acknowledgments.
|
Take; it does not claim to complete a reset or establish keyed acknowledgments.
|
||||||
|
|
||||||
Fresh independent scalar GUI review and root-owned native verification are still
|
Fresh independent requirements/correctness review and root-owned native checks of
|
||||||
required before merging this milestone into release acceptance. In particular,
|
the message-kind slice are required before release acceptance. Verify the compact
|
||||||
validate the 1100×700 layout, keyboard/focus continuity, long historical values,
|
kind picker, timer text/focus retention, overlap choices, exact closure/retirement,
|
||||||
reference selection, source reopening and prepared-impact inspection in the app.
|
mapped Reply, blocked Timer contract, source reopening and toolbar wrapping.
|
||||||
|
|
||||||
|
|
||||||
|
## Remaining closed constructor families (this slice)
|
||||||
|
|
||||||
|
Owned files: `semantic_review/**`, `semantic_review.rs`, the scoped Draft adapter
|
||||||
|
in `forms.rs`, semantic-only port choices in `forms/port_fields.rs`, and this ledger.
|
||||||
|
No shared traceability header, core, API, native state or source language edits.
|
||||||
|
Base: `f73551f`; same exclusive desktop target.
|
||||||
|
|
||||||
|
| Constructor | Complete group payload | Independent fields |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Data | Definition, bounds, literal members, record supertype, default | Unit, owner and headers |
|
||||||
|
| Port | Contract, function item members, retained legacy direction/interface | Owner and headers |
|
||||||
|
| Occurrence | Body, nested message kind/timer, references, qualified annotations, bindings, branch and step order | Pre/postconditions, owner and headers |
|
||||||
|
| Event | Body, signal item members, time kind/expression or change expression | Owner and headers |
|
||||||
|
| MessageKind | Complete nested kind and timer expression | Other occurrence body properties |
|
||||||
|
|
||||||
|
Each group owns an independent draft, including the nested MessageKind group.
|
||||||
|
Keep/Take/leave-unselected retain all drafts. Constructor picker changes save the
|
||||||
|
old branch value plus its raw inputs and editor state. Switching back restores them.
|
||||||
|
Port input/output changes also carry authored item reference strings, including
|
||||||
|
invalid strings, rather than falling back to stored UUIDs. A Data definition switch
|
||||||
|
leaves its existing default visible until the user deliberately changes or clears
|
||||||
|
it; Port's retained generic association has explicit add/edit/clear controls.
|
||||||
|
|
||||||
|
The same combined candidate projection supplies dependent form context. Only the
|
||||||
|
selected roots are materialized; unrelated invalid scalar/group drafts stay local.
|
||||||
|
Full Take payloads use the public core typed reference visitor, so UUID-shaped prose
|
||||||
|
is never remapped. For historical same-kind Take, the closed adapter retains only
|
||||||
|
current target-present/source-absent rows without a previous source-present
|
||||||
|
observation on that target slot; the core independently
|
||||||
|
checks the supplied final record. No GUI acknowledgment, retirement or impact list
|
||||||
|
is synthesized. The core remains authoritative for those lists and applicability.
|
||||||
|
|
||||||
|
Group details show every baseline/current/proposed group observation, including
|
||||||
|
qualified and historical row identities. Actual preview acknowledged/retired lists
|
||||||
|
remain explicit. A replacing group and selected descendants retain both decisions
|
||||||
|
and provide explicit leave-unselected actions. First-equal discriminator observation
|
||||||
|
can coexist with independent descendant decisions. Conflicting authored values
|
||||||
|
reject instead of silently discarding either draft's edits.
|
||||||
|
|
||||||
|
Array controls support the whole authorized payload, including order, existing
|
||||||
|
owned branches/steps, signal items, annotations and scenario bindings. They do not
|
||||||
|
create referenced semantic objects, change their ownership, delete dependencies,
|
||||||
|
or infer reference correspondences. Incompatible payload/owner combinations stay
|
||||||
|
visible as validation failures. Apply still sends exactly one prepared Document
|
||||||
|
batch, preserving comments, source/model/history agreement and one-step undo/redo.
|
||||||
|
|
||||||
|
New coverage exercises all six Data definitions and companion defaults in both
|
||||||
|
directions; all eight Occurrence and all three Event kinds; all four Port forms;
|
||||||
|
combined owner context; nested and scalar draft retention; explicit overlap;
|
||||||
|
protected fields; mapped record, state, event item and scenario binding payloads;
|
||||||
|
complete owned branch/step order; historical retirement and first-equal scope;
|
||||||
|
90 Data/Event kind-pair and direction comparisons against the core-only preview;
|
||||||
|
source comments/reopen and atomic undo/redo. Full checks and core dependency status
|
||||||
|
will be recorded at freeze. This task launches no native application.
|
||||||
|
|
||||||
|
The delegated keyed-closure fix `43370de` is integrated into SEM as `9c3184b`.
|
||||||
|
Reverse Literal/Member/Binding/Annotation closure and historical local-member
|
||||||
|
preservation now pass the desktop desired-success tests. The separately delegated existing-target Port fix `8a75294` is integrated into
|
||||||
|
SEM as `7512ac8`. Fresh reverse Keep and Candidate reviews can preserve/edit the
|
||||||
|
OA Legacy association; taking a typed contract into OA still rejects. Both fixes
|
||||||
|
are covered by desired-success desktop tests; no GUI workaround weakens validation.
|
||||||
|
|
||||||
|
Native acceptance remains root-owned: use the existing trace Review semantics
|
||||||
|
entry, inspect complete group before/after and retired identities, switch variants
|
||||||
|
away/back, resolve explicit nested overlaps, repair invalid defaults/references,
|
||||||
|
apply once, inspect source, undo/redo, save and reopen at 1100×700 and 1440×900.
|
||||||
|
|
||||||
|
|
||||||
|
## Closed constructor freeze verification
|
||||||
|
|
||||||
|
On integrated SEM base `7512ac8`, all **299 desktop tests pass**. This adds 13
|
||||||
|
constructor workflow tests and one scoped port form regression to the previous
|
||||||
|
285-test slice. The new suite includes 90 complete Data/Event kind-pair/direction
|
||||||
|
comparisons against core previews, all closed candidate kinds, mapped qualified
|
||||||
|
annotation addresses, bindings and references, actual reverse Legacy edits, owned
|
||||||
|
branch/step ordering, source/model/history agreement, retained drafts and strict
|
||||||
|
scope protection. Both delegated core defects are resolved and exercised.
|
||||||
|
|
||||||
|
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and the
|
||||||
|
desktop build all pass using the dedicated target and commands above. Native checks remain root-owned. No native application
|
||||||
|
was launched, and full milestone 3 / GUI parity / release acceptance remain open.
|
||||||
|
|
||||||
|
## Independent unordered membership and exact reset slice
|
||||||
|
|
||||||
|
Current scope: seven unordered reference collections (function input/output items,
|
||||||
|
signal/exchange items, transition triggers/effects, constrained identities) and exact
|
||||||
|
enumeration literals. Stage an explicit OA/SA address, then choose Keep, Take or
|
||||||
|
Candidate presence. New rows never create referenced model objects or infer pairs.
|
||||||
|
Pair changes preserve existing decisions and require explicit history resets and
|
||||||
|
overlap resolution. Literal pairing uses the same exact string on both sides.
|
||||||
|
|
||||||
|
History lists exact previous observations, with reversible pending resets applied
|
||||||
|
before catalog construction, constructor scope and candidate composition. A reset-only
|
||||||
|
Apply uses the same core prepared command batch and atomic source/history undo.
|
||||||
|
Artifact history stays deferred; other read-only value families can have their
|
||||||
|
exact historical observations reset without exposing value editors.
|
||||||
|
|
||||||
|
Independent annotation, binding, ordered/owned/incident/artifact decisions and
|
||||||
|
multi-source review remain later slices. Existing constructor forms remain intact.
|
||||||
|
The member-family picker offers collections available on the current counterparts.
|
||||||
|
Identity pickers reuse the ordinary property editor's typed choices. Presence and
|
||||||
|
all scalar/group drafts remain separate; leaving a decision or removing a staged
|
||||||
|
address retains its raw presence draft. A missing source key means a target-local
|
||||||
|
row that Take preserves; an explicit absent source key can remove only the mapped
|
||||||
|
entry. Existing model objects and unrelated entries remain intact. Previous member
|
||||||
|
identities, deleted UUIDs and quoted exact literals remain distinguishable.
|
||||||
|
|
||||||
|
History and Members use the existing compact scroll area, and the contextual tabs
|
||||||
|
wrap. Users can explicitly remove staged addresses and their decisions or uncheck
|
||||||
|
obsolete pending resets after refresh, without discarding other work. Candidate
|
||||||
|
projection changes only the selected boolean member entries; the core independently
|
||||||
|
checks the complete typed candidate and supplies all acknowledged/retired/impact
|
||||||
|
scope. The GUI never synthesizes accepted history or additional-change commands.
|
||||||
|
|
||||||
|
On base `c149f2f`, all **311 desktop tests pass**, including 12 new workflow tests.
|
||||||
|
The suite covers 30 five-family/direction/Keep-Take-Candidate cases, both typed
|
||||||
|
function-port item families with reverse Legacy applicability, exact literal
|
||||||
|
add/remove and default retention, historical pair migration with explicit overlap
|
||||||
|
resolution, constructor reset before equal-kind acknowledgment, deleted source
|
||||||
|
members and target-local preservation, exact reset-only source/Undo/Redo, source
|
||||||
|
reopen/comments, retained drafts, stale/source-write failures and recovery.
|
||||||
|
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and
|
||||||
|
`cargo build --locked -p oa-desktop` all pass with the dedicated desktop target.
|
||||||
|
No core, API, schema or shared header changes were needed.
|
||||||
|
|
||||||
|
Root-owned native acceptance uses the fixtures mirrored by `keyed_tests.rs`:
|
||||||
|
- Signal pair: source contains a mapped item; destination contains a separate local
|
||||||
|
item. In Members, choose Signal items and both mapped identities, then Take in
|
||||||
|
Properties. Inspect the proposed present row and retained local row; apply, undo,
|
||||||
|
redo and inspect source. Repeat in reverse. Test Candidate exclusion as well.
|
||||||
|
- Enum pair: both contain `retained` with that default; source also contains
|
||||||
|
` New value ` and destination contains `old`. Stage exact paired literals; Take
|
||||||
|
the incoming row, Candidate-remove `old`, Candidate-add `new value`. Inspect the
|
||||||
|
distinct exact strings, unchanged default and one source/history transaction.
|
||||||
|
- Historical pair: an existing one-sided member observation is replaced by an
|
||||||
|
explicitly paired address. Apply stays unavailable until that exact old history
|
||||||
|
is reset and any overlapping earlier decision is explicitly left unselected.
|
||||||
|
Use History for a reset-only Apply; values and prose history stay unchanged, and
|
||||||
|
one Undo restores the exact prior source and semantic history.
|
||||||
|
- At 1100x700, verify wrapped tabs, scrollable member/history content, long/deleted
|
||||||
|
identity labels, retained errors/decisions, staged-address removal, source failure
|
||||||
|
repair and apply/cancel access; repeat a normal transaction at 1440x900.
|
||||||
|
|
||||||
|
No native application was launched here. Independent review, actual native
|
||||||
|
acceptance, later keyed/ordered slices and full GUI parity remain open.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Semantic review native evidence
|
||||||
|
|
||||||
|
This is partial acceptance, not release completion. Windows frozen e7fa061,
|
||||||
|
SHA2563E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34.
|
||||||
|
Trial exec62786/window2099614; stderr/stdout captured under ui-semantic-windows-e7fa061.
|
||||||
|
Source fixture and exact GUI steps: C:/Users/sorok/AppData/Local/Temp/
|
||||||
|
capella2-semantic-native-fixture-20260906. Original source SHA256
|
||||||
|
54BA9528F6904428B3DE2E41E3D8CAC6861FA652E5F83F11C1516FB3DB40E937.
|
||||||
|
Root edited windows-scalar.arcadia, preserving the generator's starting source.
|
||||||
|
|
||||||
|
## Observed Windows workflows
|
||||||
|
|
||||||
|
At1440x900, opened source5,9objects/0links/2 expected pending-prose checks.
|
||||||
|
Traceability range card shows prior bounds0..100/default10 and current OA0..20/5,
|
||||||
|
SA1..30/15. Selected Keep System minimum, Take Operational maximum and final default.
|
||||||
|
Invalid99 rejects with invalid_data_default and disables Apply without losing input
|
||||||
|
or any decision. Resized actual window to1100x700; complete error/editor/actions fit.
|
||||||
|
Repair7 restores valid preview. Additional changes reports0. Apply changes SA to
|
||||||
|
1..20/default7 and preserves OA0..20/default5. Native text inspection shows exact
|
||||||
|
values and leading comment. One Undo returns All changes saved, Redo returns the
|
||||||
|
review, and Save All writes source. Disk inspection matches the native result.
|
||||||
|
|
||||||
|
At1100x700, Enabled flag Type Take Operational proposes Boolean over original
|
||||||
|
System Integer. Default Set final value then Set literal default produces the
|
||||||
|
Boolean checkbox/false value directly. Validated preview and Apply succeed; Save
|
||||||
|
All persists it. This exercises combined type context through actual controls.
|
||||||
|
|
||||||
|
## Remaining checks and findings
|
||||||
|
|
||||||
|
Raw invalid bound scoped clearing and Linux remain required. Error evidence above
|
||||||
|
covers a normal validation message, not arbitrarily large diagnostics.
|
||||||
|
Native found compact Traceability toolbar clips Artifact after the semantic button;
|
||||||
|
semantic_gui_design owns a narrow wrapping correction. No scalar-modal defect found
|
||||||
|
in the tested paths. Structured review is a separate incomplete milestone.
|
||||||
|
|
||||||
|
## Mapping, history, reopen and reverse review — 2026-09-06
|
||||||
|
|
||||||
|
At1100x700, selected Take Operational Type on OA Selected count → SA Selected count.
|
||||||
|
Preview rejected the missing explicit OA Count range correspondence and disabled
|
||||||
|
Apply. Reference pairs OA Count range → SA Count range resolved it. The proposed
|
||||||
|
named type was @sa_count_range; a candidate literal default initialized to Integer1,
|
||||||
|
using the destination range minimum. Apply and Save All persisted both atomically.
|
||||||
|
Disk source confirms Named @sa_count_range and Integer1, with0 model checks.
|
||||||
|
|
||||||
|
Selected the record trace and inspected both historical deleted children. Detail
|
||||||
|
headings distinguish full UUIDs ending2329 and232a, despite identical former names;
|
||||||
|
prior OA presence and current absence are retained. Cancel changed nothing.
|
||||||
|
|
||||||
|
Opened the saved windows-scalar.arcadia through the native file dialog. It reopened
|
||||||
|
with9objects/0links/0checks, original comment, accepted values and reviewed history.
|
||||||
|
On the range trace, switched to System→Operational and included unchanged facts.
|
||||||
|
Previously reviewed/current minimum values were OA0/SA1 and defaults OA5/SA7.
|
||||||
|
Take System minimum and default previewed1/7; Apply selected the OA destination.
|
||||||
|
Native source showed OA default7. One Undo restored OA default5 and All changes
|
||||||
|
saved, retaining the earlier three saved reviews. The reverse exercise was left
|
||||||
|
undone intentionally. The separate windows-reverse.arcadia remains an unused copy.
|
||||||
|
|
||||||
|
Closed trial62786 normally, exit0. Captured stderr was empty. No new scalar defect
|
||||||
|
was found. This still does not establish complete structured review acceptance.
|
||||||
|
|
||||||
|
## Integrated message-kind group verification — 2026-09-06
|
||||||
|
|
||||||
|
SEM f73551f integrates as DATA67ed8e0 and Linux43dd566. Both platforms passed335
|
||||||
|
complete desktop tests, strict all-target desktop Clippy, workspace formatting and
|
||||||
|
optimized desktop build. Core/server are unchanged from the full integrated gates.
|
||||||
|
Independent review on a fresh Temp archive passed22 semantic-review tests and a
|
||||||
|
288-case differential matrix against core (all6 kinds, both directions, baseline
|
||||||
|
and no-baseline, Keep/Take with unrelated Header candidate). No material findings.
|
||||||
|
Temp probe: capella2-message-review-f73551f/semantic_review/message_kind_tests.rs
|
||||||
|
under the desktop crate. Root native checks of the new group remain required.
|
||||||
|
|
||||||
|
Frozen binaries under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||||
|
- ui-semantic-groups-windows-67ed8e0/arcadia-studio.exe, SHA256
|
||||||
|
CC741CC0FE21B7873E545DE7763E74CCF18AB8401D1EEBD8F90DA493B7262DD8.
|
||||||
|
- ui-semantic-groups-linux-67ed8e0/arcadia-studio, SHA256
|
||||||
|
B50BD6462C239C52AEF93FD235FC3652901C519E5289938DD881523357C891D4.
|
||||||
|
|
||||||
|
Windows build/test session97666 and Linux2903 exited0. Linux logs are
|
||||||
|
message-kind-{test,clippy,build}.log in Capella2-data-linux-target. Frozen manifests
|
||||||
|
record source/platform/hash. The Windows freeze contains an isolated copy of the
|
||||||
|
74-object process fixture for native camera, owner-scroll and branch checks.
|
||||||
Reference in New Issue
Block a user