Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ab2b1feb | ||
|
|
8ac9f464ea |
Generated
-6
@@ -2155,16 +2155,10 @@ dependencies = [
|
||||
name = "oa-desktop"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"iced",
|
||||
"iced_runtime",
|
||||
"oa-core",
|
||||
"oa-server",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"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:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
@@ -53,8 +53,6 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
|
||||
|
||||
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
|
||||
|
||||
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
@@ -72,7 +70,7 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 4;
|
||||
arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -102,7 +100,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
||||
|
||||
## Save and reopen
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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,18 +116,14 @@ 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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -146,4 +140,4 @@ cargo build --release --workspace --locked
|
||||
- `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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -507,7 +507,6 @@ fn workshop() -> Project {
|
||||
let baseline = TraceBaseline {
|
||||
oa: p.properties(t.oa).unwrap(),
|
||||
sa: p.properties(t.sa).unwrap(),
|
||||
semantic: None,
|
||||
};
|
||||
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||
#[path = "language/semantic_source.rs"]
|
||||
mod semantic_source;
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -389,7 +387,7 @@ fn enum_field(key: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
|
||||
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
@@ -554,7 +552,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -745,10 +743,6 @@ fn write_value(
|
||||
indent: usize,
|
||||
aliases: &BTreeMap<Id, String>,
|
||||
) {
|
||||
if key == "semantic" {
|
||||
semantic_source::write(out, value, indent);
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
||||
@@ -792,7 +786,6 @@ enum Ast {
|
||||
Literal(Value, usize),
|
||||
Word(String, usize),
|
||||
Reference(String, usize),
|
||||
HistoricalUuid(Id, usize),
|
||||
Array(Vec<Ast>, usize),
|
||||
Object(BTreeMap<String, Ast>, usize),
|
||||
}
|
||||
@@ -802,20 +795,12 @@ impl Ast {
|
||||
Self::Literal(_, p)
|
||||
| Self::Word(_, p)
|
||||
| Self::Reference(_, p)
|
||||
| Self::HistoricalUuid(_, p)
|
||||
| Self::Array(_, p)
|
||||
| Self::Object(_, p) => *p,
|
||||
}
|
||||
}
|
||||
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
|
||||
match self {
|
||||
Self::HistoricalUuid(_, _) => Err(Error {
|
||||
code: "invalid_source",
|
||||
offset: self.offset(),
|
||||
message:
|
||||
"A uuid literal is allowed only in a typed historical semantic reference slot."
|
||||
.into(),
|
||||
}),
|
||||
Self::Word(word, offset) => {
|
||||
if !enum_field(key) {
|
||||
return Err(Error {
|
||||
@@ -983,7 +968,6 @@ pub(crate) struct EditValue {
|
||||
pub(crate) enum EditKind {
|
||||
Atom,
|
||||
Reference(String),
|
||||
HistoricalUuid(Id),
|
||||
Record(BTreeMap<String, EditField>),
|
||||
List(Vec<EditValue>),
|
||||
}
|
||||
@@ -1096,7 +1080,7 @@ fn edit_positions(
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
end: match ast {
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
||||
Ast::Reference(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
};
|
||||
@@ -1106,7 +1090,6 @@ fn edit_positions(
|
||||
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
||||
}
|
||||
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
||||
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
|
||||
_ => EditKind::Atom,
|
||||
};
|
||||
EditValue { span, kind }
|
||||
@@ -1287,16 +1270,6 @@ impl Parser<'_> {
|
||||
let token = self.current();
|
||||
let offset = token.span.start;
|
||||
match self.text() {
|
||||
"uuid" => {
|
||||
self.cursor += 1;
|
||||
let value = self.quoted()?;
|
||||
let id = Id::parse_str(&value).map_err(|_| Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Expected a UUID after uuid.".into(),
|
||||
})?;
|
||||
Ok(Ast::HistoricalUuid(id, offset))
|
||||
}
|
||||
"@" => {
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Reference(self.identifier()?, offset))
|
||||
@@ -1383,11 +1356,11 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5") {
|
||||
if !matches!(self.text(), "3" | "4") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
|
||||
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
let schema_version = if self.text() == "3" { 3 } else { 4 };
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
@@ -1584,12 +1557,7 @@ pub(crate) fn resolve_declarations(
|
||||
let mut fields = declaration
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
|
||||
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
|
||||
} else { v.resolve(k, aliases)? };
|
||||
Ok((k.clone(), value))
|
||||
})
|
||||
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
|
||||
.collect::<Result<Map<_, _>, Error>>()?;
|
||||
let d = declaration;
|
||||
let conversion_error = |message: String| Error {
|
||||
@@ -1761,12 +1729,8 @@ pub(crate) fn resolve_declarations(
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = if project.schema_version < 5 {
|
||||
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
|
||||
Trace::from(old)
|
||||
} else {
|
||||
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
|
||||
};
|
||||
let trace = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
project.traces.insert(d.id, trace);
|
||||
}
|
||||
}
|
||||
@@ -1837,22 +1801,6 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 {
|
||||
return crate::persistence::v4::finish(project, limits).map_err(|error| {
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset: 0,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
});
|
||||
}
|
||||
limits.check_model(&project).map_err(|message| {
|
||||
(
|
||||
usize::MAX,
|
||||
@@ -2647,8 +2595,3 @@ pub(crate) mod legacy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed historical facet identity for the targeted source writer.
|
||||
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
semantic_source::row_key(source, origins)
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
//! Typed historical source leaves; live alias resolution remains in the parent.
|
||||
use super::*;
|
||||
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
|
||||
|
||||
fn error(ast: &Ast, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: ast.offset(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(ast: &Ast) -> Result<Value, Error> {
|
||||
Ok(match ast {
|
||||
Ast::Literal(value, _) => value.clone(),
|
||||
Ast::Word(value, _) => Value::String(value.clone()),
|
||||
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
|
||||
Ast::Reference(_, _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"Historical semantic references require uuid \"...\", not @alias.",
|
||||
));
|
||||
}
|
||||
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
|
||||
Ast::Object(fields, _) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
|
||||
.collect::<Result<_, Error>>()?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_tokens(
|
||||
ast: &Ast,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) -> Result<(), Error> {
|
||||
match ast {
|
||||
Ast::Object(fields, _) => {
|
||||
for (key, value) in fields {
|
||||
path.push(key.clone());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Ast::Array(values, _) => {
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
path.push(index.to_string());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => match (slots.get(path), ast) {
|
||||
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
|
||||
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
|
||||
(Some(HistoricalSourceKind::Reference), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical reference requires uuid \"...\".",
|
||||
));
|
||||
}
|
||||
(Some(HistoricalSourceKind::Enum), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This typed historical enum requires an unquoted enum word.",
|
||||
));
|
||||
}
|
||||
(None, Ast::HistoricalUuid(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"A uuid literal is allowed only in a typed historical reference slot.",
|
||||
));
|
||||
}
|
||||
(None, Ast::Word(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical text value requires a quoted string.",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn resolve_baseline(
|
||||
ast: &Ast,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
version: u32,
|
||||
) -> Result<Value, Error> {
|
||||
let Ast::Object(fields, _) = ast else {
|
||||
return ast.resolve("baseline", aliases);
|
||||
};
|
||||
let mut resolved = Map::new();
|
||||
for (key, value) in fields {
|
||||
if key != "semantic" {
|
||||
resolved.insert(key.clone(), value.resolve(key, aliases)?);
|
||||
continue;
|
||||
}
|
||||
if version < 5 {
|
||||
return Err(error(
|
||||
value,
|
||||
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
|
||||
));
|
||||
}
|
||||
if matches!(value, Ast::Literal(Value::Null, _)) {
|
||||
resolved.insert(key.clone(), Value::Null);
|
||||
continue;
|
||||
}
|
||||
let json = raw(value)?;
|
||||
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
|
||||
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
validate_tokens(value, &mut vec![], &slots)?;
|
||||
resolved.insert(key.clone(), json);
|
||||
}
|
||||
Ok(Value::Object(resolved))
|
||||
}
|
||||
|
||||
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
|
||||
if value.is_null() {
|
||||
out.push_str("none");
|
||||
return;
|
||||
}
|
||||
let baseline: SemanticBaseline =
|
||||
serde_json::from_value(value.clone()).expect("typed semantic baseline");
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
write_at(out, value, indent, &mut vec![], &slots);
|
||||
}
|
||||
|
||||
fn write_at(
|
||||
out: &mut String,
|
||||
value: &Value,
|
||||
indent: usize,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) {
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => out.push_str(&value.to_string()),
|
||||
Value::String(value) => match slots.get(path) {
|
||||
Some(HistoricalSourceKind::Reference) => {
|
||||
out.push_str("uuid ");
|
||||
out.push_str("e(value));
|
||||
}
|
||||
Some(HistoricalSourceKind::Enum) => out.push_str(value),
|
||||
None => out.push_str("e(value)),
|
||||
},
|
||||
Value::Array(values) => {
|
||||
out.push('[');
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
path.push(index.to_string());
|
||||
write_at(out, value, indent, path, slots);
|
||||
path.pop();
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
out.push_str("{\n");
|
||||
for (key, value) in fields {
|
||||
out.push_str(&" ".repeat(indent + 1));
|
||||
out.push_str(key);
|
||||
out.push(' ');
|
||||
path.push(key.clone());
|
||||
write_at(out, value, indent + 1, path, slots);
|
||||
path.pop();
|
||||
out.push_str(";\n");
|
||||
}
|
||||
out.push_str(&" ".repeat(indent));
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize only complete typed facet rows. Their closed selector, with real
|
||||
/// identity provenance applied, owns comments when review rows reorder.
|
||||
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
let mut parser = Parser {
|
||||
source,
|
||||
tokens: strict_tokens(source).ok()?,
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits: ParseLimits::UNLIMITED,
|
||||
};
|
||||
let ast = parser.value().ok()?;
|
||||
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
||||
let mut baseline = SemanticBaseline {
|
||||
facets: vec![facet],
|
||||
};
|
||||
crate::semantic::visit_semantic_references(&mut baseline, |id| {
|
||||
if let Some(original) = origins.get(id) {
|
||||
*id = *original;
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&baseline.facets[0].selector())
|
||||
.ok()
|
||||
.map(|key| format!("semantic:{key}"))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
|
||||
|
||||
fn project_source(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"children.arcadia".into(),
|
||||
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
|
||||
|
||||
#[test]
|
||||
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
|
||||
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let analysis = analyze(source);
|
||||
let ready = analysis
|
||||
.declarations
|
||||
|
||||
@@ -7,7 +7,6 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
@@ -15,7 +14,6 @@ pub mod transitions;
|
||||
pub mod validation;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
mod state_validation;
|
||||
|
||||
@@ -13,9 +13,7 @@ generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
|
||||
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
|
||||
then performs the data-contract upgrade into the strict schema 4 envelope. The
|
||||
schema 4 review adapter preserves its prose-only trace baselines and returns
|
||||
schema 5 with semantic review absent. Serialization
|
||||
then performs the data-contract upgrade and validates schema 4. Serialization
|
||||
between compatibility DTOs copies unchanged fields; original input bytes are
|
||||
never deserialized into the new `Project`. V1 normalization also constructs this
|
||||
V2 DTO before the shared stages. Combined generation stays within one budget.
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) struct Project3 {
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -486,10 +486,8 @@ pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult
|
||||
));
|
||||
}
|
||||
}
|
||||
value["schema_version"] = 4.into();
|
||||
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
|
||||
let mut project = normalized.into_current()?;
|
||||
crate::persistence::v4::review_defaults(&project, &mut report);
|
||||
value["schema_version"] = SCHEMA_VERSION.into();
|
||||
let mut project: Project = serde_json::from_value(value)?;
|
||||
let mut references = project
|
||||
.elements
|
||||
.values()
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TraceBaseline4 {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Trace4 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub oa: Id,
|
||||
pub sa: Id,
|
||||
pub kind: TraceKind,
|
||||
pub baseline: Option<TraceBaseline4>,
|
||||
}
|
||||
impl From<Trace4> for Trace {
|
||||
fn from(old: Trace4) -> Self {
|
||||
Self {
|
||||
id: old.id,
|
||||
alias: old.alias,
|
||||
name: old.name,
|
||||
description: old.description,
|
||||
oa: old.oa,
|
||||
sa: old.sa,
|
||||
kind: old.kind,
|
||||
baseline: old.baseline.map(|baseline| TraceBaseline {
|
||||
oa: baseline.oa,
|
||||
sa: baseline.sa,
|
||||
semantic: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project4 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace4>,
|
||||
}
|
||||
impl Project4 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
if self.schema_version != 4 {
|
||||
return Err(FileError::Invalid("Expected schema_version 4.".into()));
|
||||
}
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self
|
||||
.traces
|
||||
.into_iter()
|
||||
.map(|(id, trace)| (id, trace.into()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
report.semantic_defaults.extend(
|
||||
project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| trace.baseline.is_some())
|
||||
.map(|trace| SemanticDefault {
|
||||
object: trace.id,
|
||||
field: "baseline.semantic".into(),
|
||||
description: "Original prose review preserved; semantic facets remain unreviewed."
|
||||
.into(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(4);
|
||||
review_defaults(&project, &mut report);
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
finish(old.into_current()?, limits)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -1492,15 +1492,13 @@ pub struct TracedProperties {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraceBaseline {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub semantic: Option<crate::semantic::SemanticBaseline>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Trace {
|
||||
pub id: Id,
|
||||
|
||||
@@ -7,8 +7,6 @@ pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
#[path = "migration_v3/mod.rs"]
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -150,14 +148,13 @@ pub fn decode_with_report_limited(
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => DecodeResult {
|
||||
Some(4) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +222,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
if t.sa == old {
|
||||
t.sa = new;
|
||||
}
|
||||
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||
crate::semantic::visit_semantic_references(baseline, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//! Closed typed facts for deliberately reviewed OA/SA semantics.
|
||||
//!
|
||||
//! Historical facts are independently acknowledged observations, not a synthetic
|
||||
//! live model. Their UUIDs are not live references and survive ordinary deletion.
|
||||
mod catalog;
|
||||
mod historical;
|
||||
mod linked;
|
||||
mod mutate;
|
||||
mod review;
|
||||
mod types;
|
||||
pub use catalog::*;
|
||||
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
|
||||
pub use historical::{validate_semantic_baseline, visit_semantic_references};
|
||||
pub use linked::*;
|
||||
pub use review::*;
|
||||
pub use types::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,404 +0,0 @@
|
||||
use super::types::*;
|
||||
use crate::model::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HistoricalSourceKind {
|
||||
Reference,
|
||||
Enum,
|
||||
}
|
||||
|
||||
const MAX_FACTS: usize = 16_384;
|
||||
const MAX_LEAVES: usize = 1_000_000;
|
||||
const MAX_PATH: usize = 128;
|
||||
|
||||
struct Walk<'a> {
|
||||
path: Vec<String>,
|
||||
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
|
||||
errors: Vec<String>,
|
||||
leaves: usize,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
}
|
||||
impl Walk<'_> {
|
||||
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
|
||||
if self.leaves >= MAX_LEAVES {
|
||||
return;
|
||||
}
|
||||
self.leaves += 1;
|
||||
self.path.push(key.to_string());
|
||||
f(self);
|
||||
self.path.pop();
|
||||
}
|
||||
fn slot(&mut self, kind: HistoricalSourceKind) {
|
||||
self.slots.push((self.path.clone(), kind));
|
||||
}
|
||||
fn error(&mut self, message: &str) {
|
||||
self.errors
|
||||
.push(format!("{}: {message}", self.path.join(".")));
|
||||
}
|
||||
}
|
||||
trait Historical {
|
||||
fn walk(&mut self, w: &mut Walk<'_>);
|
||||
}
|
||||
impl Historical for Id {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.slot(HistoricalSourceKind::Reference);
|
||||
(w.visitor)(self);
|
||||
}
|
||||
}
|
||||
macro_rules! plain {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
|
||||
}
|
||||
plain!(bool, u32, i64);
|
||||
impl Historical for String {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !crate::validation::valid_text(self) {
|
||||
w.error("historical text contains an invalid character");
|
||||
}
|
||||
if self.len() > 1_000_000 {
|
||||
w.error("historical text work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for f64 {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !self.is_finite() {
|
||||
w.error("historical real must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! enums {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
|
||||
} )* };
|
||||
}
|
||||
enums!(
|
||||
ExchangeItemKind,
|
||||
ExchangeMechanism,
|
||||
ParameterDirection,
|
||||
FieldStorage,
|
||||
ComponentPortKind,
|
||||
ComponentPortOrientation,
|
||||
PortDirection,
|
||||
ScenarioViewpoint,
|
||||
ControlKind,
|
||||
FragmentOperator,
|
||||
TimeEventKind,
|
||||
StateKind,
|
||||
TransmissionProtocol,
|
||||
AcquisitionProtocol,
|
||||
TransitionKind,
|
||||
StateActionKind,
|
||||
ArtifactRelation,
|
||||
ReferenceCollection,
|
||||
OrderedCollection,
|
||||
IncidentEnd,
|
||||
DataConstructor,
|
||||
PortConstructor,
|
||||
OccurrenceConstructor,
|
||||
EventConstructor,
|
||||
MessageConstructor
|
||||
);
|
||||
impl<T: Historical> Historical for Option<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if let Some(v) = self {
|
||||
v.walk(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Vec<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
|
||||
w.at(i, |w| v.walk(w));
|
||||
}
|
||||
if self.len() > MAX_LEAVES {
|
||||
w.error("historical collection work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Observed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
|
||||
Self::Value(v) => w.at("Value", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Reviewed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if matches!(
|
||||
(&self.oa, &self.sa),
|
||||
(Observed::NotApplicable, Observed::NotApplicable)
|
||||
) {
|
||||
w.error("a reviewed facet must apply on at least one side");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for KeyPair<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.oa.is_none() && self.sa.is_none() {
|
||||
w.error("key pair cannot be empty on both sides");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for AnnotationKey {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for MessageValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
w.at("expression", |w| self.expression.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for LifelineBinding {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("referenced", |w| self.referenced.walk(w));
|
||||
w.at("local", |w| self.local.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for DataValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Real(value) => w.at("Real", |w| value.walk(w)),
|
||||
Self::String(value) => w.at("String", |w| value.walk(w)),
|
||||
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
|
||||
Self::Boolean(_) | Self::Integer(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for TypeRef {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Named(id) => w.at("Named", |w| id.walk(w)),
|
||||
_ => w.slot(HistoricalSourceKind::Enum),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for ArtifactReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
|
||||
Self::Name(v) => w.at("Name", |w| v.walk(w)),
|
||||
Self::Description(v) => w.at("Description", |w| v.walk(w)),
|
||||
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
|
||||
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
|
||||
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
|
||||
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
|
||||
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! family_visitor {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
impl Historical for FacetReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
|
||||
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, review } => w.at("Member", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Order { collection, review } => w.at("Order", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
|
||||
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
|
||||
}}
|
||||
}),
|
||||
Self::Literal { keys, review } => w.at("Literal", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Annotation { keys, review } => w.at("Annotation", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Binding { keys, review } => w.at("Binding", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Child { keys, review } => w.at("Child", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Incident { keys, end, review } => w.at("Incident", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Artifact { keys, review } => w.at("Artifact", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(family_visitor);
|
||||
|
||||
fn walk_baseline<'a>(
|
||||
baseline: &mut SemanticBaseline,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
) -> Walk<'a> {
|
||||
let mut walk = Walk {
|
||||
path: vec!["facets".into()],
|
||||
slots: vec![],
|
||||
errors: vec![],
|
||||
leaves: 0,
|
||||
visitor,
|
||||
};
|
||||
if baseline.facets.len() > MAX_FACTS {
|
||||
walk.error("historical facet count limit exceeded");
|
||||
}
|
||||
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
|
||||
walk.at(i, |w| facet.walk(w));
|
||||
}
|
||||
if walk.leaves >= MAX_LEAVES {
|
||||
walk.error("historical reference work limit exceeded");
|
||||
}
|
||||
walk
|
||||
}
|
||||
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
|
||||
pub fn visit_semantic_references(
|
||||
baseline: &mut SemanticBaseline,
|
||||
mut visitor: impl FnMut(&mut Id),
|
||||
) {
|
||||
walk_baseline(baseline, &mut visitor);
|
||||
}
|
||||
pub(crate) fn historical_source_slots(
|
||||
baseline: &SemanticBaseline,
|
||||
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
|
||||
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
|
||||
}
|
||||
/// Checks persisted facts without demanding that historical references still exist.
|
||||
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
|
||||
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
|
||||
let mut slots = BTreeSet::new();
|
||||
for selector in baseline
|
||||
.facets
|
||||
.iter()
|
||||
.take(MAX_FACTS)
|
||||
.map(FacetReview::selector)
|
||||
{
|
||||
if selector_slots(&selector)
|
||||
.into_iter()
|
||||
.any(|slot| !slots.insert(slot))
|
||||
{
|
||||
errors.push(format!(
|
||||
"duplicate or overlapping historical facet: {selector:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
|
||||
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
|
||||
let mut result = vec![];
|
||||
if let Some(oa) = &keys.oa {
|
||||
result.push(KeyPair {
|
||||
oa: Some(oa.clone()),
|
||||
sa: None,
|
||||
});
|
||||
}
|
||||
if let Some(sa) = &keys.sa {
|
||||
result.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(sa.clone()),
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { collection, keys } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Member {
|
||||
collection: *collection,
|
||||
keys,
|
||||
})
|
||||
.collect(),
|
||||
FacetSelector::Literal(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Literal)
|
||||
.collect(),
|
||||
FacetSelector::Annotation(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Annotation)
|
||||
.collect(),
|
||||
FacetSelector::Binding(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Binding)
|
||||
.collect(),
|
||||
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
|
||||
FacetSelector::Incident { keys, end } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Incident { keys, end: *end })
|
||||
.collect(),
|
||||
FacetSelector::Artifact { keys, facet } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Artifact {
|
||||
keys,
|
||||
facet: *facet,
|
||||
})
|
||||
.collect(),
|
||||
value => vec![value.clone()],
|
||||
}
|
||||
}
|
||||
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
|
||||
a.oa.as_ref()
|
||||
.zip(b.oa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
|| a.sa
|
||||
.as_ref()
|
||||
.zip(b.sa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
}
|
||||
/// Slot identity is each actual side's key, not equality of the complete pair.
|
||||
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||
match (a, b) {
|
||||
(
|
||||
FacetSelector::Member {
|
||||
collection: a,
|
||||
keys: ak,
|
||||
},
|
||||
FacetSelector::Member {
|
||||
collection: b,
|
||||
keys: bk,
|
||||
},
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
|
||||
(
|
||||
FacetSelector::Incident { keys: ak, end: a },
|
||||
FacetSelector::Incident { keys: bk, end: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(
|
||||
FacetSelector::Artifact { keys: ak, facet: a },
|
||||
FacetSelector::Artifact { keys: bk, facet: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::Drift;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LinkedSemanticImpact {
|
||||
pub objects: KeyPair<Id>,
|
||||
pub traces: Vec<Id>,
|
||||
pub drift: Drift,
|
||||
pub unreviewed: bool,
|
||||
}
|
||||
const MAX_LINKED_OBJECTS: usize = 4_096;
|
||||
const MAX_LINKED_EDGES: usize = 32_768;
|
||||
|
||||
/// Derived dependency diagnostics, never another historical baseline. Follow
|
||||
/// direct references/children, then only reusable dictionary/event definitions.
|
||||
/// Association endpoints and ordinary architecture neighborhoods do not expand.
|
||||
pub fn linked_semantic_impacts(
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
) -> Result<Vec<LinkedSemanticImpact>, String> {
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for element in project.elements.values() {
|
||||
if let Some(parent) = element.parent {
|
||||
children.entry(parent).or_default().push(element.id);
|
||||
}
|
||||
}
|
||||
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for relation in project.relations.values() {
|
||||
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
|
||||
contracts.entry(source).or_default().push(relation.id);
|
||||
}
|
||||
}
|
||||
let direct = |id| -> Vec<Id> {
|
||||
let mut out = vec![];
|
||||
if let Some(element) = project.elements.get(&id) {
|
||||
out.extend(
|
||||
crate::references::element_references(element)
|
||||
.into_iter()
|
||||
.filter(|r| Some(*r) != element.parent),
|
||||
);
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
out.extend(crate::references::relation_references(relation));
|
||||
}
|
||||
out.extend(children.get(&id).into_iter().flatten().copied());
|
||||
out.extend(contracts.get(&id).into_iter().flatten().copied());
|
||||
out
|
||||
};
|
||||
let expandable = |id| {
|
||||
project.elements.get(&id).is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::ExchangeItem
|
||||
| Kind::Interface
|
||||
| Kind::Event
|
||||
)
|
||||
}) || project
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
|
||||
};
|
||||
let mut edges = 0usize;
|
||||
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let first = direct(root);
|
||||
edges += first.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
|
||||
);
|
||||
}
|
||||
let mut queue: VecDeque<_> = first.into();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if expandable(id) {
|
||||
let next = direct(id);
|
||||
edges += next.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
queue.extend(next);
|
||||
}
|
||||
}
|
||||
seen.remove(&root);
|
||||
Ok(seen)
|
||||
};
|
||||
let oa = traverse(trace.oa)?;
|
||||
let sa = traverse(trace.sa)?;
|
||||
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
|
||||
for t in project.traces.values() {
|
||||
traced.entry((t.oa, t.sa)).or_default().push(t);
|
||||
}
|
||||
let mut pairs = vec![];
|
||||
let mut used = BTreeSet::new();
|
||||
for id in oa {
|
||||
let target = references.get(&id).copied();
|
||||
if let Some(id) = target {
|
||||
used.insert(id);
|
||||
}
|
||||
pairs.push(KeyPair {
|
||||
oa: Some(id),
|
||||
sa: target,
|
||||
});
|
||||
}
|
||||
for id in sa.difference(&used) {
|
||||
pairs.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(*id),
|
||||
});
|
||||
}
|
||||
let mut result = vec![];
|
||||
for objects in pairs {
|
||||
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
|
||||
continue;
|
||||
}
|
||||
let links = objects
|
||||
.oa
|
||||
.zip(objects.sa)
|
||||
.and_then(|pair| traced.get(&pair));
|
||||
let mut flags = (false, false, links.is_none());
|
||||
let mut ids = vec![];
|
||||
for linked in links.into_iter().flatten() {
|
||||
ids.push(linked.id);
|
||||
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
|
||||
if facts.is_empty() {
|
||||
flags.2 = true;
|
||||
}
|
||||
for fact in facts {
|
||||
match fact.drift {
|
||||
Drift::OaChanged => flags.0 = true,
|
||||
Drift::SaChanged => flags.1 = true,
|
||||
Drift::BothChanged => {
|
||||
flags.0 = true;
|
||||
flags.1 = true;
|
||||
}
|
||||
Drift::Unreviewed => flags.2 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let drift = match flags {
|
||||
(true, true, _) => Drift::BothChanged,
|
||||
(true, false, _) => Drift::OaChanged,
|
||||
(false, true, _) => Drift::SaChanged,
|
||||
(false, false, true) => Drift::Unreviewed,
|
||||
_ => Drift::Unchanged,
|
||||
};
|
||||
result.push(LinkedSemanticImpact {
|
||||
objects,
|
||||
traces: ids,
|
||||
drift,
|
||||
unreviewed: flags.2,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
use super::catalog::{collection, order_collection, sides, write_scalar};
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::TransitionDirection;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) fn set_collection(
|
||||
p: &mut Project,
|
||||
id: Id,
|
||||
which: ReferenceCollection,
|
||||
list: Vec<Id>,
|
||||
) -> Result<(), String> {
|
||||
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
|
||||
return Err("Collection contains duplicate members".into());
|
||||
}
|
||||
use ReferenceCollection as C;
|
||||
if which == C::Steps {
|
||||
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
*steps = list;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { occurrences, .. },
|
||||
..
|
||||
}) = e.shared_mut()
|
||||
{
|
||||
*occurrences = list;
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Target has no ordered steps".into());
|
||||
}
|
||||
let slot = match which {
|
||||
C::Steps => unreachable!(),
|
||||
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => Some(branches),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
}) => Some(incoming_items),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
}) => Some(outgoing_items),
|
||||
_ => None,
|
||||
},
|
||||
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
|
||||
_ => None,
|
||||
},
|
||||
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.ok_or("Collection is inapplicable on target")?;
|
||||
*slot = list;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
|
||||
match layer {
|
||||
Layer::Oa => pair.oa.as_ref(),
|
||||
Layer::Sa => pair.sa.as_ref(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
|
||||
match match layer {
|
||||
Layer::Oa => &review.oa,
|
||||
Layer::Sa => &review.sa,
|
||||
} {
|
||||
Observed::Value(v) => Ok(v),
|
||||
Observed::NotApplicable => {
|
||||
Err("Facet does not exist in the selected constructor branch".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn directional_map(
|
||||
map: &BTreeMap<Id, Id>,
|
||||
direction: TransitionDirection,
|
||||
) -> Result<BTreeMap<Id, Id>, String> {
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
return Ok(map.clone());
|
||||
}
|
||||
let mut inverse = BTreeMap::new();
|
||||
for (oa, sa) in map {
|
||||
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
|
||||
return Err(format!(
|
||||
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(inverse)
|
||||
}
|
||||
pub(crate) fn remap_record(
|
||||
record: &mut SemanticRecord,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
) -> Result<(), String> {
|
||||
let mut missing = BTreeSet::new();
|
||||
let mut remap = |id: &mut Id| {
|
||||
if let Some(new) = map.get(id) {
|
||||
*id = *new;
|
||||
} else {
|
||||
missing.insert(*id);
|
||||
}
|
||||
};
|
||||
match record {
|
||||
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
|
||||
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
|
||||
SemanticRecord::Artifact(a) => remap(&mut a.owner),
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
))
|
||||
}
|
||||
}
|
||||
pub(crate) fn remap_fact(
|
||||
fact: &FacetReview,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
source: Layer,
|
||||
) -> Result<FacetReview, String> {
|
||||
// Remap only the selected observation. Keys remain in canonical OA/SA space.
|
||||
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
|
||||
let base = SemanticBaseline {
|
||||
facets: vec![fact.clone()],
|
||||
};
|
||||
let side = match source {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
};
|
||||
let mut missing = BTreeSet::new();
|
||||
for (path, kind) in historical_source_slots(&base) {
|
||||
if kind != HistoricalSourceKind::Reference {
|
||||
continue;
|
||||
}
|
||||
let rest = &path[2..];
|
||||
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
|
||||
continue;
|
||||
}
|
||||
let mut leaf = &mut json;
|
||||
for part in rest {
|
||||
leaf = if leaf.is_array() {
|
||||
leaf.get_mut(
|
||||
part.parse::<usize>()
|
||||
.map_err(|_| "Invalid historical array index")?,
|
||||
)
|
||||
} else {
|
||||
leaf.get_mut(part)
|
||||
}
|
||||
.ok_or("Invalid historical source slot")?;
|
||||
}
|
||||
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
|
||||
if let Some(new) = map.get(&id) {
|
||||
*leaf = serde_json::to_value(new).unwrap();
|
||||
} else {
|
||||
missing.insert(id);
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
));
|
||||
}
|
||||
serde_json::from_value(json).map_err(|e| e.to_string())
|
||||
}
|
||||
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
|
||||
&& map.get(&oa) != Some(&sa)
|
||||
{
|
||||
return Err("Key pair disagrees with frozen reference correspondence".into());
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty key pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { keys, .. }
|
||||
| FacetSelector::Binding(keys)
|
||||
| FacetSelector::Child(keys)
|
||||
| FacetSelector::Incident { keys, .. }
|
||||
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
|
||||
FacetSelector::Annotation(keys) => {
|
||||
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Annotation path pairing disagrees".into());
|
||||
}
|
||||
for (oa, sa) in oa
|
||||
.path
|
||||
.iter()
|
||||
.chain([&oa.field])
|
||||
.zip(sa.path.iter().chain([&sa.field]))
|
||||
{
|
||||
pair(
|
||||
&KeyPair {
|
||||
oa: Some(*oa),
|
||||
sa: Some(*sa),
|
||||
},
|
||||
map,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty annotation pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
if keys
|
||||
.oa
|
||||
.as_ref()
|
||||
.zip(keys.sa.as_ref())
|
||||
.is_some_and(|(oa, sa)| oa != sa)
|
||||
{
|
||||
return Err(
|
||||
"Enum literal identity is exact text; renaming is removal/addition".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply just one selected fact to an in-memory expected project. The caller
|
||||
/// subsequently prepares ordinary Commands and checks every induced difference.
|
||||
pub(crate) fn write_fact(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
fact: &FacetReview,
|
||||
from: Layer,
|
||||
target_layer: Layer,
|
||||
preserve_target_only: bool,
|
||||
) -> Result<(), String> {
|
||||
match fact {
|
||||
FacetReview::Member {
|
||||
collection: which,
|
||||
keys,
|
||||
review,
|
||||
} => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer)
|
||||
.copied()
|
||||
.ok_or("Map the target collection member explicitly")?;
|
||||
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
|
||||
if present {
|
||||
if !list.contains(&key) {
|
||||
list.push(key);
|
||||
}
|
||||
} else {
|
||||
list.retain(|id| *id != key);
|
||||
}
|
||||
set_collection(p, target, *which, list)
|
||||
}
|
||||
FacetReview::Order {
|
||||
collection: which,
|
||||
review,
|
||||
} => {
|
||||
let supplied = value(review, from)?.clone();
|
||||
let current = collection(p, target, order_collection(*which))
|
||||
.ok_or("Inapplicable target order")?;
|
||||
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
|
||||
let current_set: BTreeSet<_> = current.iter().copied().collect();
|
||||
if supplied_set != current_set {
|
||||
if preserve_target_only
|
||||
&& supplied_set.is_subset(¤t_set)
|
||||
&& current
|
||||
.iter()
|
||||
.filter(|id| supplied_set.contains(id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
== supplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
|
||||
}
|
||||
set_collection(p, target, order_collection(*which), supplied)
|
||||
}
|
||||
FacetReview::Literal { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target is not an enumeration".into());
|
||||
};
|
||||
if present {
|
||||
if !literals.contains(key) {
|
||||
literals.push(key.clone());
|
||||
}
|
||||
} else {
|
||||
literals.retain(|v| v != key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Annotation { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
|
||||
return Err(
|
||||
"Annotation candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no message annotations".into());
|
||||
};
|
||||
values.retain(|v| AnnotationKey::from(v) != *key);
|
||||
if let Some(row) = row {
|
||||
values.push(row);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Binding { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| v.referenced != key) {
|
||||
return Err(
|
||||
"Binding candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { bindings, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no scenario bindings".into());
|
||||
};
|
||||
bindings.retain(|v| v.referenced != key);
|
||||
if let Some(row) = row {
|
||||
bindings.push(row);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Child { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
|
||||
let e = p
|
||||
.elements
|
||||
.get_mut(key)
|
||||
.ok_or("Target child does not exist; create it through transition mapping")?;
|
||||
if *value(review, from)? {
|
||||
e.parent = Some(target);
|
||||
} else if e.parent == Some(target) {
|
||||
e.parent = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Incident { keys, end, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
|
||||
if !*value(review, from)? {
|
||||
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
|
||||
}
|
||||
let relation = p
|
||||
.relations
|
||||
.get_mut(key)
|
||||
.ok_or("Mapped incident relation is absent")?;
|
||||
let (a, b) = relation.body.endpoints();
|
||||
match end {
|
||||
IncidentEnd::Source => relation.body.set_endpoints(target, b),
|
||||
IncidentEnd::Target => relation.body.set_endpoints(a, target),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Artifact { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
|
||||
if let ArtifactReview::Presence(review) = review {
|
||||
if !*value(review, from)? {
|
||||
p.artifact_links.remove(key);
|
||||
return Ok(());
|
||||
}
|
||||
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Artifact creation requires a complete typed candidate".into());
|
||||
}
|
||||
let a = p
|
||||
.artifact_links
|
||||
.get_mut(key)
|
||||
.ok_or("Artifact candidate is absent")?;
|
||||
if a.owner != target {
|
||||
return Err(
|
||||
"Artifact metadata can only be reviewed on its currently attached owner trace"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match review {
|
||||
ArtifactReview::Presence(_) => unreachable!(),
|
||||
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
|
||||
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
|
||||
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
|
||||
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
|
||||
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
|
||||
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
|
||||
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
let (oa, sa) = sides(fact);
|
||||
let side = match from {
|
||||
Layer::Oa => oa,
|
||||
Layer::Sa => sa,
|
||||
};
|
||||
let value = side
|
||||
.get("Value")
|
||||
.ok_or("Selected branch is not applicable")?
|
||||
.clone();
|
||||
write_scalar(p, target, &fact.selector(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn constructor_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,
|
||||
..
|
||||
}
|
||||
),
|
||||
F::State(StateFacet::Kind) => facet == group,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(crate) fn copy_constructor(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
source: &SemanticRecord,
|
||||
group: &FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
let SemanticRecord::Element(source) = source else {
|
||||
return Err("Constructor source must be an element".into());
|
||||
};
|
||||
let target = p
|
||||
.elements
|
||||
.get_mut(&target)
|
||||
.ok_or("Missing target element")?;
|
||||
let a = source.shared().ok_or("Source has no shared constructor")?;
|
||||
let b = target
|
||||
.shared_mut()
|
||||
.ok_or("Target has no shared constructor")?;
|
||||
match (group, a, b) {
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default,
|
||||
..
|
||||
},
|
||||
SharedBody::DataType {
|
||||
definition: d,
|
||||
default: v,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
*d = definition.clone();
|
||||
*v = default.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
SharedBody::Port { contract, legacy },
|
||||
SharedBody::Port {
|
||||
contract: c,
|
||||
legacy: l,
|
||||
},
|
||||
) => {
|
||||
*c = contract.clone();
|
||||
*l = legacy.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
SharedBody::Occurrence { body, .. },
|
||||
SharedBody::Occurrence { body: b, .. },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
},
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: k, ..
|
||||
},
|
||||
..
|
||||
},
|
||||
) => *k = message_kind.clone(),
|
||||
(
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
SharedBody::Event { body },
|
||||
SharedBody::Event { body: b },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
SharedBody::State { kind, .. },
|
||||
SharedBody::State { kind: k, .. },
|
||||
) => *k = *kind,
|
||||
_ => return Err("Constructor family mismatch".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,255 +0,0 @@
|
||||
use crate::model::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Observed<T> {
|
||||
NotApplicable,
|
||||
Value(T),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reviewed<T> {
|
||||
pub oa: Observed<T>,
|
||||
pub sa: Observed<T>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyPair<K> {
|
||||
pub oa: Option<K>,
|
||||
pub sa: Option<K>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AnnotationKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
impl From<&MessageValue> for AnnotationKey {
|
||||
fn from(value: &MessageValue) -> Self {
|
||||
Self {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataConstructor {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration,
|
||||
Record,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortConstructor {
|
||||
FunctionInput,
|
||||
FunctionOutput,
|
||||
Component,
|
||||
Legacy,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OccurrenceConstructor {
|
||||
Exchange,
|
||||
Action,
|
||||
Control,
|
||||
SequenceReference,
|
||||
ScenarioUse,
|
||||
State,
|
||||
Fragment,
|
||||
Branch,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EventConstructor {
|
||||
Signal,
|
||||
Time,
|
||||
Change,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageConstructor {
|
||||
Asynchronous,
|
||||
Call,
|
||||
Reply,
|
||||
Create,
|
||||
Delete,
|
||||
Timer,
|
||||
}
|
||||
|
||||
// This finite catalog is also consumed by the typed historical leaf visitor.
|
||||
// Each selector fixes its payload type; there is no arbitrary value/path schema.
|
||||
macro_rules! scalar_families {
|
||||
($m:ident) => { $m! {
|
||||
Header, HeaderFacet, HeaderReview {
|
||||
Name: String, Description: String, Parent: Option<Id>
|
||||
}
|
||||
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
|
||||
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
|
||||
}
|
||||
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
|
||||
Data, DataFacet, DataReview {
|
||||
Constructor: DataConstructor,
|
||||
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
|
||||
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
|
||||
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
|
||||
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
|
||||
}
|
||||
Field, FieldFacet, FieldReview {
|
||||
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
|
||||
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
|
||||
}
|
||||
Port, PortFacet, PortReview {
|
||||
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
|
||||
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
|
||||
LegacyInterface: Option<Id>
|
||||
}
|
||||
Scenario, ScenarioFacet, ScenarioReview {
|
||||
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
|
||||
}
|
||||
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
|
||||
Occurrence, OccurrenceFacet, OccurrenceReview {
|
||||
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
|
||||
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
|
||||
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
|
||||
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
|
||||
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
|
||||
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
|
||||
}
|
||||
Event, EventFacet, EventReview {
|
||||
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
|
||||
}
|
||||
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
|
||||
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
|
||||
Relation, RelationFacet, RelationReview {
|
||||
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
|
||||
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
|
||||
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
|
||||
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
|
||||
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
|
||||
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
|
||||
Guard: String, Effect: String, StateActionPhase: StateActionKind
|
||||
}
|
||||
}};
|
||||
}
|
||||
pub(super) use scalar_families;
|
||||
|
||||
macro_rules! define_families {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
$(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum $selector { $( $leaf ),* }
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
|
||||
)*
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetSelector {
|
||||
$( $family($selector), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
|
||||
Order(OrderedCollection),
|
||||
Literal(KeyPair<String>),
|
||||
Annotation(KeyPair<AnnotationKey>),
|
||||
Binding(KeyPair<Id>),
|
||||
Child(KeyPair<Id>),
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd },
|
||||
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetReview {
|
||||
$( $family($review), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
|
||||
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
|
||||
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
|
||||
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
|
||||
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
|
||||
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
|
||||
}
|
||||
impl FacetReview {
|
||||
pub fn selector(&self) -> FacetSelector {
|
||||
match self {
|
||||
$( Self::$family(value) => FacetSelector::$family(match value {
|
||||
$( $review::$leaf(_) => $selector::$leaf, )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
|
||||
Self::Order { collection, .. } => FacetSelector::Order(*collection),
|
||||
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
|
||||
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
|
||||
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
|
||||
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
|
||||
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
|
||||
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(define_families);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ReferenceCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
FunctionInputItems,
|
||||
FunctionOutputItems,
|
||||
SignalItems,
|
||||
ExchangeItems,
|
||||
TransitionTriggers,
|
||||
TransitionEffects,
|
||||
Constrained,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum OrderedCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum IncidentEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ArtifactFacet {
|
||||
Presence,
|
||||
Name,
|
||||
Description,
|
||||
Owner,
|
||||
Relation,
|
||||
Uri,
|
||||
Revision,
|
||||
Fragment,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ArtifactReview {
|
||||
Presence(Reviewed<bool>),
|
||||
Name(Reviewed<String>),
|
||||
Description(Reviewed<String>),
|
||||
Owner(Reviewed<Id>),
|
||||
Relation(Reviewed<ArtifactRelation>),
|
||||
Uri(Reviewed<String>),
|
||||
Revision(Reviewed<Option<String>>),
|
||||
Fragment(Reviewed<Option<String>>),
|
||||
}
|
||||
impl ArtifactReview {
|
||||
pub fn facet(&self) -> ArtifactFacet {
|
||||
match self {
|
||||
Self::Presence(_) => ArtifactFacet::Presence,
|
||||
Self::Name(_) => ArtifactFacet::Name,
|
||||
Self::Description(_) => ArtifactFacet::Description,
|
||||
Self::Owner(_) => ArtifactFacet::Owner,
|
||||
Self::Relation(_) => ArtifactFacet::Relation,
|
||||
Self::Uri(_) => ArtifactFacet::Uri,
|
||||
Self::Revision(_) => ArtifactFacet::Revision,
|
||||
Self::Fragment(_) => ArtifactFacet::Fragment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticBaseline {
|
||||
pub facets: Vec<FacetReview>,
|
||||
}
|
||||
@@ -999,7 +999,7 @@ mod transaction_tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
|
||||
//! documents; this module never reads a filesystem, URL, or process directory.
|
||||
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
|
||||
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
|
||||
use language::{Diagnostic, ParseLimits, Span};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -280,7 +280,7 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
|
||||
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
pub fn compile_bundle(
|
||||
@@ -720,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical, readable current-version export. Like `language::format`, the caller is
|
||||
/// Canonical, readable source4 export. Like `language::format`, the caller is
|
||||
/// responsible for supplying a valid model; compilation checks the result.
|
||||
/// Existing source layout is preserved by `update_bundle`, not this formatter.
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
@@ -736,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
"arcadia 4;\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
@@ -926,7 +926,6 @@ pub fn update_bundle_with_identity_map(
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
@@ -956,7 +955,6 @@ pub fn update_bundle_with_identity_map(
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
@@ -1128,7 +1126,6 @@ struct SyntaxUpdate<'a> {
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
// must keep its authored argument trivia during that one-time transformation.
|
||||
legacy_value_paths: bool,
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1224,18 +1221,6 @@ impl SyntaxUpdate<'_> {
|
||||
alias.clone(),
|
||||
));
|
||||
}
|
||||
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
.pop()
|
||||
.expect("historical UUID string");
|
||||
edits.push((
|
||||
Span {
|
||||
start: actual.span.start + token.span.start,
|
||||
end: actual.span.start + token.span.end,
|
||||
},
|
||||
quote(&id.to_string()),
|
||||
));
|
||||
}
|
||||
(EditKind::Atom, EditKind::Atom) => edits.push((
|
||||
actual.span,
|
||||
self.after[after.span.start..after.span.end].into(),
|
||||
@@ -1360,27 +1345,11 @@ impl SyntaxUpdate<'_> {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.before,
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.after,
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
)
|
||||
})
|
||||
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
@@ -1488,22 +1457,9 @@ fn item_key(
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
origins.unwrap_or(&BTreeMap::new()),
|
||||
)
|
||||
{
|
||||
return Some(key);
|
||||
}
|
||||
match &value.kind {
|
||||
EditKind::HistoricalUuid(id) => Some(format!(
|
||||
"uuid:{}",
|
||||
origins.and_then(|map| map.get(id)).unwrap_or(id)
|
||||
)),
|
||||
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
|
||||
EditKind::Atom => Some(format!(
|
||||
"literal:{}",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn upgrade(
|
||||
@@ -15,7 +15,6 @@ pub(super) fn upgrade(
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
.filter(|default| default.field != "baseline.semantic")
|
||||
.map(|default| default.object)
|
||||
.collect();
|
||||
let aliases: BTreeMap<_, _> = project
|
||||
@@ -62,7 +61,6 @@ pub(super) fn upgrade(
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
identity_origins: None,
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
@@ -80,7 +78,7 @@ pub(super) fn upgrade(
|
||||
edits
|
||||
.entry(current.bundle.entry.clone())
|
||||
.or_default()
|
||||
.push((version, crate::SCHEMA_VERSION.to_string()));
|
||||
.push((version, "4".into()));
|
||||
// New allocation relations are emitted into the allocating interface's
|
||||
// existing module, outside its lexical element body. Existing source never moves.
|
||||
let mut added: BTreeMap<String, String> = BTreeMap::new();
|
||||
@@ -119,7 +117,7 @@ pub(super) fn upgrade(
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
"migration_source_mismatch",
|
||||
"Targeted source upgrade did not reproduce the migrated project.",
|
||||
"Targeted source3 upgrade did not reproduce the migrated project.",
|
||||
)]);
|
||||
}
|
||||
result.decoded.migration = Some(report.clone());
|
||||
|
||||
@@ -202,15 +202,9 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||
return None;
|
||||
}
|
||||
if let Some(shared) = e.shared()
|
||||
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
|
||||
&& kind == e.kind()
|
||||
{
|
||||
let mut shared = shared.clone();
|
||||
if let SharedBody::State {
|
||||
kind: state_kind, ..
|
||||
} = &mut shared
|
||||
{
|
||||
*state_kind = kind.state_kind()?;
|
||||
}
|
||||
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
|
||||
*viewpoint = match (*viewpoint, layer) {
|
||||
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
|
||||
@@ -395,30 +389,11 @@ pub fn plan_transition(
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
let mut omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
sa_port_omissions(project)
|
||||
} else {
|
||||
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();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
@@ -952,7 +927,6 @@ pub fn plan_transition(
|
||||
.unwrap_or(TraceBaseline {
|
||||
oa: current_oa.clone(),
|
||||
sa: current_sa.clone(),
|
||||
semantic: None,
|
||||
});
|
||||
for field in &reviewed_fields {
|
||||
set(&mut baseline.oa, *field, value(¤t_oa, *field).clone());
|
||||
@@ -1107,11 +1081,6 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
|
||||
}
|
||||
|
||||
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
|
||||
// State kind is a reviewed structural facet of one state family. An existing
|
||||
// counterpart remains selectable after either side changes its vertex kind.
|
||||
if source.is_state() && target.is_state() {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
(direction, source, target),
|
||||
(
|
||||
|
||||
@@ -876,19 +876,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, t.id, "baseline.name", &props.name);
|
||||
text(&mut out, t.id, "baseline.description", &props.description);
|
||||
}
|
||||
if let Some(semantic) = &b.semantic
|
||||
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
|
||||
{
|
||||
for message in errors {
|
||||
error(
|
||||
&mut out,
|
||||
t.id,
|
||||
"semantic_baseline",
|
||||
"baseline.semantic",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match crate::transitions::trace_drift(p, t) {
|
||||
crate::transitions::Drift::Unchanged => {}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
|
||||
arcadia 4;
|
||||
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
|
||||
description "Preserve the original review without inventing semantic history.";
|
||||
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
|
||||
description "Unreviewed OA description";
|
||||
definition { Integer { minimum 0; maximum 100; }; };
|
||||
default { Integer 20; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
|
||||
description "Unreviewed SA description";
|
||||
definition { Real { minimum 0.0; maximum 100.0; }; };
|
||||
default { Real 20.0; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
|
||||
description "The target unit and representation are intentional.";
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description "Approved OA\nprose"; };
|
||||
sa { name "Approved SA name"; description "Approved SA prose"; };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"schema_version": 4,
|
||||
"id": "40000000-0000-4000-8000-000000000001",
|
||||
"alias": "reviewed_prose",
|
||||
"name": "Reviewed dimensions",
|
||||
"description": "Preserve the original review without inventing semantic history.",
|
||||
"elements": {
|
||||
"40000000-0000-4000-8000-000000000002": {
|
||||
"id": "40000000-0000-4000-8000-000000000002",
|
||||
"alias": "oa_dimension",
|
||||
"name": "OA dimension now",
|
||||
"description": "Unreviewed OA description",
|
||||
"parent": null,
|
||||
"body": {"Oa": {"Shared": {"DataType": {
|
||||
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
|
||||
"default": {"Integer": 20},
|
||||
"unit": "m"
|
||||
}}}}
|
||||
},
|
||||
"40000000-0000-4000-8000-000000000003": {
|
||||
"id": "40000000-0000-4000-8000-000000000003",
|
||||
"alias": "sa_dimension",
|
||||
"name": "SA dimension now",
|
||||
"description": "Unreviewed SA description",
|
||||
"parent": null,
|
||||
"body": {"Sa": {"Shared": {"DataType": {
|
||||
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
|
||||
"default": {"Real": 20.0},
|
||||
"unit": "ft"
|
||||
}}}}
|
||||
}
|
||||
},
|
||||
"relations": {},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {
|
||||
"40000000-0000-4000-8000-000000000004": {
|
||||
"id": "40000000-0000-4000-8000-000000000004",
|
||||
"alias": "dimension_review",
|
||||
"name": "Dimension rationale",
|
||||
"description": "The target unit and representation are intentional.",
|
||||
"oa": "40000000-0000-4000-8000-000000000002",
|
||||
"sa": "40000000-0000-4000-8000-000000000003",
|
||||
"kind": "Item",
|
||||
"baseline": {
|
||||
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
|
||||
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -795,7 +795,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 5);
|
||||
assert_eq!(retained["schema_version"], 4);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
@@ -991,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 5);
|
||||
assert_eq!(from_text.schema_version, 4);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1193,7 +1193,6 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
|| code.trim_start().starts_with("arcadia 5;")
|
||||
{
|
||||
read(&code);
|
||||
examples += 1;
|
||||
|
||||
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
assert_eq!(decoded.project, from_source.project);
|
||||
assert_eq!(decoded.migration, from_source.migration);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 5));
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 4));
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -144,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -274,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 5);
|
||||
assert_eq!(result.project.schema_version, 4);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ project typed_ports "Typed ports" {
|
||||
#[test]
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
let migration = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
|
||||
assert!(decoded.migration.is_none());
|
||||
let canonical = language::format(&decoded.project);
|
||||
for field in [
|
||||
"incoming_items [@data]",
|
||||
|
||||
@@ -1,680 +0,0 @@
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Family {
|
||||
Data,
|
||||
Port,
|
||||
Occurrence,
|
||||
Message,
|
||||
Event,
|
||||
State,
|
||||
}
|
||||
const FAMILIES: [Family; 6] = [
|
||||
Family::Data,
|
||||
Family::Port,
|
||||
Family::Occurrence,
|
||||
Family::Message,
|
||||
Family::Event,
|
||||
Family::State,
|
||||
];
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, "reviewed", parent).unwrap()
|
||||
}
|
||||
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("review_{}", id.simple()),
|
||||
name: "Review".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn set(p: &mut Project, id: Id, body: SharedBody) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
fn integer(p: &mut Project, id: Id) {
|
||||
set(
|
||||
p,
|
||||
id,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(0),
|
||||
maximum: Some(10),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let id = match family {
|
||||
Family::Data => {
|
||||
let id = add(&mut p, Kind::DataType, layer, None);
|
||||
integer(&mut p, id);
|
||||
id
|
||||
}
|
||||
Family::Port => {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
};
|
||||
let owner = add(&mut p, kind, layer, None);
|
||||
let id = Id::new_v4();
|
||||
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
Family::Occurrence | Family::Message => {
|
||||
let owner = add(&mut p, Kind::Scenario, layer, None);
|
||||
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "10s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::Event => {
|
||||
let id = add(&mut p, Kind::Event, layer, None);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Event {
|
||||
body: EventBody::Time {
|
||||
kind: TimeEventKind::After,
|
||||
expression: "10s".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::State => {
|
||||
let machine = add(&mut p, Kind::StateMachine, layer, None);
|
||||
let region = add(&mut p, Kind::Region, layer, Some(machine));
|
||||
add(&mut p, Kind::State, layer, Some(region))
|
||||
}
|
||||
};
|
||||
pair.push(id);
|
||||
}
|
||||
let (oa, sa) = (pair[0], pair[1]);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let (constructor, descendant) = match family {
|
||||
Family::Data => (
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
FacetSelector::Data(DataFacet::IntegerMaximum),
|
||||
),
|
||||
Family::Port => (
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
FacetSelector::Port(PortFacet::LegacyDirection),
|
||||
),
|
||||
Family::Occurrence => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Message => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Event => (
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
FacetSelector::Event(EventFacet::TimeExpression),
|
||||
),
|
||||
Family::State => (
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
FacetSelector::State(StateFacet::Entry),
|
||||
),
|
||||
};
|
||||
(p, oa, sa, t, constructor, descendant)
|
||||
}
|
||||
fn change_descendant(p: &mut Project, id: Id) {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { maximum, .. },
|
||||
..
|
||||
} => *maximum = Some(20),
|
||||
SharedBody::Port {
|
||||
legacy: Some(LegacyPortContract { direction, .. }),
|
||||
..
|
||||
} => *direction = PortDirection::Out,
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Timer { expression },
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
| SharedBody::Event {
|
||||
body: EventBody::Time { expression, .. },
|
||||
} => *expression = "20s".into(),
|
||||
SharedBody::State { entry, .. } => *entry = "initialize".into(),
|
||||
_ => panic!("Unexpected test family"),
|
||||
}
|
||||
}
|
||||
fn request(
|
||||
oa: Id,
|
||||
sa: Id,
|
||||
reverse: bool,
|
||||
resolutions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let (direction, source, target) = if reverse {
|
||||
(TransitionDirection::SaToOa, sa, oa)
|
||||
} else {
|
||||
(TransitionDirection::OaToSa, oa, sa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: resolutions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution { facet, decision })
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
|
||||
let plan = plan_semantic_transition(p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
(apply_semantic_transition(p, &plan).unwrap(), plan)
|
||||
}
|
||||
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
|
||||
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|f| f.selector == *selector)
|
||||
.unwrap()
|
||||
.drift
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for decision in [
|
||||
SemanticDecision::KeepTarget,
|
||||
SemanticDecision::TakeSource,
|
||||
SemanticDecision::Candidate,
|
||||
] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
change_descendant(&mut p, target);
|
||||
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(p.elements[&target].clone()));
|
||||
}
|
||||
let (q, plan) = apply(&p, r.clone());
|
||||
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert!(facts.iter().all(|f| f.drift
|
||||
== if f.selector == constructor {
|
||||
Drift::Unchanged
|
||||
} else {
|
||||
Drift::Unreviewed
|
||||
}));
|
||||
let (mut q, _) = apply(
|
||||
&q,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
change_descendant(&mut q, source);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let repeated = plan_semantic_transition(&q, r).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for history in 0..3 {
|
||||
let (mut p, oa, sa, t, _, _) = fixture(family);
|
||||
if history == 0 {
|
||||
p.traces.get_mut(&t).unwrap().baseline = None;
|
||||
} else if history == 2 {
|
||||
p.traces
|
||||
.get_mut(&t)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline::default());
|
||||
}
|
||||
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.selector)
|
||||
.collect();
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
selectors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|s| (s, SemanticDecision::KeepTarget))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
|
||||
let json = include_str!("fixtures/v4-reviewed-prose.json")
|
||||
.replace("\"Real\"", "\"Integer\"")
|
||||
.replace("0.0", "0")
|
||||
.replace("100.0", "100")
|
||||
.replace("20.0", "20");
|
||||
for reverse in [false, true] {
|
||||
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
let p = decoded.project;
|
||||
let t = p.traces.values().next().unwrap();
|
||||
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
|
||||
let original_prose = t.baseline.clone().unwrap();
|
||||
let (oa, sa, id) = (t.oa, t.sa, t.id);
|
||||
let selections = semantic_facets(&p, t, &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| (f.selector, SemanticDecision::KeepTarget))
|
||||
.collect();
|
||||
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
|
||||
assert_eq!(q.elements, p.elements);
|
||||
let baseline = q.traces[&id].baseline.as_ref().unwrap();
|
||||
assert_eq!(baseline.oa, original_prose.oa);
|
||||
assert_eq!(baseline.sa, original_prose.sa);
|
||||
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged);
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut changed = p.clone();
|
||||
change_descendant(&mut changed, target);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::Candidate)],
|
||||
);
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(changed.elements[&target].clone()));
|
||||
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
|
||||
assert!(
|
||||
invalid
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("changes an unselected facet"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &invalid).is_err());
|
||||
r.reviews[0].resolutions.push(FacetResolution {
|
||||
facet: descendant.clone(),
|
||||
decision: SemanticDecision::Candidate,
|
||||
});
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, changed.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
assert_eq!(
|
||||
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
|
||||
Drift::Unreviewed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let (mut p, _) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![
|
||||
(constructor.clone(), SemanticDecision::KeepTarget),
|
||||
(descendant.clone(), SemanticDecision::KeepTarget),
|
||||
],
|
||||
),
|
||||
);
|
||||
let original = p.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
change_descendant(&mut p, if reverse { sa } else { oa });
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor.clone());
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
assert_eq!(
|
||||
q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&original
|
||||
);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
|
||||
for switch in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
|
||||
integer(&mut p, second);
|
||||
if switch {
|
||||
set(
|
||||
&mut p,
|
||||
second,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(30.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
change_descendant(&mut p, second);
|
||||
}
|
||||
trace(&mut p, second, sa);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
false,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.transition.sources.push(second);
|
||||
r.transition.mappings.push(TransitionMapping {
|
||||
source: second,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
});
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: second,
|
||||
reset: vec![],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: if switch {
|
||||
constructor.clone()
|
||||
} else {
|
||||
descendant.clone()
|
||||
},
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
});
|
||||
for reversed in [false, true] {
|
||||
let mut r = r.clone();
|
||||
if reversed {
|
||||
r.reviews.reverse();
|
||||
}
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
if switch {
|
||||
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
} else {
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_semantic_transition(&p, &plan).unwrap();
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
|
||||
assert!(matches!(
|
||||
q.elements[&sa].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
maximum: Some(20),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
|
||||
for reverse in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
|
||||
let source = if reverse { sa } else { oa };
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut action = p.elements[&source].shared().unwrap().clone();
|
||||
if let SharedBody::Occurrence { body, .. } = &mut action {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
set(&mut p, source, action.clone());
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
// One-sided applicability still has a constructor-group scope and records its timer leaf.
|
||||
assert_eq!(
|
||||
plan.reviews[0].acknowledged,
|
||||
vec![constructor.clone(), descendant.clone()]
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
let mut p = q;
|
||||
set(&mut p, target, action);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor);
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
assert!(!plan.conflicts.is_empty());
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
let keep = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
let (mut p, _) = apply(&p, keep.clone());
|
||||
set(
|
||||
&mut p,
|
||||
source,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(20.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
let (mut q, plan) = apply(&p, keep.clone());
|
||||
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
|
||||
assert!(plan.reviews[0].acknowledged.contains(&maximum));
|
||||
assert!(
|
||||
plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Default))
|
||||
);
|
||||
assert!(
|
||||
!plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Unit))
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
if let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real { maximum, .. },
|
||||
..
|
||||
}) = q.elements.get_mut(&source).unwrap().shared_mut()
|
||||
{
|
||||
*maximum = Some(30.);
|
||||
}
|
||||
let repeated = plan_semantic_transition(&q, keep).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
assert_eq!(
|
||||
drift(&q, t, &maximum),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let take = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor, SemanticDecision::TakeSource)],
|
||||
);
|
||||
let (p, _) = apply(&q, take.clone());
|
||||
assert!(matches!(
|
||||
p.elements[&target].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
maximum: Some(30.),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let completed = plan_semantic_transition(&p, take).unwrap();
|
||||
assert!(
|
||||
completed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &completed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
|
||||
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
|
||||
let json = persistence::decode_with_report(JSON4).unwrap();
|
||||
let source = language::parse_with_report(SOURCE4).unwrap();
|
||||
assert_eq!(json.project, source.project);
|
||||
for decoded in [json, source] {
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
let trace = decoded.project.traces.values().next().unwrap();
|
||||
let baseline = trace.baseline.as_ref().unwrap();
|
||||
assert!(baseline.semantic.is_none());
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert_eq!(baseline.oa.description, "Approved OA\nprose");
|
||||
assert_eq!(baseline.sa.name, "Approved SA name");
|
||||
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
|
||||
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
expected["schema_version"] = 5.into();
|
||||
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
|
||||
let reopened =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
|
||||
.unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_headers_cannot_admit_new_semantic_baselines() {
|
||||
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
let trace = value["traces"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap();
|
||||
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
|
||||
for version in [3, 4] {
|
||||
value["schema_version"] = version.into();
|
||||
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("semantic"), "{error}");
|
||||
}
|
||||
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
|
||||
assert!(
|
||||
language::parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("require schema/source5"))
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.message.contains("require schema/source5"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
|
||||
let json = std::str::from_utf8(JSON4).unwrap();
|
||||
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("duplicate field")
|
||||
);
|
||||
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
|
||||
assert!(
|
||||
persistence::decode(unknown.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
let start = SOURCE4.find(" oa_data_type").unwrap();
|
||||
let end = SOURCE4.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"contracts.arcadia\";\n}}\n",
|
||||
&SOURCE4[..start]
|
||||
);
|
||||
let mut module =
|
||||
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
|
||||
module.push_str(
|
||||
r#"
|
||||
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
|
||||
definition { Record { supertype none; }; };
|
||||
// This field remains inside its authored record.
|
||||
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry.clone()),
|
||||
("contracts.arcadia".into(), module.clone()),
|
||||
]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", "arcadia 5;", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
|
||||
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(
|
||||
bundle.files["project.arcadia"], entry,
|
||||
"caller draft is unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
|
||||
for (version, bytes) in [
|
||||
(
|
||||
1,
|
||||
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
|
||||
),
|
||||
(
|
||||
2,
|
||||
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
|
||||
),
|
||||
(
|
||||
3,
|
||||
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
|
||||
),
|
||||
] {
|
||||
let decoded = persistence::decode_with_report(bytes).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (version, 5));
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert!(
|
||||
decoded
|
||||
.project
|
||||
.traces
|
||||
.values()
|
||||
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&decoded.project)).unwrap(),
|
||||
decoded.project
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,431 +0,0 @@
|
||||
use oa_core::{language, persistence, semantic::*, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
|
||||
}
|
||||
|
||||
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
|
||||
Reviewed {
|
||||
oa: Observed::Value(oa),
|
||||
sa: Observed::Value(sa),
|
||||
}
|
||||
}
|
||||
|
||||
fn project(facets: Vec<FacetReview>) -> Project {
|
||||
let mut project =
|
||||
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
|
||||
project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline { facets });
|
||||
project
|
||||
}
|
||||
|
||||
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
|
||||
&mut project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.facets
|
||||
}
|
||||
|
||||
fn compile(source: String) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(91), id(92)],
|
||||
field: id(93),
|
||||
expression: "x + 1".into(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Data(DataReview::Constructor(reviewed(
|
||||
DataConstructor::Integer,
|
||||
DataConstructor::Real,
|
||||
))),
|
||||
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(None),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Data(DataReview::RealMaximum(Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::Value(Some(1.5)),
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Field(FieldReview::Default(reviewed(
|
||||
Some(vec![DataValue::String(id(90).to_string())]),
|
||||
None,
|
||||
))),
|
||||
FacetReview::Member {
|
||||
collection: ReferenceCollection::Steps,
|
||||
keys: KeyPair {
|
||||
oa: Some(id(91)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Order {
|
||||
collection: OrderedCollection::Steps,
|
||||
review: reviewed(vec![id(91), id(92)], vec![]),
|
||||
},
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Binding {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(94)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(
|
||||
Some(LifelineBinding {
|
||||
referenced: id(94),
|
||||
local: id(95),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(90).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Artifact {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(96)),
|
||||
sa: None,
|
||||
},
|
||||
review: ArtifactReview::Relation(reviewed(
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::References,
|
||||
)),
|
||||
},
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
);
|
||||
assert!(source.contains("sa NotApplicable;"));
|
||||
assert!(source.contains("Value none;"));
|
||||
assert!(source.contains(&format!("String \"{}\"", id(90))));
|
||||
assert_eq!(language::parse(&source).unwrap(), model);
|
||||
assert_eq!(compile(source).decoded.project, model);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
|
||||
model
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
|
||||
let model = project(vec![
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
|
||||
]);
|
||||
let source = language::format(&model);
|
||||
let reference = format!("Named uuid \"{}\"", id(2));
|
||||
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
|
||||
let invalid = source.replace(&reference, &replacement);
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), invalid)])
|
||||
},
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_text).is_err());
|
||||
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
|
||||
assert!(language::parse(&invalid_enum).is_err());
|
||||
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_live).is_err());
|
||||
let invalid_prose = source.replace(
|
||||
"name \"Approved OA name\";",
|
||||
&format!("name uuid \"{}\";", id(2)),
|
||||
);
|
||||
assert!(language::parse(&invalid_prose).is_err());
|
||||
// `uuid` remains an ordinary legal declaration alias after @.
|
||||
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_values_remain_closed_typed_records_in_both_formats() {
|
||||
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
|
||||
reviewed(Some(0), None),
|
||||
))]);
|
||||
let source = language::format(&model);
|
||||
for invalid in [
|
||||
source.replace("IntegerMinimum", "UnknownFacet"),
|
||||
source.replace("Value 0;", "Value \"0\";"),
|
||||
source.replace("facets [", "unexpected true; facets ["),
|
||||
] {
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
}
|
||||
let mut json = serde_json::to_value(&model).unwrap();
|
||||
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(2)],
|
||||
field: id(90),
|
||||
expression: id(2).to_string(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(2).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
]);
|
||||
let source = language::format(&model)
|
||||
.replace("Annotation {", "Annotation { // annotation rationale")
|
||||
.replace("Literal {", "Literal { // literal rationale")
|
||||
.replace("Named uuid ", "Named uuid // historical type rationale\n");
|
||||
let current = compile(source);
|
||||
let mut prepared = prepare_command(
|
||||
¤t.decoded.project,
|
||||
&Command::Reidentify(id(2), id(20)),
|
||||
)
|
||||
.unwrap();
|
||||
facets(&mut prepared.project).reverse();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &updated.bundle.files["project.arcadia"];
|
||||
assert_eq!(updated.decoded.project, prepared.project);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
"Named uuid // historical type rationale\n\"{}\"",
|
||||
id(20)
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
let type_note = text.find("// historical type rationale").unwrap();
|
||||
let literal_note = text.find("// literal rationale").unwrap();
|
||||
let annotation_note = text.find("// annotation rationale").unwrap();
|
||||
assert!(
|
||||
type_note < literal_note && literal_note < annotation_note,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("expression \"{}\"", id(2))));
|
||||
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
keys.oa,
|
||||
Some(id(2).to_string()),
|
||||
"UUID-shaped literal keys are text"
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
updated.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
|
||||
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let mut former_type = model.elements[&id(2)].clone();
|
||||
former_type.id = id(90);
|
||||
former_type.alias = "former_type".into();
|
||||
model.elements.insert(id(90), former_type);
|
||||
let current = compile(language::format(&model));
|
||||
let prepared = prepare_command(¤t.decoded.project, &Command::Delete(id(90))).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = &updated.bundle.files["project.arcadia"];
|
||||
assert!(!updated.decoded.project.contains(id(90)));
|
||||
assert!(!source.contains("@former_type"));
|
||||
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
|
||||
assert_eq!(updated.decoded.project.traces, model.traces);
|
||||
assert_eq!(language::parse(source).unwrap(), prepared.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
|
||||
use oa_core::source_files::{load_project, save_project};
|
||||
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let source = language::format(&model);
|
||||
let trace_at = source.find(" trace ").unwrap();
|
||||
let end = source.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"reviews.arcadia\";\n}}\n",
|
||||
&source[..trace_at]
|
||||
);
|
||||
let review =
|
||||
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let entry_path = directory.path().join("project.arcadia");
|
||||
std::fs::write(&entry_path, &entry).unwrap();
|
||||
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
|
||||
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
|
||||
let mut changed = compiled.decoded.project.clone();
|
||||
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
|
||||
panic!()
|
||||
};
|
||||
value.sa = Observed::Value(TypeRef::String);
|
||||
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.bundle.files["project.arcadia"], entry);
|
||||
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
|
||||
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(saved.bundle, reopened.bundle);
|
||||
assert_eq!(
|
||||
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
|
||||
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
|
||||
assert!(!changed.contains(id(90)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
|
||||
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
|
||||
let model = project(vec![]);
|
||||
let current = compile(language::format(&model).replace(
|
||||
"baseline {",
|
||||
"baseline { // prior prose review is independent",
|
||||
));
|
||||
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: id(2),
|
||||
target: Some(id(3)),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: id(2),
|
||||
resolutions: vec![
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Data(DataFacet::Unit),
|
||||
decision: SemanticDecision::KeepTarget,
|
||||
},
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Header(HeaderFacet::Name),
|
||||
decision: SemanticDecision::TakeSource,
|
||||
},
|
||||
],
|
||||
reset: vec![],
|
||||
});
|
||||
let plan = plan_semantic_transition(¤t.decoded.project, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let prepared = prepare_semantic_transition(¤t.decoded.project, &plan).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project.elements[&id(3)].name,
|
||||
"OA dimension now"
|
||||
);
|
||||
assert!(
|
||||
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
|
||||
);
|
||||
assert!(
|
||||
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
|
||||
);
|
||||
let baseline = updated.decoded.project.traces[&id(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert!(
|
||||
baseline
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
|
||||
"m".into(),
|
||||
"ft".into()
|
||||
))))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&updated.decoded.project)).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
assert_eq!(decoded.migration, source.migration);
|
||||
let project = &decoded.project;
|
||||
let report = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 5));
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
@@ -71,7 +71,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
@@ -154,12 +154,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(v2.project, text.project);
|
||||
assert_eq!(v2.migration, text.migration);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 5);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 4);
|
||||
let v1 = language::parse_with_report(
|
||||
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v1.project.schema_version, 5);
|
||||
assert_eq!(v1.project.schema_version, 4);
|
||||
assert_eq!(v1.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
|
||||
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(directory.path().join("parts")).unwrap();
|
||||
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
|
||||
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
|
||||
let directory = setup();
|
||||
let entry = directory.path().join("project.arcadia");
|
||||
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(
|
||||
directory.path().join("parts/a.arcadia"),
|
||||
"oa_activity a \"A\" { invalid",
|
||||
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
|
||||
let overlays = BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"parts/a.arcadia".into(),
|
||||
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
),
|
||||
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
|
||||
]),
|
||||
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
|
||||
std::os::unix::fs::symlink(&destination, &link).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_project(
|
||||
|
||||
@@ -6,7 +6,7 @@ fn source() -> CompiledSourceProject {
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
|
||||
|
||||
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap()
|
||||
|
||||
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
#[test]
|
||||
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -95,7 +95,7 @@ diagram review "Review" {
|
||||
#[test]
|
||||
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
let mut input = bundle(
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
|
||||
fn typed() -> SourceBundle {
|
||||
bundle(
|
||||
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
&[
|
||||
(
|
||||
"exchanges.arcadia",
|
||||
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"// Пример\nsa_function child \"航海\" { parent @missing; }",
|
||||
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
),
|
||||
] {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[("a.arcadia", left), ("b.arcadia", right)],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
assert_eq!(error.related[0].path, "a.arcadia");
|
||||
}
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
#[test]
|
||||
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
|
||||
let mut input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
&[
|
||||
("sub/a.arcadia", "include \"../shared.arcadia\";"),
|
||||
("sub/b.arcadia", "include \"../shared.arcadia\";"),
|
||||
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
);
|
||||
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" {}",
|
||||
"arcadia 4; project p \"P\" {}",
|
||||
&[("A.arcadia", ""), ("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
#[test]
|
||||
fn project_admission_is_aggregate_and_precedes_global_resolution() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
|
||||
("b.arcadia", "oa_activity two \"Two\" {}"),
|
||||
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
|
||||
|
||||
#[test]
|
||||
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
|
||||
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
assert!(
|
||||
language::parse(source).unwrap_err()[0]
|
||||
.message
|
||||
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
|
||||
#[test]
|
||||
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
|
||||
let input = bundle(
|
||||
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
|
||||
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
|
||||
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
|
||||
#[test]
|
||||
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
&[("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
|
||||
"project_limit"
|
||||
);
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"a.arcadia",
|
||||
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
|
||||
&source[span.start..span.end],
|
||||
"include \"../shared.arcadia\";"
|
||||
);
|
||||
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
assert_eq!(
|
||||
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
|
||||
.target,
|
||||
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
@@ -1017,7 +1017,7 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
|
||||
@@ -1052,7 +1052,7 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
|
||||
@@ -1104,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -1153,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
|
||||
@@ -42,7 +42,6 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
};
|
||||
p.traces.insert(id, t);
|
||||
|
||||
@@ -13,14 +13,5 @@ path = "src/main.rs"
|
||||
oa-core = { path = "../oa-core" }
|
||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||
rfd = "0.15"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
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,4 +1,3 @@
|
||||
mod hosted;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
document::Document,
|
||||
@@ -13,219 +12,8 @@ use oa_core::{
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
||||
|
||||
#[cfg(test)]
|
||||
mod direct_scenario_tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
|
||||
};
|
||||
fn setup() -> (App, scenario::direct_tests::Fixture) {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
|
||||
}
|
||||
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.scenario_context().unwrap(),
|
||||
slot: Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
},
|
||||
sender: f.sender,
|
||||
receiver: f.receiver,
|
||||
style: MessageStyle::Asynchronous,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.scenario_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
let context = app.scenario_context().unwrap();
|
||||
for (area, offset) in [
|
||||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||
] {
|
||||
let before = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(before));
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(current),
|
||||
"unchanged redraw notification retains context"
|
||||
);
|
||||
}
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(&mut app, Event::Name("Keep my draft".into()));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 200.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
let current = app.scenario_context().unwrap();
|
||||
for dimension in 0..3 {
|
||||
let mut stale = current;
|
||||
match dimension {
|
||||
0 => stale.document = Id::new_v4(),
|
||||
1 => stale.scenario = Id::new_v4(),
|
||||
_ => stale.revision += 1,
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
}
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
|
||||
let (mut app, f) = setup();
|
||||
let before = app.document.snapshot();
|
||||
for dimension in 0..4 {
|
||||
let mut event = begin(&app, &f);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match dimension {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.scenario = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending scenario name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Exchange(Choice {
|
||||
id: f.exchange,
|
||||
label: "Contract".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
let created = app.selected;
|
||||
assert_ne!(created, f.owner);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[f.call, f.reply, created]
|
||||
);
|
||||
let before_reorder = app.document.bundle().clone();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, f.sender,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(context),
|
||||
"same-owner selection retains gesture context"
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: created,
|
||||
slot: Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
},
|
||||
}));
|
||||
assert_eq!(app.selected, created);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[created, f.call, f.reply]
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before_reorder);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert_eq!(app.project(), &f.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
|
||||
let (mut app, f) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 4000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Exchange(Choice {
|
||||
id: f.exchange,
|
||||
label: "Contract".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Long message".repeat(1024)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered message".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project().named(app.selected), "Recovered message");
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod behavior_lifecycle_tests;
|
||||
mod process_canvas;
|
||||
mod state_diagram;
|
||||
#[cfg(test)]
|
||||
mod state_diagram_tests;
|
||||
@@ -281,7 +69,6 @@ impl std::fmt::Display for Choice {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Pending {
|
||||
HostedOpen,
|
||||
New,
|
||||
Demo,
|
||||
Open,
|
||||
@@ -301,7 +88,6 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Hosted(crate::hosted::Event),
|
||||
PortAnchor(crate::port_edit::Event),
|
||||
Connection(crate::connection_edit::Event),
|
||||
Menu(crate::model_menu::Event),
|
||||
@@ -320,7 +106,6 @@ pub enum Message {
|
||||
SelectProject,
|
||||
Search(String),
|
||||
StartTransition,
|
||||
StartSemanticReview(Id),
|
||||
Workflow(crate::workflow::Event),
|
||||
CollapseExplorer(Id),
|
||||
ExplorerGroup(crate::explorer::Group),
|
||||
@@ -330,7 +115,6 @@ pub enum Message {
|
||||
BrowseGroup(crate::explorer::Group),
|
||||
ExplorerPage(usize),
|
||||
CreateKind(Kind),
|
||||
Data(crate::data_view::Event),
|
||||
Add(bool),
|
||||
AddRelation,
|
||||
PortAction(crate::port_workflow::Action),
|
||||
@@ -343,9 +127,6 @@ pub enum Message {
|
||||
Field(String, String),
|
||||
FieldText(String, text_editor::Action),
|
||||
FieldShape(String, Value),
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -363,8 +144,6 @@ pub enum Message {
|
||||
BehaviorOwner(Choice),
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
ToggleCollapse,
|
||||
@@ -413,7 +192,6 @@ pub enum Message {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
@@ -426,7 +204,6 @@ pub struct App {
|
||||
pub page: Page,
|
||||
pub guide: crate::guide::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
@@ -515,7 +292,6 @@ impl App {
|
||||
page: Page::Diagram,
|
||||
guide: crate::guide::State::default(),
|
||||
behavior: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer,
|
||||
selected,
|
||||
@@ -529,7 +305,6 @@ impl App {
|
||||
Kind::Activity
|
||||
},
|
||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||
hosted: Default::default(),
|
||||
busy: false,
|
||||
pending: None,
|
||||
delete_pending: false,
|
||||
@@ -799,14 +574,9 @@ impl App {
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = scenario_selection.or(selected) {
|
||||
if let Some(id) = selected {
|
||||
self.selected = id;
|
||||
}
|
||||
self.sync();
|
||||
@@ -849,10 +619,7 @@ impl App {
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
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.hosted = hosted;
|
||||
self.window_size = window_size;
|
||||
self.canvas_size = canvas_size;
|
||||
self.source_version = source_version;
|
||||
@@ -914,12 +681,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let base = crate::context_menu::Popup::new(
|
||||
crate::views::view(self),
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||||
.into()
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -969,19 +732,6 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Scrollable geometry can change while async work or an overlay blocks
|
||||
// commands. Keep the observed translation current; its passive handler
|
||||
// still validates document, scenario and revision before accepting it.
|
||||
if matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
|
||||
| Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| crate::behavior::topology::CanvasEvent::Viewport(..)
|
||||
)
|
||||
) {
|
||||
return self.handle(message);
|
||||
}
|
||||
// Global subscriptions also receive keys consumed by an overlay.
|
||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||
self.model_menu = None;
|
||||
@@ -1014,8 +764,6 @@ impl App {
|
||||
Message::Field(..)
|
||||
| Message::FieldText(..)
|
||||
| Message::FieldShape(..)
|
||||
| Message::FieldAppend(..)
|
||||
| Message::FieldValuePath(..)
|
||||
| Message::FieldVariant(..)
|
||||
| Message::FieldArray(..)
|
||||
| Message::ElementKind(_)
|
||||
@@ -1031,7 +779,6 @@ impl App {
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -1051,10 +798,6 @@ impl App {
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
if self.hosted.visible {
|
||||
self.hosted.visible = false;
|
||||
return Task::none();
|
||||
}
|
||||
if self.workflow.take().is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1079,7 +822,6 @@ impl App {
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -1087,8 +829,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
);
|
||||
if !modal_event
|
||||
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|
||||
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
|| (self.file_prompt.is_some()
|
||||
&& !matches!(
|
||||
message,
|
||||
@@ -1146,11 +887,6 @@ impl App {
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if let Some(context) = self.topology_context() {
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
}
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
@@ -1160,7 +896,6 @@ impl App {
|
||||
self.connection = None;
|
||||
}
|
||||
if previous_layer != self.layer {
|
||||
self.data_state = Default::default();
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = None;
|
||||
@@ -1193,117 +928,8 @@ impl App {
|
||||
task
|
||||
}
|
||||
}
|
||||
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
|
||||
let scenario = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
|
||||
.then_some(crate::behavior::scenario::Context {
|
||||
document: self.canvas_document,
|
||||
scenario,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Hosted(event) => return self.hosted_event(event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Inner and outer scroll notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
// while still rejecting another owner/document/revision.
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id)
|
||||
== Some(context.scenario)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.scenario.choose_tool(context.scenario, tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the scenario."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
slot,
|
||||
sender,
|
||||
receiver,
|
||||
style,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_message(
|
||||
self.project(),
|
||||
context,
|
||||
slot,
|
||||
sender,
|
||||
receiver,
|
||||
style,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, slot, ..
|
||||
} => {
|
||||
match scenario::reorder(
|
||||
self.project(),
|
||||
context.scenario,
|
||||
occurrence,
|
||||
slot,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
|
||||
Message::StateDiagram(event) => return self.edit_state_diagram(event),
|
||||
Message::BehaviorOwner(choice) => {
|
||||
if self.apply()
|
||||
@@ -1498,32 +1124,6 @@ impl App {
|
||||
self.status = e;
|
||||
}
|
||||
}
|
||||
Message::FieldAppend(path, value) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.append(&path, value)
|
||||
{
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
Message::FieldValueBrowse(path, scope, page) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& draft
|
||||
.value
|
||||
.pointer(&path)
|
||||
.is_some_and(|row| row.get("expression").is_some())
|
||||
{
|
||||
draft
|
||||
.value_browsers
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldValuePath(path, key) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
{
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
Message::FieldVariant(path, value) => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
let result = d.materialize().and_then(|current| {
|
||||
@@ -1693,57 +1293,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::StartSemanticReview(selected) => {
|
||||
if self.apply() {
|
||||
match crate::semantic_review::State::new(self.project(), selected) {
|
||||
Ok(state) => {
|
||||
self.workflow =
|
||||
Some(crate::workflow::Workflow::Semantic(Box::new(state)))
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Workflow(event) => {
|
||||
if matches!(event, crate::workflow::Event::Cancel) {
|
||||
self.workflow = None;
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(mut workflow) = self.workflow.take() {
|
||||
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
|
||||
match event {
|
||||
crate::workflow::Event::Semantic(event) => {
|
||||
state.update(self.project(), event)
|
||||
}
|
||||
crate::workflow::Event::Apply => {
|
||||
let focus = state.target(self.project());
|
||||
match state.command(self.project()) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
if let Some(target) = focus {
|
||||
self.selected = target;
|
||||
if let Some(layer) = self.project().layer_of(target)
|
||||
{
|
||||
self.layer = layer;
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
|
||||
return Task::none();
|
||||
}
|
||||
state.error = Some(self.status.clone());
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.clone();
|
||||
state.error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.workflow = Some(workflow);
|
||||
return Task::none();
|
||||
}
|
||||
let stay_in_catalog = self.page == Page::Catalog
|
||||
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
|
||||
let transition_focus = match &workflow {
|
||||
@@ -1829,19 +1384,6 @@ impl App {
|
||||
self.fit();
|
||||
}
|
||||
}
|
||||
Message::Data(event) => {
|
||||
if self.data_state.update(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
match crate::data_view::command(self.project(), self.layer, event) {
|
||||
Ok(command) => {
|
||||
self.execute(command);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::CreateKind(kind) => self.create_kind = kind,
|
||||
Message::Add(child) => {
|
||||
if self.apply() {
|
||||
@@ -1936,9 +1478,6 @@ impl App {
|
||||
Ok(Some(command)) => {
|
||||
if self.execute(command) {
|
||||
self.behavior.acknowledge_commit();
|
||||
if self.topology_context().is_some() {
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -2510,9 +2049,6 @@ impl App {
|
||||
return self.handle(Message::Save(false));
|
||||
}
|
||||
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() {
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
@@ -2564,9 +2100,6 @@ impl App {
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
self.hosted.session = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.uncertain = None;
|
||||
self.document.mark_saved(receipt.snapshot);
|
||||
self.prune_closed_files(!self.document.dirty());
|
||||
self.status = "All source files saved".into();
|
||||
@@ -2670,16 +2203,7 @@ impl App {
|
||||
Err(e) => e,
|
||||
};
|
||||
}
|
||||
Message::WindowResized(size) => {
|
||||
// The canvas can retain its capped size during a real window
|
||||
// resize. Invalidate any sequence pointer/slot gesture anyway.
|
||||
if self.window_size != size
|
||||
&& (self.scenario_context().is_some() || self.topology_context().is_some())
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.window_size = size;
|
||||
}
|
||||
Message::WindowResized(size) => self.window_size = size,
|
||||
Message::CanvasResized(size) => {
|
||||
let first_measurement = self.canvas_size.is_none();
|
||||
let size_changed = self.canvas_size != Some(size);
|
||||
@@ -2905,7 +2429,6 @@ impl App {
|
||||
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
||||
self.pending = None;
|
||||
match pending {
|
||||
Pending::HostedOpen => self.open_hosted(),
|
||||
Pending::New => {
|
||||
self.reset(Project::empty(), None);
|
||||
Task::none()
|
||||
@@ -3054,20 +2577,10 @@ impl App {
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
if self.migration.is_some() {
|
||||
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||||
self.page = Page::Validation;
|
||||
} else {
|
||||
self.status =
|
||||
"Source project opened. Included files and comments are preserved.".into();
|
||||
}
|
||||
self.status =
|
||||
"Source project opened. Included files and comments are preserved.".into();
|
||||
}
|
||||
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_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
@@ -3695,9 +3208,7 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
||||
disk.expectations.get(&disk.bundle.entry)
|
||||
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
||||
// Source1/2 require the historical single-document adapters.
|
||||
// Source3+ must retain its authored bundle and token migration.
|
||||
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
|
||||
&& decoded.migration.is_some()
|
||||
{
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
@@ -3782,97 +3293,6 @@ mod source_project_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
|
||||
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
|
||||
for split in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("project.arcadia");
|
||||
let mut authored = std::collections::BTreeMap::new();
|
||||
if split {
|
||||
let start = original.find("{\n").unwrap() + 2;
|
||||
let end = original.rfind('}').unwrap();
|
||||
authored.insert(
|
||||
"project.arcadia",
|
||||
format!(
|
||||
"{} include \"dictionary.arcadia\";\n}}\n",
|
||||
&original[..start]
|
||||
),
|
||||
);
|
||||
authored.insert("dictionary.arcadia", original[start..end].to_owned());
|
||||
} else {
|
||||
authored.insert("project.arcadia", original.to_owned());
|
||||
}
|
||||
for (file, source) in &authored {
|
||||
std::fs::write(directory.0.join(file), source).unwrap();
|
||||
}
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
);
|
||||
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.path, Some(path.clone()));
|
||||
assert_eq!(app.document.bundle().files.len(), authored.len());
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"accepted source migration needs explicit saving"
|
||||
);
|
||||
for (file, source) in &authored {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||
*source
|
||||
);
|
||||
}
|
||||
let text = app
|
||||
.document
|
||||
.bundle()
|
||||
.files
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<String>();
|
||||
assert!(text.contains("// Reused record parameters retain their own identities."));
|
||||
assert!(text.contains("// Frozen authored schema3 compatibility input."));
|
||||
let packet = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "packet")
|
||||
.unwrap()
|
||||
.id;
|
||||
let origin = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "origin")
|
||||
.unwrap()
|
||||
.id;
|
||||
assert_eq!(
|
||||
app.document.compiled().index.lexical_owners.get(&origin),
|
||||
Some(&packet)
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
save(&mut app, path.clone()).unwrap();
|
||||
assert!(!app.dirty());
|
||||
for (file, source) in &before.bundle.files {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||
*source
|
||||
);
|
||||
}
|
||||
let mut reopened = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut reopened,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
|
||||
);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.document.bundle(), app.document.bundle());
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
|
||||
let path = directory.0.join("project.arcadia");
|
||||
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
|
||||
@@ -4427,50 +3847,3 @@ mod lint_tests {
|
||||
assert!(app.project().elements.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_paging_lifecycle_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
|
||||
let mut p = Project::empty();
|
||||
let mut oa = None;
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for index in 0..70 {
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Interface,
|
||||
layer,
|
||||
&format!("Interface {layer} {index}"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
if layer == Layer::Oa {
|
||||
oa = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut app = App::with_project(p.clone());
|
||||
let page = Message::Data(crate::data_view::Event::Page {
|
||||
section: crate::data_view::Section::Catalog,
|
||||
owner: None,
|
||||
page: 2,
|
||||
});
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(page.clone());
|
||||
let retained = app.data_state.clone();
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(Message::Layer(Layer::Oa));
|
||||
assert_eq!(app.data_state, retained);
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
let _ = app.update(page.clone());
|
||||
let _ = app.update(Message::Select(oa.unwrap()));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
let _ = app.update(page);
|
||||
app.reset(p, None);
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,18 +322,14 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
|
||||
Message::Field("/name".into(), "Unapplied state name".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let context = behavior::topology::Context {
|
||||
document: app.canvas_document,
|
||||
owner: fixture.machine,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
};
|
||||
for event in [
|
||||
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
|
||||
900., 600.,
|
||||
))),
|
||||
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
|
||||
behavior::Event::Topology(behavior::topology::Event::Fit),
|
||||
] {
|
||||
send(app, Message::TopologyCanvas(event));
|
||||
send(app, Message::Behavior(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
|
||||
@@ -1,568 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,817 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, CanvasEvent};
|
||||
impl App {
|
||||
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
|
||||
let owner = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.project().elements.get(&owner)?.kind(),
|
||||
Kind::Process | Kind::FunctionalChain
|
||||
))
|
||||
.then_some(topology::Context {
|
||||
document: self.canvas_document,
|
||||
owner,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.viewport(size)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand(self.project(), context.owner, id);
|
||||
self.behavior.topology = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Open(_, occurrence) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.select(target);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Back(_) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let back = state.back(self.project());
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the process.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.begin(self.project(), context, tool, control, insertion);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status="Choose the contextual contribution and apply at the captured position.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::AppendExchange(_) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.append_exchange(self.project(), context.owner);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Connect { source, target, .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.connect(self.project(), context.owner, source, target);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Describe the path condition, then Create.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reconnect {
|
||||
link, end, target, ..
|
||||
} => {
|
||||
match topology::reconnect(self.project(), context.owner, link, end, target)
|
||||
{
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = link;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
topology::{
|
||||
CanvasTool, Event, Insertion, Tool,
|
||||
direct_tests::{self, Fixture},
|
||||
},
|
||||
};
|
||||
fn setup() -> (App, Fixture) {
|
||||
let f = direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
|
||||
}
|
||||
fn begin(app: &App) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.topology_context().unwrap(),
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = direct_tests::empty_exchange_fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let empty = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Exchange,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.exchange,
|
||||
label: "Assessment".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("First contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let first_id = app.selected;
|
||||
let first = app.document.snapshot();
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id]
|
||||
);
|
||||
assert_eq!(app.project().relations, empty.decoded.project.relations);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
|
||||
context,
|
||||
)));
|
||||
assert!(
|
||||
app.behavior.has_pending(),
|
||||
"reachable single-exchange continuation: {}",
|
||||
app.status
|
||||
);
|
||||
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: next,
|
||||
label: "Response".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Second contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let second_id = app.selected;
|
||||
let second = app.document.snapshot();
|
||||
assert_ne!(first_id, second_id);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id, second_id]
|
||||
);
|
||||
let links = app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::SequenceLink)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(
|
||||
(links[0].source(), links[0].target()),
|
||||
(first_id, second_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
empty.decoded.project.elements.len() + 2
|
||||
);
|
||||
for (id, relation) in &empty.decoded.project.relations {
|
||||
assert_eq!(app.project().relations[id], *relation);
|
||||
}
|
||||
for work in f.work {
|
||||
assert_eq!(
|
||||
app.project().elements[&work],
|
||||
empty.decoded.project.elements[&work]
|
||||
);
|
||||
}
|
||||
let reopened = Document::from_bundle(
|
||||
second.bundle.clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.bundle(), &second.bundle);
|
||||
for expected in [&first, &empty] {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"each Create must add exactly one history entry"
|
||||
);
|
||||
for expected in [&first, &second] {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
|
||||
let (mut app, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
for field in 0..4 {
|
||||
let mut event = begin(&app);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match field {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.owner = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
};
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
for event in [
|
||||
CanvasEvent::Zoom(context, Some(0.7)),
|
||||
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
|
||||
let (mut app, f) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 3000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "Assess".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_ne!(app.selected, f.owner);
|
||||
assert_eq!(app.project().named(app.selected), "Recovered contribution");
|
||||
let created = app.selected;
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert!(app.project().contains(created));
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Connect,
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let other = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let other_id = other.id;
|
||||
assert!(app.execute(Command::CreateElement(other)));
|
||||
let _ = app.update(Message::BehaviorOwner(Choice {
|
||||
id: other_id,
|
||||
label: "Other".into(),
|
||||
}));
|
||||
assert_eq!(app.topology_context().unwrap().owner, other_id);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
|
||||
context,
|
||||
source: f.nodes[0],
|
||||
target: f.nodes[2],
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
let mut stale = context;
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
snapshot: original.clone(),
|
||||
path: root.join(&original.bundle.entry),
|
||||
disk: Some(source_files::DiskProject {
|
||||
root,
|
||||
bundle: original.bundle.clone(),
|
||||
expectations: Default::default(),
|
||||
reachable: original.bundle.files.keys().cloned().collect(),
|
||||
diagnostics: vec![],
|
||||
}),
|
||||
};
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(&mut app, Event::Name("Keep this form".into()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 80.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
|
||||
let (mut app, f) = setup();
|
||||
let source = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
assert!(app.execute(Command::CreateElement(source)));
|
||||
let mut occurrence = app
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
|
||||
.unwrap();
|
||||
let occurrence_id = occurrence.id;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
|
||||
*body = OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
};
|
||||
}
|
||||
assert!(app.execute(Command::CreateElement(occurrence)));
|
||||
let snapshot = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
|
||||
context,
|
||||
occurrence_id,
|
||||
)));
|
||||
assert_eq!(app.selected, source_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, source_id);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(app.selected, occurrence_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let original = app.document.snapshot();
|
||||
let edited = format!(
|
||||
"{}\n// Retained source draft\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(edited.clone().into()),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_workspace.text(), edited);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
}
|
||||
+58
-1668
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,8 @@
|
||||
//! Process/chain authoring and projection use contextual occurrences and explicit
|
||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||
use super::*;
|
||||
use iced::widget::{Column, text_input};
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
mod direct;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) mod direct_tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -48,8 +40,9 @@ pub enum Event {
|
||||
Guard(String),
|
||||
AlternateGuard(String),
|
||||
Control(ControlChoice),
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -66,25 +59,11 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
struct Draft {
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
browsers: [Browser; 4],
|
||||
owner: Id,
|
||||
tool: Tool,
|
||||
name: String,
|
||||
@@ -120,6 +99,9 @@ impl State {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
Event::Start(owner, tool, selected) => {
|
||||
let e = p
|
||||
.elements
|
||||
@@ -145,9 +127,6 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool,
|
||||
name: String::new(),
|
||||
@@ -175,11 +154,6 @@ impl State {
|
||||
Event::Guard(value) => d.guard = value,
|
||||
Event::AlternateGuard(value) => d.alternate_guard = value,
|
||||
Event::Control(value) => d.control = value.0,
|
||||
Event::Search(index, value) if index < 4 => {
|
||||
d.browsers[index].search = value;
|
||||
d.browsers[index].page = 0;
|
||||
}
|
||||
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -189,13 +163,12 @@ impl State {
|
||||
}
|
||||
|
||||
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||
if !self.fit {
|
||||
return self.zoom.unwrap_or(1.);
|
||||
}
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
self.zoom.unwrap_or_else(|| {
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
})
|
||||
}
|
||||
}
|
||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
||||
@@ -274,24 +247,13 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||
label: format!("{} · {}", e.name, e.alias),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
fn semantic_path(p: &Project, id: Id) -> String {
|
||||
let mut path = Vec::new();
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
let Some(e) = p.elements.get(&id) else { break };
|
||||
path.push(e.alias.as_str());
|
||||
current = e.parent;
|
||||
}
|
||||
path.reverse();
|
||||
path.join(" / ")
|
||||
}
|
||||
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
||||
p.elements
|
||||
.get(&owner)
|
||||
@@ -432,40 +394,6 @@ impl Draft {
|
||||
if self.before.is_some() && self.before == self.after {
|
||||
return Err("Choose different preceding and following contributions.".into());
|
||||
}
|
||||
let captured = self
|
||||
.insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, self.owner, r.source())
|
||||
&& is_contribution(p, self.owner, r.target())
|
||||
})
|
||||
.ok_or("The captured path no longer exists in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if captured
|
||||
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
|
||||
{
|
||||
return Err("The captured path changed. Choose its position again.".into());
|
||||
}
|
||||
let matches: Vec<_> = sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
|
||||
.collect();
|
||||
let split_link = if self.tool == Tool::Connect {
|
||||
None
|
||||
} else if captured.is_some() {
|
||||
captured
|
||||
} else if !self.fixed && matches.len() == 1 {
|
||||
Some(matches[0])
|
||||
} else if !self.fixed && matches.len() > 1 {
|
||||
return Err("Select the specific sequence link to insert on.".into());
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut commands = preserved_order(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -490,6 +418,7 @@ impl Draft {
|
||||
{
|
||||
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
|
||||
}
|
||||
require_guard(p, source, &self.guard)?;
|
||||
commands.push(link(p, source, target, &self.guard));
|
||||
return Ok(unique_batch(p, commands));
|
||||
}
|
||||
@@ -530,6 +459,12 @@ impl Draft {
|
||||
return Err("Choose same-layer work for the second path.".into());
|
||||
}
|
||||
let parallel = self.control == ControlKind::Fork;
|
||||
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Describe both alternative guards; use 'else' for the fallback path.".into(),
|
||||
);
|
||||
}
|
||||
let split = occurrence(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -617,47 +552,30 @@ impl Draft {
|
||||
commands.push(Command::CreateElement(e));
|
||||
(id, id)
|
||||
};
|
||||
if let Some(existing) = split_link {
|
||||
let mut updated = existing.clone();
|
||||
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
||||
*target = first;
|
||||
}
|
||||
commands.push(Command::EditRelation(existing.id, updated));
|
||||
} else if let Some(before) = self.before {
|
||||
commands.push(link(
|
||||
p,
|
||||
before,
|
||||
first,
|
||||
if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
},
|
||||
));
|
||||
if let Some(before) = self.before {
|
||||
// Branch guards belong to the split's two outgoing paths. A branch
|
||||
// inserted after another choice must first use an explicit link.
|
||||
let guard = if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
};
|
||||
require_guard(p, before, guard)?;
|
||||
commands.push(link(p, before, first, guard));
|
||||
}
|
||||
if let Some(after) = self.after {
|
||||
commands.push(link(p, last, after, ""));
|
||||
}
|
||||
let new_ids: Vec<_> = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut order = owner.steps().expect("process steps").clone();
|
||||
let slot = self.insertion.before.or(self.after);
|
||||
let index = slot
|
||||
.map(|id| {
|
||||
order
|
||||
.iter()
|
||||
.position(|v| *v == id)
|
||||
.ok_or("The presentation slot changed.")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(order.len());
|
||||
order.splice(index..index, new_ids);
|
||||
commands.push(Command::SetSteps(self.owner, order));
|
||||
// Inserting between two selected contributions replaces their direct
|
||||
// precedence while preserving other links and their semantic identities.
|
||||
if let (Some(before), Some(after)) = (self.before, self.after) {
|
||||
commands.extend(
|
||||
sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| r.source() == before && r.target() == after)
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
}
|
||||
Ok(unique_batch(p, commands))
|
||||
}
|
||||
}
|
||||
@@ -680,6 +598,23 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
||||
}
|
||||
Command::Batch(commands)
|
||||
}
|
||||
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
|
||||
if guard.trim().is_empty()
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice | ControlKind::Loop
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
Err("Describe this choice / repeat condition, or enter 'else'.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(e: Event) -> Message {
|
||||
Message::Behavior(if matches!(e, Event::Submit) {
|
||||
super::Event::Submit
|
||||
@@ -687,9 +622,307 @@ fn event(e: Event) -> Message {
|
||||
super::Event::Topology(e)
|
||||
})
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
view::view(app, owner, out)
|
||||
fn picker<'a>(
|
||||
choices: Vec<Choice>,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
pick_list(choices, selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
if !p.elements[&owner].description.is_empty() {
|
||||
out = out.push(
|
||||
text(&p.elements[&owner].description)
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
|
||||
action(
|
||||
"+ Exchange",
|
||||
event(Event::Start(owner, Tool::Exchange, selected))
|
||||
),
|
||||
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
action(
|
||||
"+ Control",
|
||||
event(Event::Start(owner, Tool::Control, selected))
|
||||
),
|
||||
action(
|
||||
"Branch / parallel…",
|
||||
event(Event::Start(owner, Tool::Branch, selected))
|
||||
),
|
||||
action(
|
||||
"Connect…",
|
||||
event(Event::Start(owner, Tool::Connect, selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(owner)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.topology
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
|
||||
let legacy = legacy_order(p, owner);
|
||||
if legacy.len() > 1 {
|
||||
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices: Vec<_> = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|choice| compatible_source(p, d, choice.id))
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d.target,
|
||||
"Choose an existing source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source name)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d.alternate,
|
||||
"Work on the second path…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form.push(
|
||||
row![
|
||||
text_input("First path condition", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
text_input("Second path condition", &d.alternate_guard)
|
||||
.on_input(|s| event(Event::AlternateGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.before,
|
||||
if d.tool == Tool::Connect {
|
||||
"From contribution…"
|
||||
} else {
|
||||
"After contribution (optional)…"
|
||||
},
|
||||
Event::Before
|
||||
),
|
||||
action("Clear", event(Event::ClearBefore)),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.after,
|
||||
if d.tool == Tool::Connect {
|
||||
"To contribution…"
|
||||
} else {
|
||||
"Before contribution (optional)…"
|
||||
},
|
||||
Event::After
|
||||
),
|
||||
action("Clear", event(Event::ClearAfter)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if d.tool != Tool::Branch {
|
||||
form = form.push(
|
||||
text_input("Sequence guard (for a choice or loop)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if let Err(problem) = d.command(p) {
|
||||
form = form.push(label(problem));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|
||||
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
|
||||
{
|
||||
let mut graph = Graph::new(p, owner, app.selected);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(container(column![
|
||||
text("Start this process").size(18),
|
||||
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
|
||||
].spacing(12)).padding(24).width(Fill).style(style::card));
|
||||
return scrollable(out.padding(24)).into();
|
||||
} else if graph.edges.is_empty() {
|
||||
out = out.push(label(
|
||||
"No explicit sequence links. Connect contributions to describe topology.",
|
||||
));
|
||||
}
|
||||
graph.scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let scale = graph.scale;
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
out = out.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.))),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
} else {
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
let conversion = preserved_order(p, owner, None);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · the stored contribution order defines this sequence.",
|
||||
));
|
||||
if !conversion.is_empty() {
|
||||
out = out.push(action(
|
||||
"Convert ordered path to explicit links",
|
||||
Message::Command(unique_batch(p, conversion)),
|
||||
));
|
||||
}
|
||||
(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(canvas)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(200., 470.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|size| event(Event::Viewport(size)))
|
||||
.on_resize(|size| event(Event::Viewport(size))),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(target),
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
|
||||
{
|
||||
out = out.push(action(
|
||||
&format!("Open source: {} →", p.named(*target)),
|
||||
Message::Select(*target),
|
||||
));
|
||||
}
|
||||
for r in sequence_links(p, owner) {
|
||||
let guard = match &r.body {
|
||||
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
out = out.push(
|
||||
action(
|
||||
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
|
||||
Message::Select(r.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
@@ -709,7 +942,6 @@ struct GraphNode {
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
@@ -719,21 +951,15 @@ struct GraphEdge {
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
}
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
struct Graph {
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
impl<'a> Graph<'a> {
|
||||
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
|
||||
Self::expanded(p, owner, selected, &BTreeSet::new())
|
||||
}
|
||||
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
||||
impl Graph {
|
||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -774,17 +1000,17 @@ impl<'a> Graph<'a> {
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
for c in choices {
|
||||
let e = &p.elements[&c.id];
|
||||
let rank = ranks[&c.id];
|
||||
let slot = rows.entry(rank).or_insert(24.);
|
||||
let slot = rows.entry(rank).or_default();
|
||||
let mut node = GraphNode {
|
||||
id: e.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(24. + rank as f32 * 420., *slot),
|
||||
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
|
||||
Size::new(270., 104.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
@@ -793,8 +1019,8 @@ impl<'a> Graph<'a> {
|
||||
source: None,
|
||||
preview: vec![],
|
||||
preview_links: vec![],
|
||||
preview_total: 0,
|
||||
};
|
||||
*slot += 1;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||
match body {
|
||||
OccurrenceBody::Action { action, .. } => {
|
||||
@@ -825,19 +1051,11 @@ impl<'a> Graph<'a> {
|
||||
.map(|id| format!("ref · {}", p.named(id)))
|
||||
.unwrap_or("Choose source in Properties".into());
|
||||
if let Some(source) = sequence {
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
node.preview = contributions(p, *source)
|
||||
.into_iter()
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.take(4)
|
||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||
.collect();
|
||||
node.rect.height =
|
||||
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
||||
let preview_ids: BTreeSet<_> =
|
||||
node.preview.iter().map(|(id, _)| *id).collect();
|
||||
node.preview_links = sequence_links(p, *source)
|
||||
@@ -853,13 +1071,11 @@ impl<'a> Graph<'a> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
*slot += node.rect.height + 36.;
|
||||
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
let a = nodes
|
||||
.iter()
|
||||
@@ -873,14 +1089,11 @@ impl<'a> Graph<'a> {
|
||||
.rect;
|
||||
let start = Point::new(a.x + a.width, a.center_y());
|
||||
let end = Point::new(b.x, b.center_y());
|
||||
// Parallel records keep distinct visible paths and guard hit targets.
|
||||
// Their semantic endpoints and IDs remain unchanged.
|
||||
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
|
||||
let points = if forward {
|
||||
let points = if end.x > start.x {
|
||||
let mid = (start.x + end.x) / 2.;
|
||||
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
||||
} else {
|
||||
let below = size.height + 24. + i as f32 * 42.;
|
||||
let below = size.height + 24. + i as f32 * 30.;
|
||||
vec![
|
||||
start,
|
||||
Point::new(start.x + 18., start.y),
|
||||
@@ -894,7 +1107,7 @@ impl<'a> Graph<'a> {
|
||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let label_at = if forward {
|
||||
let label_at = if end.x > start.x {
|
||||
Point::new(end.x - 138., end.y - 43.)
|
||||
} else {
|
||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||
@@ -915,9 +1128,6 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
size,
|
||||
@@ -994,24 +1204,47 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
impl canvas::Program<Message> for Graph<'_> {
|
||||
type State = Input;
|
||||
impl canvas::Program<Message> for Graph {
|
||||
type State = ();
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
_: &mut (),
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
self.input(state, event, bounds, cursor)
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && let Some(point) = cursor.position_in(bounds)
|
||||
{
|
||||
let point = Point::new(point.x / self.scale, point.y / self.scale);
|
||||
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
|
||||
let id = if point.y > n.rect.y + 75. {
|
||||
n.source.unwrap_or(n.id)
|
||||
} else {
|
||||
n.id
|
||||
};
|
||||
return Some(Action::publish(Message::Select(id)).and_capture());
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points
|
||||
.windows(2)
|
||||
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
|
||||
}) {
|
||||
return Some(Action::publish(Message::Select(e.id)).and_capture());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Input,
|
||||
_: &(),
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
@@ -1094,7 +1327,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
"Open source →".into(),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
@@ -1167,12 +1400,11 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.overlay(state, &mut f, cursor, bounds);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_: &Input,
|
||||
_: &(),
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
@@ -1246,9 +1478,6 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool: Tool::Branch,
|
||||
name: String::new(),
|
||||
@@ -1437,7 +1666,7 @@ mod tests {
|
||||
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
||||
session.execute(command).unwrap();
|
||||
let result = session.project();
|
||||
assert!(result.relations.contains_key(&old_link));
|
||||
assert!(!result.relations.contains_key(&old_link));
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
assert_eq!(sequence_links(result, owner).len(), 6);
|
||||
assert_eq!(
|
||||
@@ -1548,26 +1777,10 @@ mod tests {
|
||||
draft: Some(branch(owner, a, b, before, after)),
|
||||
..State::default()
|
||||
};
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: Id::nil(),
|
||||
label: "Missing".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.update(&p, Event::Guard(String::new())).unwrap();
|
||||
assert!(state.update(&p, Event::Submit).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: a,
|
||||
label: "Work".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
@@ -1623,8 +1836,9 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
let mut state = State::default();
|
||||
state.viewport(Size::new(486., 460.));
|
||||
state.zoom(None);
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
||||
.unwrap();
|
||||
graph.scale = state.scale(graph.size, 500.);
|
||||
assert!(graph.size.width * graph.scale <= 462.1);
|
||||
assert!(graph.size.height * graph.scale <= 450.1);
|
||||
@@ -1635,7 +1849,7 @@ mod tests {
|
||||
);
|
||||
let result = canvas::Program::update(
|
||||
&graph,
|
||||
&mut Input::default(),
|
||||
&mut (),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||
mouse::Cursor::Available(click),
|
||||
@@ -1644,10 +1858,12 @@ mod tests {
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||
state.zoom(Some(1.));
|
||||
state.viewport(Size::new(800., 460.));
|
||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
||||
.unwrap();
|
||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||
state.zoom(None);
|
||||
state.update(&p, Event::Fit).unwrap();
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Insertion {
|
||||
pub link: Option<Id>,
|
||||
pub before: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CanvasTool {
|
||||
#[default]
|
||||
Select,
|
||||
Add(Tool, ControlKind),
|
||||
Connect,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum End {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
},
|
||||
Connect {
|
||||
context: Context,
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
Reconnect {
|
||||
context: Context,
|
||||
link: Id,
|
||||
end: End,
|
||||
target: Id,
|
||||
},
|
||||
Reorder {
|
||||
context: Context,
|
||||
occurrence: Id,
|
||||
before: Id,
|
||||
},
|
||||
Expand(Context, Id),
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
| Self::Reconnect { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
self.expanded.clear();
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
// Choosing how to edit must retain the user's current view.
|
||||
self.tool = tool;
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
}
|
||||
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
if !is_contribution(p, owner, id)
|
||||
|| !matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.expanded.remove(&id) {
|
||||
self.expanded.insert(id);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
commands.iter().find_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn begin(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !matches!(
|
||||
tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, context.owner, r.source())
|
||||
&& is_contribution(p, context.owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if insertion
|
||||
.before
|
||||
.is_some_and(|id| !is_contribution(p, context.owner, id))
|
||||
{
|
||||
return Err("Choose a presentation slot in this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(context.owner, tool, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.insertion = insertion;
|
||||
d.control = control;
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
|
||||
let last = legacy_order(p, owner)
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or("Choose an ordered exchange path to append to.")?;
|
||||
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
|
||||
self.draft.as_mut().expect("started form").fixed = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn connect(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !is_contribution(p, owner, source)
|
||||
|| !is_contribution(p, owner, target)
|
||||
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
|
||||
{
|
||||
return Err("Choose compatible contributions directly owned by this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(owner, Tool::Connect, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.before = Some(source);
|
||||
d.after = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
||||
if !is_contribution(p, owner, occurrence) {
|
||||
return None;
|
||||
}
|
||||
match p.elements[&occurrence].shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { action, .. },
|
||||
..
|
||||
} => *action,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
} => *exchange,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn reconnect(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
end: End,
|
||||
endpoint: Id,
|
||||
) -> Result<Command, String> {
|
||||
let mut r = p
|
||||
.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, owner, r.source())
|
||||
&& is_contribution(p, owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")?
|
||||
.clone();
|
||||
if !is_contribution(p, owner, endpoint) {
|
||||
return Err("Choose a local contribution endpoint.".into());
|
||||
}
|
||||
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
|
||||
match end {
|
||||
End::Source => *source = endpoint,
|
||||
End::Target => *target = endpoint,
|
||||
}
|
||||
}
|
||||
let command = Command::EditRelation(id, r);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Input {
|
||||
context: Option<Context>,
|
||||
tool: CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
Connect {
|
||||
source: Id,
|
||||
first: bool,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reconnect {
|
||||
link: Id,
|
||||
end: End,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reorder {
|
||||
occurrence: Id,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Add {
|
||||
insertion: Insertion,
|
||||
},
|
||||
}
|
||||
impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points.windows(2).any(|v| {
|
||||
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
|
||||
Size::new(23., 24.),
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
.find(|(p, _)| p.distance(point) < 10. / self.scale)
|
||||
.map(|(_, end)| (e.id, end))
|
||||
}
|
||||
fn connectable(&self, source: Id, target: Id) -> bool {
|
||||
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
|
||||
}
|
||||
pub(super) fn input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
(Some(c), t, e)
|
||||
});
|
||||
if state.context != context
|
||||
|| state.tool != tool
|
||||
|| state.bounds.is_some_and(|b| b != bounds)
|
||||
{
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
state.context = context;
|
||||
state.tool = tool;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(
|
||||
Press::Connect { start, moved, .. }
|
||||
| Press::Reconnect { start, moved, .. }
|
||||
| Press::Reorder { start, moved, .. },
|
||||
) = &mut state.press
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
|
||||
) => {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => {
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
let point = world?;
|
||||
let Some(context) = context else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture());
|
||||
};
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
insertion: Insertion {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
},
|
||||
});
|
||||
}
|
||||
CanvasTool::Connect => {
|
||||
let first = state.source.is_none();
|
||||
let source = state.source.or_else(|| self.node_at(point))?;
|
||||
state.source = Some(source);
|
||||
state.press = Some(Press::Connect {
|
||||
source,
|
||||
first,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
{
|
||||
state.press = Some(Press::Reorder {
|
||||
occurrence: self.selected,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.and_then(|id| publish(CanvasEvent::Select(context, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
Press::Add { insertion } if world.is_some() => {
|
||||
if let CanvasTool::Add(tool, control) = tool {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Begin {
|
||||
context,
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Connect {
|
||||
source,
|
||||
first,
|
||||
moved,
|
||||
..
|
||||
} if moved || !first => {
|
||||
if let Some(target) = world
|
||||
.and_then(|p| self.node_at(p))
|
||||
.filter(|target| self.connectable(source, *target))
|
||||
{
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Connect {
|
||||
context,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(target) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reconnect {
|
||||
context,
|
||||
link,
|
||||
end,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reorder {
|
||||
occurrence,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(before) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence,
|
||||
before,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlay(
|
||||
&self,
|
||||
state: &Input,
|
||||
f: &mut Frame,
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
for n in &self.nodes {
|
||||
if n.id == source || self.connectable(source, n.id) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
|
||||
draw_arrow(
|
||||
f,
|
||||
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
|
||||
style::TEAL,
|
||||
2.,
|
||||
);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reconnect { link, end, .. }) = state.press
|
||||
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
|
||||
{
|
||||
for node in &self.nodes {
|
||||
let (source, target) = match end {
|
||||
End::Source => (node.id, edge.target),
|
||||
End::Target => (edge.source, node.id),
|
||||
};
|
||||
if self.connectable(source, target) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(point) = point {
|
||||
let (from, to) = match end {
|
||||
End::Source => (point, *edge.points.last().unwrap()),
|
||||
End::Target => (edge.points[0], point),
|
||||
};
|
||||
draw_arrow(f, &[from, to], style::TEAL, 2.5);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reorder { occurrence, .. }) = state.press
|
||||
&& let Some(point) = point
|
||||
&& let Some(target) = self.node_at(point)
|
||||
{
|
||||
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
|
||||
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
|
||||
if source.rect.x == target.rect.x {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
Point::new(target.rect.x, target.rect.y - 7.),
|
||||
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
|
||||
),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
draw_text(
|
||||
f,
|
||||
"Order before · paths unchanged".into(),
|
||||
Point::new(target.rect.x, target.rect.y - 24.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
|
||||
for p in [e.points[0], *e.points.last().unwrap()] {
|
||||
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let CanvasTool::Add(..) = tool
|
||||
&& let Some(point) = point
|
||||
{
|
||||
let at = self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none());
|
||||
let caption = if let Some(id) = at {
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
"Append contribution · unconnected"
|
||||
};
|
||||
draw_text(
|
||||
f,
|
||||
caption.into(),
|
||||
Point::new(point.x + 12., point.y + 16.),
|
||||
12. / self.scale,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
pub(super) fn picker<'a>(
|
||||
mut choices: Vec<Choice>,
|
||||
d: &'a Draft,
|
||||
index: usize,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
let browser = &d.browsers[index];
|
||||
let query = browser.search.trim().to_lowercase();
|
||||
choices.retain(|c| c.label.to_lowercase().contains(&query));
|
||||
let range = choice_page(choices.len(), browser.page);
|
||||
let page = range.start / 24;
|
||||
let mut out = column![
|
||||
text_input("Search compatible choices…", &browser.search)
|
||||
.on_input(move |v| event(Event::Search(index, v)))
|
||||
.padding(7)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(selected) = selected
|
||||
.as_ref()
|
||||
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
|
||||
{
|
||||
out = out.push(label(format!("Selected: {}", selected.label)));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
|
||||
text(format!(
|
||||
"{} choices · {}/{}",
|
||||
choices.len(),
|
||||
page + 1,
|
||||
choices.len().div_ceil(24).max(1)
|
||||
))
|
||||
.size(11),
|
||||
button("Next").on_press_maybe(
|
||||
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
out.push(
|
||||
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
// Keep the canvas at the same sibling position when its palette becomes a
|
||||
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||
let mut controls = Column::new().spacing(14);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
out = out.push(controls);
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
} else {
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
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) {
|
||||
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)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
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;
|
||||
@@ -432,12 +432,11 @@ mod tests {
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
//! One address for a carried value, shared by scenario creation and property editing.
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FieldPathKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
pub picker_label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.picker_label)
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
|
||||
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|
||||
|| match phase {
|
||||
MessageKind::Call => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
|
||||
let qualified = entry
|
||||
.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&entry.field))
|
||||
.map(|id| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
ParameterDirection::Unspecified => " · direction unspecified — review",
|
||||
ParameterDirection::In => " · In",
|
||||
ParameterDirection::Out => " · Out",
|
||||
ParameterDirection::InOut => " · InOut",
|
||||
ParameterDirection::Return => " · Return",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let terminal = qualified
|
||||
.last()
|
||||
.expect("A field path always has a terminal");
|
||||
let owner_path = qualified[..qualified.len() - 1].join(" → ");
|
||||
let picker_label = format!(
|
||||
"{terminal}{direction} — {}{}",
|
||||
project.named(entry.item),
|
||||
if owner_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" / {owner_path}")
|
||||
},
|
||||
);
|
||||
FieldPathChoice {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
field: entry.field,
|
||||
},
|
||||
label: format!(
|
||||
"{} / {}{direction}",
|
||||
project.named(entry.item),
|
||||
qualified.join(" → ")
|
||||
),
|
||||
picker_label,
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded picker must expose its distinguishing field before a long path.
|
||||
// Keep the full address separately for the wrapping detail text.
|
||||
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
|
||||
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn choices(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(sorted_choices(
|
||||
data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
sorted_choices(
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
data::effective_fields(project, *item)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some(data::CarriedFieldPath {
|
||||
item: *item,
|
||||
path: vec![],
|
||||
field: field.id,
|
||||
direction: *direction,
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Browse one record at a time, including explicitly chosen recursive hops.
|
||||
pub fn at_scope(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
scope: Option<&FieldPathKey>,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
match scope {
|
||||
Some(key) => children(project, exchange, phase, key),
|
||||
None => Ok(roots(project, exchange, phase)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
/// In particular, optional recursive Value records may be traversed repeatedly.
|
||||
pub fn resolve(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Option<FieldPathChoice> {
|
||||
data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
}
|
||||
|
||||
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
|
||||
let (&field, path) = key.path.split_last()?;
|
||||
Some(FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand exactly one selected record. This keeps recursive authoring explicit
|
||||
/// and finite, and never traverses Reference storage or manufactures value paths.
|
||||
pub fn children(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
storage: FieldStorage::Value,
|
||||
..
|
||||
}) = project.elements.get(&key.field).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if !data::is_record(project, *record) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
|
||||
return Err(format!(
|
||||
"A value path can contain at most {} enclosing fields.",
|
||||
data::MAX_VALUE_PATH_DEPTH
|
||||
));
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(sorted_choices(
|
||||
data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
project,
|
||||
data::CarriedFieldPath {
|
||||
item: entry.item,
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
direction: entry.direction,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub struct Payload {
|
||||
pub project: Project,
|
||||
pub exchange: Id,
|
||||
pub item: Id,
|
||||
pub leaf: Id,
|
||||
pub origin: Id,
|
||||
pub destination: Id,
|
||||
pub scenario: Id,
|
||||
}
|
||||
pub fn payload() -> Payload {
|
||||
let mut p = Project::empty();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
supertype: None,
|
||||
};
|
||||
let record = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
|
||||
.unwrap();
|
||||
let origin = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
|
||||
.unwrap();
|
||||
let destination = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref, direction) in [
|
||||
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
|
||||
(origin, TypeRef::Named(record), ParameterDirection::In),
|
||||
(destination, TypeRef::Named(record), ParameterDirection::Out),
|
||||
] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
direction,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
|
||||
source,
|
||||
target,
|
||||
items: vec![item],
|
||||
};
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
validate_candidate(&p).unwrap();
|
||||
Payload {
|
||||
project: p,
|
||||
exchange,
|
||||
item,
|
||||
leaf,
|
||||
origin,
|
||||
destination,
|
||||
scenario,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_choices_keep_reused_record_addresses_distinct() {
|
||||
let mut f = payload();
|
||||
assert_eq!(
|
||||
data::item_mechanism(&f.project, f.item),
|
||||
Some(ExchangeMechanism::Operation)
|
||||
);
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
call.iter()
|
||||
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
|
||||
let reply = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Reply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
reply
|
||||
.iter()
|
||||
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let both = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let leaves = both
|
||||
.iter()
|
||||
.filter(|c| c.key.field == f.leaf)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(leaves.len(), 2);
|
||||
assert_ne!(leaves[0].key, leaves[1].key);
|
||||
assert_ne!(leaves[0].label, leaves[1].label);
|
||||
let values = leaves
|
||||
.into_iter()
|
||||
.map(|c| (c.key.clone(), "1".to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
|
||||
let mut f = payload();
|
||||
if let Some(SharedBody::DataField {
|
||||
storage, direction, ..
|
||||
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
|
||||
{
|
||||
*storage = FieldStorage::Reference;
|
||||
*direction = ParameterDirection::Unspecified;
|
||||
}
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
|
||||
assert!(direct.key.path.is_empty());
|
||||
assert!(direct.label.contains("unspecified"));
|
||||
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
|
||||
}
|
||||
pub fn recursive_payload() -> (Payload, Id) {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let next = f
|
||||
.project
|
||||
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&next)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, next)
|
||||
}
|
||||
pub fn wide_payload() -> Payload {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
|
||||
for index in 0..count {
|
||||
let body = f.project.elements[&template].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Field {template} {index}"),
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
#[test]
|
||||
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
|
||||
let mut f = wide_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let direct = roots(&f.project, exchange, &MessageKind::Call);
|
||||
assert_eq!(direct.len(), 96);
|
||||
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
|
||||
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
|
||||
assert_eq!(representative.len(), 96 * 129);
|
||||
for root in &direct {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
|
||||
Some(root.clone())
|
||||
);
|
||||
}
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
|
||||
assert_eq!(nested.len(), 128);
|
||||
for child in &nested {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
|
||||
Some(child.clone())
|
||||
);
|
||||
assert!(child.label.ends_with(" · In"));
|
||||
}
|
||||
// A temporarily over-limit model still exposes its unrelated root fields;
|
||||
// the editor must not expand the whole graph before showing a choice.
|
||||
for index in 0..32 {
|
||||
let body = f.project.elements[&f.origin].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Extra {index}"),
|
||||
Some(f.item),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
|
||||
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
|
||||
assert_eq!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
|
||||
.unwrap()
|
||||
.len(),
|
||||
128
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let record = FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
};
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
|
||||
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
|
||||
assert_eq!(leaf.key.path, vec![f.origin, next]);
|
||||
assert!(
|
||||
!choices(&f.project, exchange, &MessageKind::Call)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key == leaf.key)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
assert_eq!(parent(&leaf.key), Some(record));
|
||||
let deepest = FieldPathKey {
|
||||
path: std::iter::once(f.origin)
|
||||
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
||||
.collect(),
|
||||
field: next,
|
||||
};
|
||||
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
|
||||
assert!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &deepest)
|
||||
.unwrap_err()
|
||||
.contains("128")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let context = FieldPathKey {
|
||||
path: vec![f.origin, next, next],
|
||||
field: next,
|
||||
};
|
||||
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
|
||||
let leaf = visible
|
||||
.iter()
|
||||
.find(|choice| choice.key.field == f.leaf)
|
||||
.unwrap();
|
||||
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
|
||||
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
|
||||
assert!(
|
||||
visible.windows(2).all(
|
||||
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
visible[0].to_string().chars().take(24).collect::<String>(),
|
||||
visible[1].to_string().chars().take(24).collect::<String>()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,7 +292,6 @@ impl DiagramCanvas<'_> {
|
||||
request.placement = Some(node);
|
||||
} else if let Some(edge) = self
|
||||
.hit_label(&scene.routed, local)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, local))
|
||||
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
|
||||
{
|
||||
request.object = edge;
|
||||
@@ -370,33 +369,6 @@ impl DiagramCanvas<'_> {
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
}
|
||||
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
||||
let p = self.world(p);
|
||||
routed
|
||||
.edges
|
||||
.values()
|
||||
.flat_map(|edge| {
|
||||
self.project
|
||||
.relations
|
||||
.get(&edge.id)
|
||||
.into_iter()
|
||||
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
|
||||
.map(move |end| (edge.id, end.label))
|
||||
})
|
||||
.filter(|(_, label)| {
|
||||
Rectangle::new(
|
||||
point(label.bounds.position),
|
||||
Size::new(label.bounds.size.width, label.bounds.size.height),
|
||||
)
|
||||
.contains(p)
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
p.distance(point(a.1.center))
|
||||
.total_cmp(&p.distance(point(b.1.center)))
|
||||
.then(a.0.cmp(&b.0))
|
||||
})
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
fn preview_diagram(&self, state: &State) -> Diagram {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
@@ -753,10 +725,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
state.drag = Some((id, p, position));
|
||||
state.preview = Some(position);
|
||||
Some(Action::publish(Message::SelectNode(id)).and_capture())
|
||||
} else if let Some(id) = self
|
||||
.hit_label(&scene.routed, p)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, p))
|
||||
{
|
||||
} else if let Some(id) = self.hit_label(&scene.routed, p) {
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
||||
if id == self.selected
|
||||
@@ -1041,37 +1010,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(relation) = self.project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, routed) {
|
||||
if end.navigable {
|
||||
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(a);
|
||||
p.line_to(tip);
|
||||
p.line_to(b);
|
||||
}),
|
||||
Stroke::default().with_color(color).with_width(1.7),
|
||||
);
|
||||
}
|
||||
let label = &end.label;
|
||||
f.fill_rectangle(
|
||||
point(label.bounds.position),
|
||||
Size::new(label.bounds.size.width, label.bounds.size.height),
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: label.text.clone(),
|
||||
position: point(label.center),
|
||||
color: if active { style::TEAL } else { style::MUTED },
|
||||
size: 13.into(),
|
||||
align_x: iced::widget::text::Alignment::Center,
|
||||
align_y: iced::alignment::Vertical::Center,
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
@@ -1319,17 +1257,6 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
|
||||
})
|
||||
})
|
||||
}
|
||||
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
|
||||
let tip = point(tip);
|
||||
let delta = point(inward) - tip;
|
||||
let direction = delta / delta.x.hypot(delta.y).max(0.001);
|
||||
let normal = Vector::new(-direction.y, direction.x) * 4.;
|
||||
[
|
||||
tip + direction * 9. + normal,
|
||||
tip,
|
||||
tip + direction * 9. - normal,
|
||||
]
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PortGlyph {
|
||||
Function,
|
||||
@@ -1617,21 +1544,6 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
if !label.text.is_empty() {
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
||||
}
|
||||
if let Some(relation) = p.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, route) {
|
||||
let side = if end.source_end { "source" } else { "target" };
|
||||
svg.push_str(&format!(
|
||||
"<g data-association=\"{}\" data-end=\"{side}\">",
|
||||
edge.id
|
||||
));
|
||||
if end.navigable {
|
||||
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
||||
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
|
||||
}
|
||||
let label = &end.label;
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
@@ -2441,98 +2353,6 @@ mod tests {
|
||||
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
|
||||
}
|
||||
#[test]
|
||||
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
|
||||
for self_loop in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::DataType, "Tree", 100., 100.);
|
||||
let b = if self_loop {
|
||||
a
|
||||
} else {
|
||||
p.add(Kind::DataType, "Branch", 650., 100.)
|
||||
};
|
||||
for id in BTreeSet::from([a, b]) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
|
||||
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
|
||||
source: a,
|
||||
target: b,
|
||||
source_role: "parent".into(),
|
||||
target_role: "children".into(),
|
||||
source_minimum: 0,
|
||||
source_maximum: Some(1),
|
||||
target_minimum: 0,
|
||||
target_maximum: None,
|
||||
source_navigable: false,
|
||||
target_navigable: true,
|
||||
};
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let projected = project_view(&p, diagram);
|
||||
assert!(
|
||||
!projected
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.id == edge)
|
||||
.unwrap()
|
||||
.oriented
|
||||
);
|
||||
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
||||
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
||||
assert_eq!(ends.len(), 2);
|
||||
let svg = export_svg(&p, diagram);
|
||||
assert!(svg.contains("parent [0..1]</text>"));
|
||||
assert!(svg.contains("children [0..*]</text>"));
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.7,
|
||||
pan: Vector::new(350., 350.),
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
};
|
||||
for end in ends {
|
||||
assert!(svg.contains(&format!(
|
||||
"x=\"{}\" y=\"{}\" text-anchor",
|
||||
end.label.center.x, end.label.center.y
|
||||
)));
|
||||
let at = Point::new(
|
||||
end.label.center.x * canvas.zoom + canvas.pan.x,
|
||||
end.label.center.y * canvas.zoom + canvas.pan.y,
|
||||
);
|
||||
let mut state = State::default();
|
||||
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
||||
);
|
||||
assert!(
|
||||
state.route_drag.is_none(),
|
||||
"Endpoint contract labels never drag the association name"
|
||||
);
|
||||
assert_eq!(
|
||||
canvas
|
||||
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
|
||||
.object,
|
||||
edge
|
||||
);
|
||||
canvas.selected = p.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
|
||||
let (mut p, diagram, edge) = route_fixture();
|
||||
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
|
||||
|
||||
+43
-490
@@ -14,7 +14,6 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -32,12 +31,6 @@ pub struct Input {
|
||||
pub raw: String,
|
||||
pub editor: text_editor::Content,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -45,11 +38,7 @@ pub struct Draft {
|
||||
baseline: Value,
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
context: Option<Value>,
|
||||
pub semantic_groups: bool,
|
||||
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -86,18 +75,12 @@ impl Draft {
|
||||
value,
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
context: None,
|
||||
semantic_groups: false,
|
||||
constructor_drafts: BTreeMap::new(),
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||
match value {
|
||||
@@ -146,32 +129,8 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
pub fn materialize(&self) -> Result<Value, String> {
|
||||
self.materialize_inputs(|_| true)
|
||||
}
|
||||
/// Read-only context for dependent controls. Workflow proposals never replace
|
||||
/// the authored value or its input buffers and are not used by command().
|
||||
pub fn context_value(&self) -> Value {
|
||||
self.context
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
|
||||
self.materialize_inputs(|path| {
|
||||
scopes
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
})
|
||||
}
|
||||
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
|
||||
let mut value = self.value.clone();
|
||||
for (path, input) in &self.inputs {
|
||||
if !include(path) {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = value.pointer_mut(path) else {
|
||||
continue;
|
||||
};
|
||||
@@ -242,166 +201,6 @@ impl Draft {
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
|
||||
/// including an invalid value which this replacement deliberately removes.
|
||||
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
*self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.ok_or("Property no longer exists.")? = value;
|
||||
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
||||
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>) {
|
||||
let previous = std::mem::take(&mut self.inputs);
|
||||
self.rebuild();
|
||||
for (path, input) in previous {
|
||||
if let Some(path) = new_path(&path)
|
||||
&& self.inputs.contains_key(&path)
|
||||
{
|
||||
self.inputs.insert(path, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn array_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.value.clone();
|
||||
let count = candidate
|
||||
.pointer(path)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("List no longer exists.")?
|
||||
.len();
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild_retaining(|old| {
|
||||
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
|
||||
return Some(old.to_owned());
|
||||
};
|
||||
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
|
||||
let position = number.parse::<usize>().ok()?;
|
||||
let position = match index {
|
||||
Some(index) if direction == 0 && position == index => return None,
|
||||
Some(index) if direction == 0 && position > index => position - 1,
|
||||
Some(index) if direction != 0 => {
|
||||
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
|
||||
if position == index {
|
||||
target
|
||||
} else if position == target {
|
||||
index
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
_ => position,
|
||||
};
|
||||
Some(if rest.is_empty() {
|
||||
format!("{path}/{position}")
|
||||
} else {
|
||||
format!("{path}/{position}/{rest}")
|
||||
})
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
self.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.rebuild_retaining(|path| Some(path.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let row = self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.rebuild_retaining(|old| {
|
||||
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
|
||||
.then(|| old.to_owned())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn array(
|
||||
&mut self,
|
||||
path: &str,
|
||||
@@ -409,34 +208,36 @@ impl Draft {
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
candidate
|
||||
let items = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let row = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
@@ -511,51 +312,6 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
|
||||
fn within(path: &str, root: &str) -> bool {
|
||||
path == root
|
||||
|| path
|
||||
.strip_prefix(root)
|
||||
.is_some_and(|tail| tail.starts_with('/'))
|
||||
}
|
||||
fn edit_array(
|
||||
value: &mut Value,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let items = value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn friendly(name: &str) -> String {
|
||||
match name {
|
||||
"id" => "UUID identity".into(),
|
||||
@@ -655,7 +411,6 @@ fn reference(path: &str) -> bool {
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
| "supertype"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -685,7 +440,6 @@ fn optional_reference(path: &str) -> bool {
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "supertype"
|
||||
)
|
||||
}
|
||||
fn multiline(path: &str) -> bool {
|
||||
@@ -708,22 +462,15 @@ fn free_string(path: &str) -> bool {
|
||||
multiline(path)
|
||||
|| matches!(
|
||||
path.rsplit('/').next(),
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit")
|
||||
)
|
||||
|| path.split('/').rev().nth(1) == Some("literals")
|
||||
|| (path.contains("/default/")
|
||||
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
|
||||
}
|
||||
|
||||
// These are choices for the closed model, not a second validation boundary.
|
||||
// A current reference is always retained, even while other draft fields make it
|
||||
// incompatible. Applying the complete draft remains the core validator's job.
|
||||
pub(crate) fn reference_choices(
|
||||
draft: &Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
current: Id,
|
||||
) -> Vec<Choice> {
|
||||
fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec<Choice> {
|
||||
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
|
||||
let compatible = reference_ids(draft, project, path);
|
||||
let mut objects = compatible
|
||||
@@ -775,7 +522,7 @@ pub(crate) fn reference_choices(
|
||||
}
|
||||
|
||||
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let value = draft.context_value();
|
||||
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||
let owner = value
|
||||
.get("element")
|
||||
@@ -915,42 +662,21 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.collect();
|
||||
}
|
||||
if field == "field" {
|
||||
let address_path = path
|
||||
.strip_suffix("/field")
|
||||
.and_then(|row| value.pointer(&format!("{row}/path")))
|
||||
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
return element
|
||||
.as_ref()
|
||||
.and_then(|e| match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
..
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id).and_then(|r| {
|
||||
let scope = address_path.split_last().map(|(&field, path)| {
|
||||
crate::data_values::FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
}
|
||||
});
|
||||
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||
}),
|
||||
} => project.relations.get(id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|choice| choice.key.path == address_path)
|
||||
.map(|choice| choice.key.field)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
.map(|r| validation::carried_fields(project, r).into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let scenario = element
|
||||
.as_ref()
|
||||
.and_then(|e| e.parent)
|
||||
@@ -981,40 +707,6 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"supertype" => element.as_ref().is_some_and(|owner| {
|
||||
if candidate.id == owner.id
|
||||
|| candidate.kind() != owner.kind()
|
||||
|| (candidate.kind() == Kind::DataType
|
||||
&& !data::is_record(project, candidate.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let (
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind, mechanism, ..
|
||||
}),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: base_kind,
|
||||
mechanism: base_mechanism,
|
||||
..
|
||||
}),
|
||||
) = (owner.shared(), candidate.shared())
|
||||
&& (kind != base_kind
|
||||
|| (*base_mechanism != ExchangeMechanism::Unspecified
|
||||
&& mechanism != base_mechanism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut next = Some(candidate.id);
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = next {
|
||||
if id == owner.id || !seen.insert(id) {
|
||||
return false;
|
||||
}
|
||||
next = project.elements.get(&id).and_then(data::supertype);
|
||||
}
|
||||
true
|
||||
}),
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
@@ -1366,21 +1058,6 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||
"storage" => &["Value", "Reference"],
|
||||
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
|
||||
"direction" if path.contains("DataField") => {
|
||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||
}
|
||||
"transmission" => &[
|
||||
"Unspecified",
|
||||
"Synchronous",
|
||||
"Asynchronous",
|
||||
"Unicast",
|
||||
"Multicast",
|
||||
"Broadcast",
|
||||
],
|
||||
"acquisition" => &["Unspecified", "Read", "Accept"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1389,7 +1066,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
],
|
||||
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
|
||||
"phase" => &["Entry", "Do", "Exit"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
|
||||
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
|
||||
"kind" if path.contains("State") => &[
|
||||
"State",
|
||||
@@ -1455,20 +1132,11 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
if field == "definition" {
|
||||
return vec![
|
||||
("Boolean".into(), json!("Boolean")),
|
||||
(
|
||||
"Integer".into(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Real".into(),
|
||||
json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Text".into(),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}}),
|
||||
),
|
||||
("Integer".into(), json!("Integer")),
|
||||
("Real".into(), json!("Real")),
|
||||
("Text".into(), json!("String")),
|
||||
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
|
||||
("Record".into(), json!({"Record":{"supertype":null}})),
|
||||
("Record".into(), json!("Record")),
|
||||
];
|
||||
}
|
||||
if field == "type_ref" {
|
||||
@@ -1597,22 +1265,6 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
content.into()
|
||||
}
|
||||
|
||||
/// Render one closed property subtree for a workflow-owned typed draft.
|
||||
/// The caller routes messages to that draft and authorizes the selected scope.
|
||||
pub fn scoped<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
label: &str,
|
||||
) -> View<'a, Message> {
|
||||
match draft.value.pointer(path) {
|
||||
Some(value) => property(draft, project, value, path, label, false),
|
||||
None => text("This property is not applicable to the current target.")
|
||||
.size(12)
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
@@ -1621,11 +1273,8 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
|
||||
if path.ends_with("/Port/legacy") {
|
||||
let mut content = column![].spacing(4);
|
||||
if !value.is_null() {
|
||||
content = content.push(
|
||||
@@ -1639,29 +1288,6 @@ fn property<'a>(
|
||||
}
|
||||
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 {
|
||||
vec![]
|
||||
} else {
|
||||
@@ -1919,73 +1545,6 @@ fn property<'a>(
|
||||
mod tests {
|
||||
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> {
|
||||
reference_ids(draft, project, path)
|
||||
}
|
||||
@@ -2516,8 +2075,6 @@ mod tests {
|
||||
Layer::Oa,
|
||||
SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
},
|
||||
);
|
||||
let mut draft = Draft::from_project(&p, item).unwrap();
|
||||
@@ -2534,12 +2091,8 @@ mod tests {
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
|
||||
.unwrap();
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
p.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
|
||||
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(p.elements[&item].layer(), Layer::Oa);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,14 +42,10 @@ pub(super) fn variants(
|
||||
_value: &Value,
|
||||
) -> Option<Vec<(String, Value)>> {
|
||||
if path.ends_with("/Port/contract") {
|
||||
let current = draft.context_value();
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let sa = current.pointer("/body/Sa").is_some();
|
||||
if !sa {
|
||||
return Some(if draft.semantic_groups {
|
||||
vec![("Legacy generic port".into(), json!("Legacy"))]
|
||||
} else {
|
||||
vec![]
|
||||
});
|
||||
return Some(vec![]);
|
||||
}
|
||||
let owner = current
|
||||
.get("parent")
|
||||
@@ -73,9 +69,6 @@ pub(super) fn variants(
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
));
|
||||
}
|
||||
if draft.semantic_groups {
|
||||
choices.push(("Legacy generic port".into(), json!("Legacy")));
|
||||
}
|
||||
return Some(choices);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/kind") {
|
||||
@@ -87,7 +80,7 @@ pub(super) fn variants(
|
||||
);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/orientation") {
|
||||
let current = draft.context_value();
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
|
||||
let orientations: &[&str] =
|
||||
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
||||
@@ -151,7 +144,13 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
|
||||
}
|
||||
let declared: Vec<_> = interfaces
|
||||
.iter()
|
||||
.flat_map(|id| data::interface_items(project, *id))
|
||||
.filter_map(|id| project.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect();
|
||||
items_satisfy(project, items, &declared)
|
||||
}
|
||||
@@ -715,12 +714,8 @@ mod tests {
|
||||
interface,
|
||||
);
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Topic {
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
Self::Server => "REST API & automation",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
|
||||
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
|
||||
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
|
||||
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
],
|
||||
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use engineering payloads",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
@@ -510,34 +510,17 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
|
||||
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
|
||||
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Define a reusable payload",
|
||||
&[
|
||||
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
|
||||
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
|
||||
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
|
||||
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
|
||||
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
|
||||
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
|
||||
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Operation parameters and message values",
|
||||
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
|
||||
),
|
||||
explain(
|
||||
"Reuse, ownership and references",
|
||||
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
|
||||
),
|
||||
walkthrough(
|
||||
"Allocate an item to interfaces",
|
||||
&[
|
||||
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
|
||||
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
|
||||
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
|
||||
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Connect participant boundaries",
|
||||
&[
|
||||
@@ -740,7 +723,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Open and migration",
|
||||
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
|
||||
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
|
||||
@@ -751,42 +734,16 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Server => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
|
||||
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
|
||||
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
||||
sections: &[
|
||||
example(
|
||||
"Start a local service",
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
"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",
|
||||
),
|
||||
walkthrough(
|
||||
"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",
|
||||
"Build a safe editing client",
|
||||
&[
|
||||
"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.",
|
||||
@@ -795,6 +752,10 @@ 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.",
|
||||
],
|
||||
),
|
||||
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(
|
||||
"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.",
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,609 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//! 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))
|
||||
}
|
||||
@@ -4,8 +4,6 @@ mod behavior;
|
||||
mod connection_edit;
|
||||
mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -13,14 +11,12 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod hosted;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_navigation;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
|
||||
@@ -415,15 +415,10 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
if p.layer_of(id).is_some() {
|
||||
entries.push(item("Create relationship…", Message::AddRelation, true));
|
||||
entries.push(item(
|
||||
"Create counterpart / review prose…",
|
||||
"Create / update counterpart…",
|
||||
Message::StartTransition,
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Review semantics…",
|
||||
Message::StartSemanticReview(id),
|
||||
p.traces.values().any(|t| t.oa == id || t.sa == id),
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Attach artifact reference…",
|
||||
|
||||
@@ -157,9 +157,12 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
|
||||
}
|
||||
if let Some(legacy) = legacy {
|
||||
rows.push(legacy_line(&context, legacy));
|
||||
if let Some(interface) = legacy.interface {
|
||||
for item in data::interface_items(project, interface) {
|
||||
origins.entry(item).or_default().push(format!(
|
||||
if let Some(interface) = legacy.interface
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
context.element(interface).and_then(Element::shared)
|
||||
{
|
||||
for item in items {
|
||||
origins.entry(*item).or_default().push(format!(
|
||||
"preserved interface {} (suggestion only; not an active typed contract)",
|
||||
context.label(interface)
|
||||
));
|
||||
@@ -708,17 +711,11 @@ mod tests {
|
||||
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let interface = add(&mut project, Kind::Interface, "Incident service");
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"First item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Second item",
|
||||
interface,
|
||||
second,
|
||||
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Interface {
|
||||
items: vec![first, second],
|
||||
},
|
||||
);
|
||||
let connection = &project.relations[&boundary];
|
||||
let (a, b) = (connection.source(), connection.target());
|
||||
@@ -793,12 +790,8 @@ mod tests {
|
||||
..
|
||||
} = fixture();
|
||||
let interface = add(&mut project, Kind::Interface, "Preserved contract");
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
|
||||
let legacy = port(
|
||||
&mut project,
|
||||
source,
|
||||
|
||||
@@ -168,9 +168,12 @@ impl State {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(interface) = old.and_then(|old| old.interface) {
|
||||
let items = data::interface_items(p, interface);
|
||||
incoming.extend(items.iter().copied());
|
||||
if let Some(SharedBody::Interface { items }) = old
|
||||
.and_then(|old| old.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.shared())
|
||||
{
|
||||
incoming.extend(items);
|
||||
outgoing.extend(items);
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
|
||||
use crate::{
|
||||
app::{Choice, Message},
|
||||
forms::Draft,
|
||||
};
|
||||
use iced::widget::text_editor;
|
||||
use oa_core::{transitions::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod constructors;
|
||||
mod history;
|
||||
mod keyed;
|
||||
mod message_kind;
|
||||
mod scalar;
|
||||
mod view;
|
||||
pub(crate) use view::summary;
|
||||
#[cfg(test)]
|
||||
mod constructor_tests;
|
||||
#[cfg(test)]
|
||||
mod keyed_tests;
|
||||
#[cfg(test)]
|
||||
mod message_kind_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Facts,
|
||||
References,
|
||||
Members,
|
||||
History,
|
||||
Impacts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Trace(Choice),
|
||||
Direction(TransitionDirection),
|
||||
Select(FacetSelector),
|
||||
Decide(FacetSelector, Option<SemanticDecision>),
|
||||
Input(String, String),
|
||||
Text(String, text_editor::Action),
|
||||
Shape(String, Value),
|
||||
Variant(String, Value),
|
||||
Array(String, Option<usize>, i32),
|
||||
Append(String, Value),
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
Tab(Tab),
|
||||
ShowUnchanged(bool),
|
||||
ReferenceOa(Choice),
|
||||
ReferenceSa(Choice),
|
||||
ReferenceSearch(String),
|
||||
AddReference,
|
||||
RemoveReference(Id),
|
||||
AcceptImpact(usize, bool),
|
||||
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 anchor: Id,
|
||||
pub trace: Option<Id>,
|
||||
pub direction: TransitionDirection,
|
||||
pub facts: Vec<FacetDrift>,
|
||||
pub selected: Option<FacetSelector>,
|
||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||
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>,
|
||||
// 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 proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub tab: Tab,
|
||||
pub show_unchanged: bool,
|
||||
pub reference_oa: Option<Choice>,
|
||||
pub reference_sa: Option<Choice>,
|
||||
pub reference_search: String,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let explicit = project.traces.get(&selected);
|
||||
let anchor = explicit.map_or(selected, |trace| trace.oa);
|
||||
let layer = project
|
||||
.layer_of(anchor)
|
||||
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
|
||||
let choices: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == anchor || t.sa == anchor)
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
|
||||
}
|
||||
let trace = explicit
|
||||
.map(|t| t.id)
|
||||
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
|
||||
let mut state = Self {
|
||||
anchor,
|
||||
trace,
|
||||
direction: if layer == Layer::Oa {
|
||||
TransitionDirection::OaToSa
|
||||
} else {
|
||||
TransitionDirection::SaToOa
|
||||
},
|
||||
facts: vec![],
|
||||
selected: None,
|
||||
decisions: 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,
|
||||
group_drafts: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
tab: Tab::Facts,
|
||||
show_unchanged: false,
|
||||
reference_oa: None,
|
||||
reference_sa: None,
|
||||
reference_search: String::new(),
|
||||
accepted_impacts: vec![],
|
||||
};
|
||||
state.load_trace(project);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn target(&self, project: &Project) -> Option<Id> {
|
||||
let trace = project.traces.get(&self.trace?)?;
|
||||
Some(if self.direction == TransitionDirection::OaToSa {
|
||||
trace.sa
|
||||
} else {
|
||||
trace.oa
|
||||
})
|
||||
}
|
||||
|
||||
fn load_trace(&mut self, project: &Project) {
|
||||
self.facts.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.accepted_impacts.clear();
|
||||
self.references.clear();
|
||||
self.draft = self
|
||||
.target(project)
|
||||
.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)) {
|
||||
self.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
self.member_family = self
|
||||
.member_families(project)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(keyed::Family::Literal);
|
||||
self.refresh(project);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, project: &Project, event: Event) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
Event::Trace(choice) => {
|
||||
if !project
|
||||
.traces
|
||||
.get(&choice.id)
|
||||
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
{
|
||||
Err("Choose one of this object's existing traces.".into())
|
||||
} else {
|
||||
self.trace = Some(choice.id);
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Direction(direction) => {
|
||||
self.direction = direction;
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
Event::Select(facet) => {
|
||||
self.selected = Some(facet);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::Decide(facet, decision) => {
|
||||
if let Some(decision) = decision {
|
||||
if !self.editable(project, &facet)
|
||||
&& !(decision == SemanticDecision::KeepTarget
|
||||
&& self.can_acknowledge_constructor(project, &facet))
|
||||
{
|
||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||
} 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);
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
self.decisions.remove(&facet);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::Input(path, value) => self.edit(project, &path, |d| {
|
||||
d.input(&path, value);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Text(path, action) => self.edit(project, &path, |d| {
|
||||
d.edit_text(&path, action);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Shape(path, value) => {
|
||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||
}
|
||||
Event::Variant(path, value) => {
|
||||
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||
}
|
||||
Event::Array(path, index, direction) => {
|
||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||
}
|
||||
Event::Append(path, value) => {
|
||||
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
||||
}
|
||||
Event::ValuePath(path, key) => {
|
||||
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
||||
}
|
||||
Event::Tab(tab) => {
|
||||
self.tab = tab;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ShowUnchanged(show) => {
|
||||
self.show_unchanged = show;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceOa(choice) => {
|
||||
self.reference_oa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSa(choice) => {
|
||||
self.reference_sa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSearch(value) => {
|
||||
self.reference_search = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
self.references.insert(oa.id, sa.id);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("Choose both an Operational and a System reference.".into()),
|
||||
},
|
||||
Event::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
Event::AcceptImpact(index, accepted) => {
|
||||
if let Some(impact) = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|p| p.impacts.get(index))
|
||||
.cloned()
|
||||
{
|
||||
self.accepted_impacts.retain(|i| i.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(impact);
|
||||
}
|
||||
}
|
||||
self.refresh(project);
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
self.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
if replan {
|
||||
self.accepted_impacts.clear();
|
||||
self.refresh(project);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
action: impl FnOnce(&mut Draft) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
|
||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||
return Err("Choose Set final value before editing this property.".into());
|
||||
}
|
||||
let roots = self
|
||||
.target(project)
|
||||
.and_then(|id| constructors::roots(project, id, facet))
|
||||
.ok_or("Selected property is unavailable.")?;
|
||||
if !roots
|
||||
.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 = if scalar::grouped(facet) {
|
||||
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.
|
||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
||||
}
|
||||
action(draft)
|
||||
}
|
||||
|
||||
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)
|
||||
.and_then(|id| scalar::binding(project, id, facet))
|
||||
.is_some()
|
||||
&& (!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 {
|
||||
if !scalar::grouped(facet) {
|
||||
return false;
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return false;
|
||||
};
|
||||
if trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, facet)
|
||||
.ok()
|
||||
.is_some_and(|current| {
|
||||
scalar::value(¤t, Layer::Oa).is_some()
|
||||
&& scalar::observation(¤t, Layer::Oa)
|
||||
== scalar::observation(¤t, Layer::Sa)
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
|
||||
let trace = self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.ok_or("Choose the trace whose review history you want to update.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(self.direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.traces.insert(source, trace.id);
|
||||
request.references = self.references.clone();
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: self
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: facet.clone(),
|
||||
decision: *decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: self.resets.iter().cloned().collect(),
|
||||
});
|
||||
request.accepted_impacts = self.accepted_impacts.clone();
|
||||
if self
|
||||
.decisions
|
||||
.values()
|
||||
.any(|d| *d == SemanticDecision::Candidate)
|
||||
{
|
||||
let final_value = self.combined_value(project, false)?;
|
||||
let candidate = if project.elements.contains_key(&target) {
|
||||
SemanticRecord::Element(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
} else {
|
||||
SemanticRecord::Relation(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
};
|
||||
request.candidates.push(candidate);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Compose selected values for both the candidate and dependent form
|
||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||
/// request construction always requires every selected input to parse.
|
||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut final_value = Draft::from_project(project, target)
|
||||
.ok_or("Target has no editable record.")?
|
||||
.value;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
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.")?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.candidate_draft(facet)
|
||||
.ok_or("Target draft is unavailable.")?;
|
||||
match draft.materialize_scopes(&roots) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource if scalar::grouped(facet) => {
|
||||
if self.can_acknowledge_constructor(project, facet) {
|
||||
original.clone()
|
||||
} else {
|
||||
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
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
|
||||
}
|
||||
};
|
||||
for path in roots {
|
||||
let value = supplied
|
||||
.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)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
let context = self.combined_value(project, true).ok();
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
for draft in self.group_drafts.values_mut() {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
self.error = Some(
|
||||
"Several traces are available. Choose the correspondence and review history above."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
match semantic_facets(project, &trace, &self.references) {
|
||||
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) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.selected
|
||||
.as_ref()
|
||||
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
|
||||
{
|
||||
self.selected = self
|
||||
.facts
|
||||
.iter()
|
||||
.find(|f| f.drift != Drift::Unchanged)
|
||||
.or(self.facts.first())
|
||||
.map(|f| f.selector.clone());
|
||||
}
|
||||
let request = match self.request(project) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match plan_semantic_transition(project, request) {
|
||||
Ok(plan) => {
|
||||
if plan.conflicts.is_empty() {
|
||||
match prepare_semantic_transition(project, &plan) {
|
||||
Ok(prepared) => self.proposed = Some(prepared.project),
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
self.plan = Some(plan);
|
||||
}
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||
if self.decisions.is_empty() && self.resets.is_empty() {
|
||||
return Err(
|
||||
"Choose at least one property to review or an exact history observation to reset."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let plan = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.ok_or("Refresh the review preview before applying.")?;
|
||||
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
|
||||
Ok(Command::Batch(plan.commands.clone()))
|
||||
}
|
||||
|
||||
pub fn can_apply(&self) -> bool {
|
||||
(!self.decisions.is_empty() || !self.resets.is_empty())
|
||||
&& self.error.is_none()
|
||||
&& self
|
||||
.plan
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
|
||||
}
|
||||
|
||||
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
let historical: BTreeSet<_> = self
|
||||
.facts
|
||||
.iter()
|
||||
.flat_map(|f| scalar::references(&f.current))
|
||||
.chain(
|
||||
self.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
|
||||
)
|
||||
.collect();
|
||||
let mut ids: BTreeSet<_> = project
|
||||
.elements
|
||||
.keys()
|
||||
.chain(project.relations.keys())
|
||||
.chain(project.artifact_links.keys())
|
||||
.filter(|id| project.layer_of(**id) == Some(layer))
|
||||
.copied()
|
||||
.collect();
|
||||
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
|
||||
let search = self.reference_search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| view::choice(project, id))
|
||||
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
}
|
||||
|
||||
pub fn form_event(message: Message) -> Message {
|
||||
let event = match message {
|
||||
Message::Field(path, value) => Event::Input(path, value),
|
||||
Message::FieldText(path, action) => Event::Text(path, action),
|
||||
Message::FieldShape(path, value) => Event::Shape(path, value),
|
||||
Message::FieldVariant(path, value) => Event::Variant(path, value),
|
||||
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
|
||||
Message::FieldAppend(path, value) => Event::Append(path, value),
|
||||
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
|
||||
_ => Event::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,327 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,782 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,717 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Fixed typed scalar bindings for property forms. Core validates every result.
|
||||
use oa_core::*;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
struct Own {
|
||||
whole: Value,
|
||||
prefix: Vec<String>,
|
||||
kind: String,
|
||||
}
|
||||
fn enum_tag(value: &Value) -> Option<&str> {
|
||||
value.as_str().or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|v| v.keys().next().map(String::as_str))
|
||||
})
|
||||
}
|
||||
impl Own {
|
||||
fn get(project: &Project, id: Id) -> Option<Self> {
|
||||
let (whole, element) = if let Some(e) = project.elements.get(&id) {
|
||||
(serde_json::to_value(e).ok()?, true)
|
||||
} else {
|
||||
(
|
||||
serde_json::to_value(project.relations.get(&id)?).ok()?,
|
||||
false,
|
||||
)
|
||||
};
|
||||
let mut prefix = vec!["body".to_string()];
|
||||
let mut value = &whole["body"];
|
||||
if element {
|
||||
let layer = enum_tag(value)?.to_string();
|
||||
prefix.push(layer.clone());
|
||||
value = &value[&layer];
|
||||
if enum_tag(value) == Some("Shared") {
|
||||
prefix.push("Shared".into());
|
||||
value = &value["Shared"];
|
||||
}
|
||||
}
|
||||
let kind = enum_tag(value)?.to_string();
|
||||
prefix.push(kind.clone());
|
||||
Some(Self {
|
||||
whole,
|
||||
prefix,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
|
||||
for part in path {
|
||||
value = value.get(part)?;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
use FacetSelector as F;
|
||||
let (family, parts): (&str, &[&str]) = match facet {
|
||||
F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]),
|
||||
F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]),
|
||||
F::Header(HeaderFacet::Parent) => {
|
||||
return own.whole.get("parent").map(|_| vec!["parent".into()]);
|
||||
}
|
||||
F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]),
|
||||
F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]),
|
||||
F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]),
|
||||
F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]),
|
||||
F::Data(DataFacet::Constructor) => ("DataType", &["definition"]),
|
||||
F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]),
|
||||
F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]),
|
||||
F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]),
|
||||
F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]),
|
||||
F::Data(DataFacet::StringMinimumLength) => {
|
||||
("DataType", &["definition", "String", "minimum_length"])
|
||||
}
|
||||
F::Data(DataFacet::StringMaximumLength) => {
|
||||
("DataType", &["definition", "String", "maximum_length"])
|
||||
}
|
||||
F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]),
|
||||
F::Data(DataFacet::Default) => ("DataType", &["default"]),
|
||||
F::Data(DataFacet::Unit) => ("DataType", &["unit"]),
|
||||
F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]),
|
||||
F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]),
|
||||
F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]),
|
||||
F::Field(FieldFacet::Unit) => ("DataField", &["unit"]),
|
||||
F::Field(FieldFacet::Direction) => ("DataField", &["direction"]),
|
||||
F::Field(FieldFacet::Storage) => ("DataField", &["storage"]),
|
||||
F::Field(FieldFacet::Default) => ("DataField", &["default"]),
|
||||
F::Port(PortFacet::Constructor) => ("Port", &["contract"]),
|
||||
F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]),
|
||||
F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]),
|
||||
F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]),
|
||||
F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]),
|
||||
F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]),
|
||||
F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]),
|
||||
F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]),
|
||||
F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]),
|
||||
F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]),
|
||||
F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]),
|
||||
F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]),
|
||||
F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]),
|
||||
F::Occurrence(OccurrenceFacet::Exchange) => {
|
||||
("Occurrence", &["body", "Exchange", "exchange"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]),
|
||||
F::Occurrence(OccurrenceFacet::Receiver) => {
|
||||
("Occurrence", &["body", "Exchange", "receiver"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => {
|
||||
("Occurrence", &["body", "Exchange", "message_kind"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::TimerExpression) => (
|
||||
"Occurrence",
|
||||
&["body", "Exchange", "message_kind", "Timer", "expression"],
|
||||
),
|
||||
F::Occurrence(OccurrenceFacet::ReplyTo) => {
|
||||
("Occurrence", &["body", "Exchange", "reply_to"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
|
||||
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
|
||||
("Occurrence", &["body", "Action", "lifeline"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]),
|
||||
F::Occurrence(OccurrenceFacet::Sequence) => {
|
||||
("Occurrence", &["body", "SequenceReference", "sequence"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Scenario) => {
|
||||
("Occurrence", &["body", "ScenarioUse", "scenario"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]),
|
||||
F::Occurrence(OccurrenceFacet::StateLifeline) => {
|
||||
("Occurrence", &["body", "State", "lifeline"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::FragmentOperator) => {
|
||||
("Occurrence", &["body", "Fragment", "operator"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]),
|
||||
F::Event(EventFacet::Constructor) => ("Event", &["body"]),
|
||||
F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]),
|
||||
F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]),
|
||||
F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]),
|
||||
F::State(StateFacet::Kind) => ("State", &["kind"]),
|
||||
F::State(StateFacet::Entry) => ("State", &["entry"]),
|
||||
F::State(StateFacet::Exit) => ("State", &["exit"]),
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
|
||||
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
|
||||
F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]),
|
||||
F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]),
|
||||
F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]),
|
||||
F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]),
|
||||
F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]),
|
||||
F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]),
|
||||
F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]),
|
||||
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
|
||||
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
|
||||
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
|
||||
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
|
||||
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
|
||||
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
|
||||
F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]),
|
||||
F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]),
|
||||
F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]),
|
||||
F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]),
|
||||
_ => return None,
|
||||
};
|
||||
if family != "*" && own.kind != family {
|
||||
return None;
|
||||
}
|
||||
let mut path = own.prefix.clone();
|
||||
path.extend(parts.iter().map(|s| s.to_string()));
|
||||
at(&own.whole, &path).map(|_| path)
|
||||
}
|
||||
|
||||
pub(super) fn binding(project: &Project, id: Id, facet: &FacetSelector) -> Option<String> {
|
||||
let own = Own::get(project, id)?;
|
||||
path(&own, facet).map(|parts| format!("/{}", parts.join("/")))
|
||||
}
|
||||
pub(super) fn grouped(facet: &FacetSelector) -> bool {
|
||||
matches!(
|
||||
facet,
|
||||
FacetSelector::Data(DataFacet::Constructor)
|
||||
| FacetSelector::Port(PortFacet::Constructor)
|
||||
| FacetSelector::Occurrence(
|
||||
OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind
|
||||
)
|
||||
| FacetSelector::Event(EventFacet::Constructor)
|
||||
)
|
||||
}
|
||||
fn pair_json(fact: &FacetReview) -> Value {
|
||||
let json = serde_json::to_value(fact).expect("Typed semantic fact");
|
||||
let (family, value) = json
|
||||
.as_object()
|
||||
.expect("Typed family")
|
||||
.iter()
|
||||
.next()
|
||||
.expect("Family");
|
||||
match family.as_str() {
|
||||
"Member" | "Order" | "Literal" | "Annotation" | "Binding" | "Child" | "Incident" => {
|
||||
value["review"].clone()
|
||||
}
|
||||
"Artifact" => value["review"]
|
||||
.as_object()
|
||||
.expect("Artifact leaf")
|
||||
.values()
|
||||
.next()
|
||||
.expect("Artifact value")
|
||||
.clone(),
|
||||
_ => value
|
||||
.as_object()
|
||||
.expect("Scalar leaf")
|
||||
.values()
|
||||
.next()
|
||||
.expect("Scalar value")
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn observation(fact: &FacetReview, layer: Layer) -> Value {
|
||||
pair_json(fact)[match layer {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
}]
|
||||
.clone()
|
||||
}
|
||||
pub(super) fn value(fact: &FacetReview, layer: Layer) -> Option<Value> {
|
||||
observation(fact, layer).get("Value").cloned()
|
||||
}
|
||||
pub(super) fn source_value(
|
||||
fact: &FacetReview,
|
||||
source: Layer,
|
||||
correspondence: &BTreeMap<Id, Id>,
|
||||
) -> Result<Value, String> {
|
||||
// Retain only the source observation before invoking the core's typed UUID
|
||||
// visitor. UUID-shaped prose must never be treated as a reference.
|
||||
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
|
||||
let leaf = json
|
||||
.as_object_mut()
|
||||
.ok_or("Missing scalar family")?
|
||||
.values_mut()
|
||||
.next()
|
||||
.ok_or("Missing scalar family")?
|
||||
.as_object_mut()
|
||||
.ok_or("Missing scalar leaf")?
|
||||
.values_mut()
|
||||
.next()
|
||||
.ok_or("Missing scalar leaf")?;
|
||||
let selected = observation(fact, source);
|
||||
leaf["oa"] = selected.clone();
|
||||
leaf["sa"] = selected;
|
||||
let typed = serde_json::from_value(json).map_err(|e| e.to_string())?;
|
||||
let mut baseline = SemanticBaseline {
|
||||
facets: vec![typed],
|
||||
};
|
||||
let mut missing = BTreeSet::new();
|
||||
visit_semantic_references(&mut baseline, |id| {
|
||||
if let Some(mapped) = correspondence.get(id) {
|
||||
*id = *mapped;
|
||||
} else {
|
||||
missing.insert(*id);
|
||||
}
|
||||
});
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Choose reference counterparts before taking this value: {}",
|
||||
missing
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
value(&baseline.facets[0], source)
|
||||
.ok_or("Source property is not applicable; review its constructor first.".into())
|
||||
}
|
||||
pub(super) fn references(fact: &FacetReview) -> BTreeSet<Id> {
|
||||
let mut ids = BTreeSet::new();
|
||||
visit_semantic_references(
|
||||
&mut SemanticBaseline {
|
||||
facets: vec![fact.clone()],
|
||||
},
|
||||
|id| {
|
||||
ids.insert(*id);
|
||||
},
|
||||
);
|
||||
ids
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{app::App, document::Document, workflow::Workflow};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
pub(super) fn apply(app: &mut App) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
}
|
||||
pub(super) fn state(app: &App) -> &State {
|
||||
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
||||
panic!("Semantic workflow missing: {}", app.status)
|
||||
};
|
||||
state
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Operational range", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "System range", None)
|
||||
.unwrap();
|
||||
for (id, min, max, unit) in [(oa, 0, 20, "m"), (sa, 1, 30, "cm")] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(min),
|
||||
maximum: Some(max),
|
||||
},
|
||||
default: None,
|
||||
unit: unit.into(),
|
||||
};
|
||||
}
|
||||
let trace = add_trace(&mut p, oa, sa, "Range rationale");
|
||||
(p, oa, sa, trace)
|
||||
}
|
||||
pub(super) fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.simple()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
pub(super) fn start(app: &mut App, source: Id) {
|
||||
let _ = app.update(Message::StartSemanticReview(source));
|
||||
}
|
||||
fn facet(value: DataFacet) -> FacetSelector {
|
||||
FacetSelector::Data(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
|
||||
let (p, oa, sa, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, oa);
|
||||
let minimum = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
let maximum = "/body/Sa/Shared/DataType/definition/Integer/maximum";
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.into(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMaximum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
// Exact event emitted by the upper bound's visible Clear bound button.
|
||||
send(&mut app, Event::Shape(maximum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
|
||||
assert!(
|
||||
state(&app)
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(maximum)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
// Clear must also discard the selected invalid value, without requiring it
|
||||
// to parse before removal. A valid but uncommitted neighboring input survives.
|
||||
send(&mut app, Event::Shape(maximum.into(), json!(30)));
|
||||
send(&mut app, Event::Input(maximum.into(), "29".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Shape(minimum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
|
||||
"29"
|
||||
);
|
||||
assert!(
|
||||
!state(&app)
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs
|
||||
.contains_key(minimum)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
apply(&mut app);
|
||||
assert!(matches!(
|
||||
app.project().elements[&sa].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: Some(29)
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers() {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, layer, "Payload", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Numbers", Some(item))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Integer,
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: Some(vec![DataValue::Integer(1), DataValue::Integer(2)]),
|
||||
};
|
||||
pair.push(field);
|
||||
}
|
||||
add_trace(&mut p, pair[0], pair[1], "Numbers review");
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, pair[0]);
|
||||
let root = "/body/Sa/Shared/DataField";
|
||||
let minimum = format!("{root}/minimum");
|
||||
let default = format!("{root}/default");
|
||||
let first = format!("{default}/0/Integer");
|
||||
let second = format!("{default}/1/Integer");
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.clone(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Default),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(first.clone(), "-".into()));
|
||||
send(&mut app, Event::Input(second.clone(), "23".into()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Append(default.clone(), json!({"Integer": 3})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[&second].raw,
|
||||
"23"
|
||||
);
|
||||
// Same-variant reselection must not throw away the invalid authored value.
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
send(&mut app, Event::Array(default.clone(), Some(0), 1));
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Array(default, Some(1), 0));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
|
||||
"-"
|
||||
);
|
||||
apply(&mut app);
|
||||
assert!(
|
||||
matches!(app.project().elements[&pair[1]].shared(), Some(SharedBody::DataField {
|
||||
default: Some(values), minimum: 0, ..
|
||||
}) if values == &vec![DataValue::Integer(23), DataValue::Integer(3)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity() {
|
||||
let mut p = Project::empty();
|
||||
let a = Id::new_v4();
|
||||
let b = Id::new_v4();
|
||||
let first = FacetSelector::Child(KeyPair {
|
||||
oa: Some(a),
|
||||
sa: None,
|
||||
});
|
||||
let second = FacetSelector::Child(KeyPair {
|
||||
oa: Some(b),
|
||||
sa: None,
|
||||
});
|
||||
let first_title = super::view::title(&p, &first);
|
||||
let second_title = super::view::title(&p, &second);
|
||||
assert_ne!(first_title, second_title);
|
||||
assert!(first_title.contains(&a.to_string()));
|
||||
assert!(second_title.contains(&b.to_string()));
|
||||
let one = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
|
||||
.unwrap();
|
||||
let two = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "Value", Some(one))
|
||||
.unwrap();
|
||||
let annotation = |path| {
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(AnnotationKey { path, field }),
|
||||
sa: None,
|
||||
})
|
||||
};
|
||||
let first_title = super::view::title(&p, &annotation(vec![one, a]));
|
||||
let second_title = super::view::title(&p, &annotation(vec![two, b]));
|
||||
assert_ne!(first_title, second_title);
|
||||
for id in [one, field] {
|
||||
assert!(first_title.contains(&format!("@{}", p.elements[&id].alias)));
|
||||
}
|
||||
assert!(first_title.contains(&a.to_string()));
|
||||
assert!(second_title.contains(&b.to_string()));
|
||||
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
|
||||
}
|
||||
pub(super) fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
||||
send(app, Event::Select(selected.clone()));
|
||||
send(app, Event::Decide(selected, Some(decision)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, trace) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Retain this engineering rationale.\r\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let (source, target, prefix, expected_max, kept_min) = if reverse {
|
||||
(sa, oa, "/body/Oa/Shared/DataType", 30, 0)
|
||||
} else {
|
||||
(oa, sa, "/body/Sa/Shared/DataType", 20, 1)
|
||||
};
|
||||
start(&mut app, source);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMaximum),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
|
||||
);
|
||||
let prepared =
|
||||
prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap())
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&before.decoded.project,
|
||||
"Preview must not mutate accepted state"
|
||||
);
|
||||
apply(&mut app);
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&prepared,
|
||||
"Apply must equal the exact reviewed project including diagrams"
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { minimum, maximum },
|
||||
unit,
|
||||
..
|
||||
}) = app.project().elements[&target].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((*minimum, *maximum), (Some(kept_min), Some(expected_max)));
|
||||
assert_eq!(unit, "kg\n exact suffix ");
|
||||
let baseline = app.project().traces[&trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(baseline.facets.len(), 3, "Unselected facts stay unreviewed");
|
||||
assert!(
|
||||
app.document.bundle().files[&app.document.bundle().entry]
|
||||
.contains("// Retain this engineering rationale.")
|
||||
);
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
||||
let (p, oa, _, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, oa);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
send(&mut app, Event::Input(path.into(), "-".into()));
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Tab(Tab::References));
|
||||
assert!(
|
||||
state(&app).error.is_some(),
|
||||
"Navigation must retain visible validation errors"
|
||||
);
|
||||
apply(&mut app);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
send(&mut app, Event::Input(path.into(), "2".into()));
|
||||
send(&mut app, Event::Input("/alias".into(), "unexpected".into()));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
state(&app)
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("selected review")
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
send(&mut app, Event::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_selection_updates_only_that_history() {
|
||||
let (mut p, oa, sa, first) = fixture();
|
||||
let second = add_trace(&mut p, oa, sa, "Independent assessment");
|
||||
p.traces.get_mut(&second).unwrap().kind = TraceKind::Item;
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
assert!(state(&app).trace.is_none());
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Trace(Choice {
|
||||
id: second,
|
||||
label: "Independent assessment".into(),
|
||||
}),
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
apply(&mut app);
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert!(
|
||||
app.project().traces[&first]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
app.project().traces[&second]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() {
|
||||
let (p, oa, _, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
app.document
|
||||
.execute(Command::Rename(oa, "Changed outside preview".into()))
|
||||
.unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
apply(&mut app);
|
||||
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
|
||||
assert_eq!(state(&app).decisions.len(), 1);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
|
||||
let (p, oa, _, _) = fixture();
|
||||
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();
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, oa);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
"long final unit ".repeat(40),
|
||||
),
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
apply(&mut app);
|
||||
assert!(state(&app).error.is_some());
|
||||
assert_eq!(state(&app).decisions.len(), 1);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing() {
|
||||
let (mut p, oa, sa, _) = fixture();
|
||||
let parent_oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "OA parent", None)
|
||||
.unwrap();
|
||||
let parent_sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "SA parent", None)
|
||||
.unwrap();
|
||||
for id in [parent_oa, parent_sa] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
// ExchangeItem supertypes are an independent optional typed reference.
|
||||
for (id, parent) in [(oa, Some(parent_oa)), (sa, None)] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: parent },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::RecordSupertype),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(Choice {
|
||||
id: parent_oa,
|
||||
label: "OA parent".into(),
|
||||
}),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(Choice {
|
||||
id: parent_sa,
|
||||
label: "SA parent".into(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
parent_oa.to_string(),
|
||||
),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
apply(&mut app);
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
unit,
|
||||
..
|
||||
}) = app.project().elements[&sa].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(*supertype, Some(parent_sa));
|
||||
assert_eq!(
|
||||
unit,
|
||||
&parent_oa.to_string(),
|
||||
"UUID-shaped prose must not be remapped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_structured_facts_and_partial_rollup_are_honest() {
|
||||
let (p, oa, _, trace) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(
|
||||
FacetSelector::Literal(KeyPair {
|
||||
oa: Some("Unpaired".into()),
|
||||
sa: None,
|
||||
}),
|
||||
Some(SemanticDecision::TakeSource),
|
||||
),
|
||||
);
|
||||
assert!(state(&app).decisions.is_empty());
|
||||
assert!(state(&app).error.is_some());
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
apply(&mut app);
|
||||
let rollup = trace_semantic_drift(
|
||||
app.project(),
|
||||
&app.project().traces[&trace],
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rollup.own_unreviewed);
|
||||
assert!(summary(app.project(), &app.project().traces[&trace]).contains("Unreviewed"));
|
||||
let mut request = State::new(app.project(), trace).unwrap();
|
||||
request.update(
|
||||
app.project(),
|
||||
Event::Select(facet(DataFacet::IntegerMaximum)),
|
||||
);
|
||||
let _ = request.view(app.project());
|
||||
assert_eq!(
|
||||
scalar::value(
|
||||
&capture_facet(
|
||||
app.project(),
|
||||
oa,
|
||||
request.target(app.project()).unwrap(),
|
||||
&facet(DataFacet::Default)
|
||||
)
|
||||
.unwrap(),
|
||||
Layer::Oa
|
||||
),
|
||||
Some(json!(null))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves() {
|
||||
let (p, oa, _, trace) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
assert!(state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor)));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Constructor),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{:?}",
|
||||
state(&app).plan.as_ref().map(|p| &p.conflicts)
|
||||
);
|
||||
apply(&mut app);
|
||||
let facts = &app.project().traces[&trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets;
|
||||
assert_eq!(facts.len(), 2);
|
||||
assert!(
|
||||
facts
|
||||
.iter()
|
||||
.any(|f| f.selector() == facet(DataFacet::Constructor))
|
||||
);
|
||||
assert!(
|
||||
!facts
|
||||
.iter()
|
||||
.any(|f| f.selector() == facet(DataFacet::IntegerMinimum))
|
||||
);
|
||||
start(&mut app, oa);
|
||||
assert!(
|
||||
!state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor))
|
||||
);
|
||||
let remaining: Vec<_> = state(&app)
|
||||
.facts
|
||||
.iter()
|
||||
.filter(|f| f.drift == Drift::Unreviewed)
|
||||
.map(|f| f.selector.clone())
|
||||
.collect();
|
||||
for selector in remaining {
|
||||
decide(&mut app, selector, SemanticDecision::KeepTarget);
|
||||
}
|
||||
apply(&mut app);
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert!(
|
||||
!trace_semantic_drift(
|
||||
app.project(),
|
||||
&app.project().traces[&trace],
|
||||
&BTreeMap::new()
|
||||
)
|
||||
.unwrap()
|
||||
.own_unreviewed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_kind_scalar_uses_typed_candidate_and_core_validation() {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, layer, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, layer, "Region", Some(machine))
|
||||
.unwrap();
|
||||
pair.push(
|
||||
p.add_element(Kind::State, layer, "Ready", Some(region))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
add_trace(&mut p, pair[0], pair[1], "State review");
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, pair[0]);
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{:?}",
|
||||
state(&app).plan.as_ref().map(|p| &p.conflicts)
|
||||
);
|
||||
let expected = state(&app).proposed.clone().unwrap();
|
||||
apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.project().elements[&pair[1]].kind(), Kind::Final);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds() {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let a = p
|
||||
.add_element(Kind::DataType, layer, "Origin type", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::DataType, layer, "Destination type", None)
|
||||
.unwrap();
|
||||
for id in [a, b] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let relation = p.relate(RelationKind::DataAssociation, "Association", a, b);
|
||||
if let RelationBody::DataAssociation { source_role, .. } =
|
||||
&mut p.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*source_role = if layer == Layer::Oa {
|
||||
"Authored source role"
|
||||
} else {
|
||||
"Local role"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
pair.push(relation);
|
||||
}
|
||||
add_trace(&mut p, pair[0], pair[1], "Association review");
|
||||
let original = p.relations[&pair[1]].clone();
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, pair[0]);
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Relation(RelationFacet::SourceRole),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Relation(RelationFacet::TargetMaximum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
apply(&mut app);
|
||||
let accepted = &app.project().relations[&pair[1]];
|
||||
assert_eq!(accepted.alias, original.alias);
|
||||
assert_eq!(
|
||||
(accepted.source(), accepted.target()),
|
||||
(original.source(), original.target())
|
||||
);
|
||||
let RelationBody::DataAssociation {
|
||||
source_role,
|
||||
target_maximum,
|
||||
..
|
||||
} = &accepted.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(source_role, "Authored source role");
|
||||
assert_eq!(*target_maximum, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar() {
|
||||
let (p, oa, sa, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(path.into(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
"reviewed local".into(),
|
||||
),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
apply(&mut app);
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { minimum, .. },
|
||||
unit,
|
||||
..
|
||||
}) = app.project().elements[&sa].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(*minimum, Some(1));
|
||||
assert_eq!(unit, "reviewed local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval() {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Operational record", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "System record", None)
|
||||
.unwrap();
|
||||
let base = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Inherited record", None)
|
||||
.unwrap();
|
||||
for (id, supertype) in [(oa, None), (sa, Some(base)), (base, None)] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Inherited value", Some(base))
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Telemetry", None)
|
||||
.unwrap();
|
||||
let root = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Position", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref) in [(leaf, TypeRef::Real), (root, TypeRef::Named(sa))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::FunctionalExchange, "Telemetry exchange", a, b);
|
||||
let (source_port, target_port) = match &mut p.relations.get_mut(&exchange).unwrap().body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} => {
|
||||
items.push(item);
|
||||
(*source_port, *target_port)
|
||||
}
|
||||
_ => panic!(),
|
||||
};
|
||||
for id in [source_port, target_port] {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
} => incoming_items.push(item),
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
} => outgoing_items.push(item),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Telemetry scenario", None)
|
||||
.unwrap();
|
||||
let message = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, "Send position", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![MessageValue {
|
||||
path: vec![root],
|
||||
field: leaf,
|
||||
expression: "current position".into(),
|
||||
}],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(message);
|
||||
add_trace(&mut p, oa, sa, "Record inheritance review");
|
||||
let mut app = App::with_project(p);
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, oa);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::RecordSupertype),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
let impacts = &state(&app).plan.as_ref().unwrap().impacts;
|
||||
assert_eq!(impacts.len(), 1);
|
||||
assert_eq!(impacts[0].object, message);
|
||||
let exact = impacts[0].clone();
|
||||
send(&mut app, Event::AcceptImpact(0, true));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{:?}",
|
||||
state(&app).plan.as_ref().map(|p| &p.conflicts)
|
||||
);
|
||||
assert_eq!(
|
||||
state(&app).plan.as_ref().unwrap().request.accepted_impacts,
|
||||
vec![exact]
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
assert!(
|
||||
state(&app)
|
||||
.plan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.request
|
||||
.accepted_impacts
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::AcceptImpact(0, true));
|
||||
let expected = state(&app).proposed.clone().unwrap();
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = app.project().elements[&message].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(values.is_empty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
@@ -1,886 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{style, views::short};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{
|
||||
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
|
||||
},
|
||||
};
|
||||
|
||||
fn event(event: Event) -> Message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event))
|
||||
}
|
||||
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
|
||||
button(text(label.into()).size(12))
|
||||
.padding([7, 9])
|
||||
.style(style::nav)
|
||||
.on_press(event(event_value))
|
||||
}
|
||||
pub(super) fn choice(project: &Project, id: Id) -> Choice {
|
||||
let alias = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|v| v.alias.as_str())
|
||||
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
|
||||
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
|
||||
Choice {
|
||||
id,
|
||||
label: alias.map_or_else(
|
||||
|| format!("Deleted reference · {id}"),
|
||||
|alias| format!("{} · @{alias}", project.named(id)),
|
||||
),
|
||||
}
|
||||
}
|
||||
fn words(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (index, c) in value.chars().enumerate() {
|
||||
if c == '_' {
|
||||
out.push(' ');
|
||||
} else {
|
||||
if index > 0 && c.is_uppercase() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
||||
match facet {
|
||||
FacetSelector::Header(_) => "Name and ownership",
|
||||
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
|
||||
"Data and defaults"
|
||||
}
|
||||
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
|
||||
"Contracts and interfaces"
|
||||
}
|
||||
FacetSelector::Scenario(_)
|
||||
| FacetSelector::Lifeline(_)
|
||||
| FacetSelector::Occurrence(_)
|
||||
| FacetSelector::Event(_)
|
||||
| FacetSelector::State(_)
|
||||
| FacetSelector::Annotation(_)
|
||||
| FacetSelector::Binding(_) => "Behavior and scenarios",
|
||||
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
|
||||
"Relationships and constraints"
|
||||
}
|
||||
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
|
||||
FacetSelector::Child(_)
|
||||
| FacetSelector::Incident { .. }
|
||||
| FacetSelector::Artifact { .. } => "Attached records",
|
||||
}
|
||||
}
|
||||
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||
let keys = |keys: &KeyPair<Id>, missing: &str| {
|
||||
format!(
|
||||
"{} ↔ {}",
|
||||
keys.oa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| missing.into()),
|
||||
keys.sa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| missing.into())
|
||||
)
|
||||
};
|
||||
match facet {
|
||||
FacetSelector::Member {
|
||||
collection,
|
||||
keys: pair,
|
||||
} => format!(
|
||||
"{} · {}",
|
||||
words(&format!("{collection:?}")),
|
||||
keys(pair, "No identity")
|
||||
),
|
||||
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
||||
FacetSelector::Literal(pair) => format!(
|
||||
"Literal · {} ↔ {}",
|
||||
pair.oa
|
||||
.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!(
|
||||
"Message value · {} ↔ {}",
|
||||
pair.oa
|
||||
.as_ref()
|
||||
.map(|key| annotation_title(project, key))
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
pair.sa
|
||||
.as_ref()
|
||||
.map(|key| annotation_title(project, key))
|
||||
.unwrap_or_else(|| "Absent".into())
|
||||
),
|
||||
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")),
|
||||
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")),
|
||||
FacetSelector::Incident { keys: pair, end } => {
|
||||
format!("Incident {end:?} · {}", keys(pair, "Absent"))
|
||||
}
|
||||
FacetSelector::Artifact { keys: pair, facet } => {
|
||||
format!(
|
||||
"Artifact {} · {}",
|
||||
words(&format!("{facet:?}")),
|
||||
keys(pair, "Absent")
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let json = serde_json::to_value(facet).expect("Typed selector");
|
||||
let value = json
|
||||
.as_object()
|
||||
.expect("Family")
|
||||
.values()
|
||||
.next()
|
||||
.expect("Leaf");
|
||||
words(value.as_str().unwrap_or("Property"))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
|
||||
key.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&key.field))
|
||||
.map(|id| choice(project, *id).label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
fn drift(drift: Drift) -> &'static str {
|
||||
match drift {
|
||||
Drift::Unreviewed => "Unreviewed",
|
||||
Drift::Unchanged => "Unchanged since review",
|
||||
Drift::OaChanged => "Operational changed",
|
||||
Drift::SaChanged => "System changed",
|
||||
Drift::BothChanged => "Both changed",
|
||||
}
|
||||
}
|
||||
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
|
||||
let references = BTreeMap::from([(trace.oa, trace.sa)]);
|
||||
match trace_semantic_drift(project, trace, &references) {
|
||||
Ok(value) => format!(
|
||||
"Semantics: {}{} · Linked: {}{}",
|
||||
drift(value.own),
|
||||
if value.own_unreviewed && value.own != Drift::Unreviewed {
|
||||
" · incomplete review"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
drift(value.linked),
|
||||
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
|
||||
" · incomplete review"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Err(error) => format!("Semantic review unavailable: {error}"),
|
||||
}
|
||||
}
|
||||
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
|
||||
match value {
|
||||
Value::Null => "Not set".into(),
|
||||
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => {
|
||||
if let Ok(id) = value.parse::<Id>()
|
||||
&& references.contains(&id)
|
||||
{
|
||||
choice(project, id).label
|
||||
} else if value.is_empty() {
|
||||
"Empty text".into()
|
||||
} else {
|
||||
format!("“{value}”")
|
||||
}
|
||||
}
|
||||
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Value::Object(values) => values
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
format!("{}: {}", words(key), formatted(project, value, references))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
|
||||
let Some(fact) = fact else {
|
||||
return "Unreviewed — no previous observation".into();
|
||||
};
|
||||
let value = scalar::observation(fact, layer);
|
||||
if value.as_str() == Some("NotApplicable") {
|
||||
"Not applicable".into()
|
||||
} else if keyed::supported(&fact.selector()) {
|
||||
if value["Value"].as_bool() == Some(true) {
|
||||
"Present".into()
|
||||
} else {
|
||||
"Absent".into()
|
||||
}
|
||||
} else {
|
||||
formatted(project, &value["Value"], &scalar::references(fact))
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
||||
let mut traces: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
.map(|t| Choice {
|
||||
id: t.id,
|
||||
label: format!(
|
||||
"{} · {:?} · {} ↔ {} · @{}",
|
||||
t.name,
|
||||
t.kind,
|
||||
project.named(t.oa),
|
||||
project.named(t.sa),
|
||||
t.alias
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
traces.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
|
||||
let direction = if self.direction == TransitionDirection::OaToSa {
|
||||
"Use Operational changes in System"
|
||||
} else {
|
||||
"Use System changes in Operational"
|
||||
};
|
||||
let mut content = column![
|
||||
text("Review semantics").size(22),
|
||||
row![
|
||||
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
|
||||
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
||||
].spacing(8),
|
||||
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("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);
|
||||
let body = match self.tab {
|
||||
Tab::Facts => self.facts_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),
|
||||
};
|
||||
content = content.push(body);
|
||||
if let Some(error) = &self.error {
|
||||
content = content.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
if let Some(plan) = &self.plan {
|
||||
for error in &plan.conflicts {
|
||||
content = content.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let unreviewed = self
|
||||
.facts
|
||||
.iter()
|
||||
.filter(|f| f.drift == Drift::Unreviewed)
|
||||
.count();
|
||||
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 {
|
||||
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
||||
}
|
||||
content
|
||||
.push(
|
||||
row![
|
||||
text(
|
||||
"Apply updates the selected history and model together; Undo restores both."
|
||||
)
|
||||
.size(10)
|
||||
.width(Fill),
|
||||
button(text("Cancel").size(12))
|
||||
.padding(8)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
|
||||
button(text("Apply reviewed changes").size(12))
|
||||
.padding(8)
|
||||
.on_press_maybe(
|
||||
self.can_apply()
|
||||
.then_some(Message::Workflow(crate::workflow::Event::Apply))
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let mut list = column![
|
||||
checkbox(self.show_unchanged)
|
||||
.label("Include unchanged facts")
|
||||
.text_size(11)
|
||||
.on_toggle(|v| event(Event::ShowUnchanged(v)))
|
||||
]
|
||||
.spacing(5);
|
||||
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
|
||||
for fact in &self.facts {
|
||||
if self.show_unchanged
|
||||
|| fact.drift != Drift::Unchanged
|
||||
|| self.decisions.contains_key(&fact.selector)
|
||||
|| self.selected.as_ref() == Some(&fact.selector)
|
||||
{
|
||||
groups.entry(group(&fact.selector)).or_default().push(fact);
|
||||
}
|
||||
}
|
||||
for (group, facts) in groups {
|
||||
list = list.push(text(group).size(12).color(style::TEAL));
|
||||
for fact in facts {
|
||||
let mark = match self.decisions.get(&fact.selector) {
|
||||
Some(SemanticDecision::KeepTarget) => "Keep · ",
|
||||
Some(SemanticDecision::TakeSource) => "Take · ",
|
||||
Some(SemanticDecision::Candidate) => "Final value · ",
|
||||
None => "",
|
||||
};
|
||||
list = list.push(
|
||||
button(
|
||||
column![
|
||||
text(short(&title(project, &fact.selector), 52)).size(12),
|
||||
text(format!("{mark}{}", drift(fact.drift))).size(10)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.padding(7)
|
||||
.width(Fill)
|
||||
.style(if self.selected.as_ref() == Some(&fact.selector) {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Select(fact.selector.clone()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
row![
|
||||
scrollable(list).height(305).width(260),
|
||||
scrollable(self.detail(project)).height(305).width(Fill)
|
||||
]
|
||||
.spacing(12)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let Some(fact) = self
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
|
||||
else {
|
||||
return text("Choose a trace and property to compare.")
|
||||
.size(13)
|
||||
.into();
|
||||
};
|
||||
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
||||
let previous = trace
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.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 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![
|
||||
text(format!(
|
||||
"{label}{}",
|
||||
if layer == self.direction.target_layer() {
|
||||
" · destination"
|
||||
} else {
|
||||
" · source"
|
||||
}
|
||||
))
|
||||
.size(12)
|
||||
.color(style::TEAL),
|
||||
text("Previously reviewed").size(10).color(style::MUTED),
|
||||
text(before).size(12),
|
||||
text("Current").size(10).color(style::MUTED),
|
||||
text(current).size(12),
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill)
|
||||
};
|
||||
let mut detail = column![
|
||||
text(title(project, &fact.selector)).size(17),
|
||||
text(drift(fact.drift)).size(11),
|
||||
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
||||
]
|
||||
.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
|
||||
&& let Some(trace) = trace
|
||||
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
||||
{
|
||||
detail = detail
|
||||
.push(
|
||||
text("Proposed destination value · validated preview")
|
||||
.size(11)
|
||||
.color(style::TEAL),
|
||||
)
|
||||
.push(
|
||||
text(if is_group {
|
||||
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
|
||||
.ok()
|
||||
.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),
|
||||
);
|
||||
} else {
|
||||
detail = detail.push(
|
||||
text("No validated final preview. Resolve the messages below before applying.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
"System"
|
||||
} else {
|
||||
"Operational"
|
||||
};
|
||||
let source = if self.direction.source_layer() == Layer::Oa {
|
||||
"Operational"
|
||||
} else {
|
||||
"System"
|
||||
};
|
||||
let mut buttons = row![
|
||||
action(
|
||||
format!("Keep {destination}"),
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
|
||||
),
|
||||
action(
|
||||
format!("Take {source}"),
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
|
||||
)
|
||||
]
|
||||
.spacing(5);
|
||||
buttons = buttons.push(action(
|
||||
if is_group {
|
||||
"Set final kind and payload…"
|
||||
} else {
|
||||
"Set final value…"
|
||||
},
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
||||
));
|
||||
detail = detail.push(buttons.wrap()).push(action(
|
||||
"Leave unselected",
|
||||
Event::Decide(fact.selector.clone(), None),
|
||||
));
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& keyed::supported(&fact.selector)
|
||||
{
|
||||
let facet = fact.selector.clone();
|
||||
detail = detail.push(
|
||||
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) {
|
||||
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("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
||||
} else {
|
||||
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!(
|
||||
fact.selector,
|
||||
FacetSelector::Child(_)
|
||||
| FacetSelector::Incident { .. }
|
||||
| FacetSelector::Artifact { .. }
|
||||
) {
|
||||
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
||||
}
|
||||
detail.into()
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut content = column![
|
||||
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 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),
|
||||
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::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
|
||||
action("Use pair", Event::AddReference),
|
||||
].spacing(7),
|
||||
].spacing(9);
|
||||
for (oa, sa) in &self.references {
|
||||
content = content.push(
|
||||
row![
|
||||
text(format!(
|
||||
"{}\n↔ {}",
|
||||
choice(project, *oa).label,
|
||||
choice(project, *sa).label
|
||||
))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
action("Remove pair", Event::RemoveReference(*oa))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
content = content
|
||||
.push(text("Referenced definitions · reviewed on their own traces").size(14));
|
||||
match linked_semantic_impacts(project, trace, &self.references) {
|
||||
Ok(impacts) => {
|
||||
for impact in impacts {
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} ↔ {} · {}{} · {} trace(s)",
|
||||
impact
|
||||
.objects
|
||||
.oa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
impact
|
||||
.objects
|
||||
.sa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
drift(impact.drift),
|
||||
if impact.unreviewed {
|
||||
" · incomplete review"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
impact.traces.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => content = content.push(text(error).size(12)),
|
||||
}
|
||||
}
|
||||
scrollable(content).height(305).into()
|
||||
}
|
||||
|
||||
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
|
||||
if let Some(plan) = &self.plan {
|
||||
if plan.impacts.is_empty() {
|
||||
content =
|
||||
content.push(text("No additional prepared changes in this preview.").size(12));
|
||||
}
|
||||
for (index, impact) in plan.impacts.iter().enumerate() {
|
||||
let before = impact
|
||||
.before
|
||||
.as_ref()
|
||||
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
||||
let after = impact
|
||||
.after
|
||||
.as_ref()
|
||||
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
||||
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
content = content.push(
|
||||
container(
|
||||
column![
|
||||
checkbox(self.accepted_impacts.contains(impact))
|
||||
.label(format!("Accept · {}", project.named(impact.object)))
|
||||
.text_size(12)
|
||||
.on_toggle(move |accepted| event(Event::AcceptImpact(
|
||||
index, accepted
|
||||
))),
|
||||
text("Before").size(11).color(style::MUTED),
|
||||
text(
|
||||
before
|
||||
.as_ref()
|
||||
.map(|v| formatted(project, v, &ids))
|
||||
.unwrap_or_else(|| "Absent · record creation".into())
|
||||
)
|
||||
.size(12),
|
||||
text("After").size(11).color(style::MUTED),
|
||||
text(
|
||||
after
|
||||
.as_ref()
|
||||
.map(|v| formatted(project, v, &ids))
|
||||
.unwrap_or_else(|| "Absent · record deletion".into())
|
||||
)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(10)
|
||||
.style(style::subtle),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content).height(305).into()
|
||||
}
|
||||
}
|
||||
@@ -250,10 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert!(!app.dirty());
|
||||
assert!(
|
||||
app.source_snapshot
|
||||
.starts_with(&format!("arcadia {SCHEMA_VERSION};"))
|
||||
);
|
||||
assert!(app.source_snapshot.starts_with("arcadia 3;"));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,14 +55,22 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
]
|
||||
.spacing(10)
|
||||
.width(220),
|
||||
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
column![
|
||||
text(short(&p.name, 46)).size(17),
|
||||
label(if app.dirty() {
|
||||
"Unsaved changes"
|
||||
} else if app.path.is_some() {
|
||||
"All changes saved"
|
||||
} else {
|
||||
"Local project"
|
||||
})
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
action("New", Message::Request(Pending::New)),
|
||||
crate::hosted::view::header_action(),
|
||||
action("Open", Message::Request(Pending::Open)),
|
||||
action("Save as", Message::Save(true)),
|
||||
button(text(app.save_label()).size(12))
|
||||
button(text("Save all").size(12))
|
||||
.padding([10, 16])
|
||||
.on_press(Message::Save(false))
|
||||
]
|
||||
@@ -239,13 +247,6 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.style(style::panel);
|
||||
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
|
||||
if let Some(workflow) = &app.workflow {
|
||||
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
|
||||
return modal_width(
|
||||
root,
|
||||
workflow.view(p),
|
||||
(app.window_size.width - 50.).clamp(800., 1120.),
|
||||
);
|
||||
}
|
||||
return modal(root, workflow.view(p));
|
||||
}
|
||||
if let Some(p) = &app.file_prompt {
|
||||
@@ -298,27 +299,15 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
root
|
||||
}
|
||||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||||
modal_width(base, content, 800.)
|
||||
}
|
||||
fn modal_width<'a>(
|
||||
base: View<'a, Message>,
|
||||
content: View<'a, Message>,
|
||||
width: f32,
|
||||
) -> View<'a, Message> {
|
||||
widget::stack![
|
||||
base,
|
||||
widget::opaque(
|
||||
container(
|
||||
container(content)
|
||||
.padding(26)
|
||||
.width(width)
|
||||
.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()
|
||||
})
|
||||
container(container(content).padding(26).width(800).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()
|
||||
@@ -714,7 +703,69 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
|
||||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
crate::data_view::view(app)
|
||||
let p = app.project();
|
||||
let mut content = column![heading(
|
||||
"Data & interfaces",
|
||||
"Define information, material and energy at the system boundary."
|
||||
)]
|
||||
.spacing(15);
|
||||
for kind in Kind::ALL {
|
||||
let is_data = matches!(
|
||||
kind,
|
||||
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
|
||||
);
|
||||
if !is_data {
|
||||
continue;
|
||||
}
|
||||
let elements = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == app.layer && e.kind() == kind)
|
||||
.collect::<Vec<_>>();
|
||||
if elements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
content = content.push(
|
||||
text(format!("{} · {}", kind.label(), elements.len()))
|
||||
.size(15)
|
||||
.color(style::kind_color(kind)),
|
||||
);
|
||||
for e in elements {
|
||||
content = content.push(crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
text(&e.name).size(14),
|
||||
label(format!(
|
||||
"@{}{}",
|
||||
e.alias,
|
||||
e.parent
|
||||
.map(|id| format!(" · in {}", p.named(id)))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
text(short(&e.description, 125))
|
||||
.size(12)
|
||||
.color(style::MUTED)
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill),
|
||||
text("→").size(17)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.style(if e.id == app.selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::Select(e.id)),
|
||||
move |anchor| crate::model_menu::open(e.id, anchor),
|
||||
));
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
}
|
||||
|
||||
fn traces(app: &App) -> View<'_, Message> {
|
||||
@@ -725,18 +776,13 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
"Connect operational needs, system responsibilities and development evidence."
|
||||
),
|
||||
row![
|
||||
button(text("Create counterpart / review prose…").size(12))
|
||||
button(text("Create / update counterpart…").size(12))
|
||||
.padding(9)
|
||||
.on_press(Message::StartTransition),
|
||||
action("+ Manual trace", Message::AddTrace),
|
||||
action(
|
||||
"Review semantics…",
|
||||
Message::StartSemanticReview(app.selected)
|
||||
),
|
||||
action("+ Artifact", Message::AddArtifact)
|
||||
]
|
||||
.spacing(8)
|
||||
.wrap()
|
||||
]
|
||||
.spacing(16);
|
||||
for t in p.traces.values() {
|
||||
@@ -756,14 +802,10 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
label(format!("{:?} · Prose: {current}", t.kind)),
|
||||
label(format!("{:?} · {current}", t.kind)),
|
||||
Space::new().width(Fill),
|
||||
action("Review semantics…", Message::StartSemanticReview(t.id)),
|
||||
action("Edit link", Message::Select(t.id))
|
||||
action("Review link", Message::Select(t.id))
|
||||
],
|
||||
text(crate::semantic_review::summary(p, t))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
text(short(&t.description, 130)).size(12)
|
||||
]
|
||||
.spacing(10),
|
||||
@@ -1057,51 +1099,6 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
app.project().named(endpoint.exchange)
|
||||
)));
|
||||
}
|
||||
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
|
||||
for default in report.semantic_defaults.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"{} · {}",
|
||||
app.project().named(default.object),
|
||||
default.field
|
||||
),
|
||||
Message::Select(default.object),
|
||||
))
|
||||
.push(label(&default.description));
|
||||
}
|
||||
for allocation in report.generated_interface_allocations.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Created item allocation · @{} · order {}",
|
||||
allocation.alias, allocation.order
|
||||
),
|
||||
Message::Select(allocation.id),
|
||||
))
|
||||
.push(label(format!(
|
||||
"{} → {}",
|
||||
app.project().named(allocation.source),
|
||||
app.project().named(allocation.target)
|
||||
)));
|
||||
}
|
||||
for value in report.unresolved_message_values.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Review qualified value · {}",
|
||||
app.project().named(value.occurrence)
|
||||
),
|
||||
Message::EditProperties(value.occurrence),
|
||||
))
|
||||
.push(label(&value.reason));
|
||||
}
|
||||
if report.semantic_defaults.len() > 6
|
||||
|| report.generated_interface_allocations.len() > 6
|
||||
|| report.unresolved_message_values.len() > 6
|
||||
{
|
||||
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
|
||||
}
|
||||
for boundary in &report.orientation_defaults {
|
||||
card = card.push(action(
|
||||
&format!(
|
||||
|
||||
@@ -15,7 +15,6 @@ use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Semantic(crate::semantic_review::Event),
|
||||
Port(crate::port_workflow::Event),
|
||||
ExchangeItem(Id, bool),
|
||||
Endpoint(bool, EndpointChoice),
|
||||
@@ -34,7 +33,6 @@ pub enum Event {
|
||||
Preview,
|
||||
}
|
||||
pub enum Workflow {
|
||||
Semantic(Box<crate::semantic_review::State>),
|
||||
Ports(Box<crate::port_workflow::State>),
|
||||
Relation(Box<Relationship>),
|
||||
ManualTrace {
|
||||
@@ -335,11 +333,6 @@ impl Workflow {
|
||||
/// choices and previews never mutate the Session.
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
|
||||
match self {
|
||||
Self::Semantic(_) => {
|
||||
return Err(
|
||||
"Semantic review commits its exact command batch through Document.".into(),
|
||||
);
|
||||
}
|
||||
Self::Ports(state) => return state.update(p, event),
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
@@ -578,7 +571,6 @@ impl Workflow {
|
||||
}
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Semantic(_) => "Review semantics",
|
||||
Self::Ports(state) => state.title(),
|
||||
Self::ManualTrace { .. } => "Link existing counterparts",
|
||||
Self::Relation(_) => "Create relationship",
|
||||
@@ -592,9 +584,6 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
|
||||
if let Self::Semantic(state) = self {
|
||||
return state.view(p);
|
||||
}
|
||||
if let Self::Ports(state) = self {
|
||||
return state.view(p);
|
||||
}
|
||||
@@ -605,7 +594,7 @@ impl Workflow {
|
||||
None
|
||||
};
|
||||
match self {
|
||||
Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
|
||||
Self::Ports(_) => unreachable!("Port dialog returned above"),
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
source,
|
||||
@@ -1084,7 +1073,6 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
let enabled = match self {
|
||||
Self::Semantic(_) => false,
|
||||
Self::Ports(_) => false,
|
||||
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
||||
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
|
||||
|
||||
@@ -72,18 +72,6 @@ pub(crate) fn router() -> Router<AppState> {
|
||||
"/api/v1/projects/{id}/transitions/apply",
|
||||
post(commit_transition),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/query",
|
||||
post(query_semantic_transitions),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/preview",
|
||||
post(preview_semantic_transition),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/apply",
|
||||
post(commit_semantic_transition),
|
||||
)
|
||||
.fallback(|| async {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1101,164 +1089,6 @@ struct TransitionApplyRequest {
|
||||
plan: oa_core::transitions::TransitionPlan,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SemanticQueryRequest {
|
||||
traces: Vec<Id>,
|
||||
#[serde(default)]
|
||||
references: BTreeMap<Id, Id>,
|
||||
revision: Option<u64>,
|
||||
}
|
||||
|
||||
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"semantic_transition_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn query_semantic_transitions(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: SemanticQueryRequest = json_body(&body)?;
|
||||
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
|
||||
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
|
||||
}
|
||||
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
|
||||
if selected.len() != request.traces.len() {
|
||||
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
|
||||
}
|
||||
let current = store.get(id)?;
|
||||
check_read_revision(¤t, request.revision)?;
|
||||
let mut items = Vec::new();
|
||||
for trace_id in request.traces {
|
||||
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
|
||||
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
|
||||
})?;
|
||||
let facets = oa_core::semantic::semantic_facets(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let drift = oa_core::semantic::trace_semantic_drift(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let linked_impacts = oa_core::semantic::linked_semantic_impacts(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
|
||||
}
|
||||
Ok(response(¤t, StatusCode::OK, json!({"revision":current.revision,"items":items})))
|
||||
}).await
|
||||
}
|
||||
|
||||
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
|
||||
let transition = &request.transition;
|
||||
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
|
||||
count
|
||||
.checked_add(review.resolutions.len())?
|
||||
.checked_add(review.reset.len())
|
||||
});
|
||||
if transition.sources.len() > 100
|
||||
|| transition.mappings.len() > 200
|
||||
|| transition.resolutions.len() > 400
|
||||
|| request.traces.len() > 100
|
||||
|| request.references.len() > 5_000
|
||||
|| request.reviews.len() > 100
|
||||
|| decisions.is_none_or(|count| count > 5_000)
|
||||
|| request.candidates.len() > 200
|
||||
|| request.accepted_impacts.len() > 200
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn preview_semantic_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
let expected = expected(&headers)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
|
||||
check_semantic_request(&request)?;
|
||||
let current = store.checked(id, &expected)?;
|
||||
let plan = oa_core::semantic::plan_semantic_transition(¤t.model, request)
|
||||
.map_err(semantic_rejected)?;
|
||||
if !plan.commands.is_empty() {
|
||||
check_commands(&plan.commands)?;
|
||||
}
|
||||
if plan.conflicts.is_empty() {
|
||||
let candidate = oa_core::semantic::prepare_semantic_transition(¤t.model, &plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
PreparedProject::from_command(¤t, candidate)?;
|
||||
}
|
||||
Ok(response(
|
||||
¤t,
|
||||
StatusCode::OK,
|
||||
json!({"base_revision":current.revision,"plan":plan}),
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SemanticApplyRequest {
|
||||
base_revision: u64,
|
||||
plan: oa_core::semantic::SemanticTransitionPlan,
|
||||
}
|
||||
|
||||
async fn commit_semantic_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
let expected = expected(&headers)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: SemanticApplyRequest = json_body(&body)?;
|
||||
let current = store.checked(id, &expected)?;
|
||||
if current.revision != request.base_revision {
|
||||
let mut error = ApiError::new(
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
"plan_revision_conflict",
|
||||
"Semantic preview revision changed. Create a new preview.",
|
||||
);
|
||||
error.current_revision = Some(current.revision);
|
||||
return Err(error);
|
||||
}
|
||||
check_semantic_request(&request.plan.request)?;
|
||||
if !request.plan.commands.is_empty() {
|
||||
check_commands(&request.plan.commands)?;
|
||||
}
|
||||
let candidate =
|
||||
oa_core::semantic::prepare_semantic_transition(¤t.model, &request.plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
let prepared = PreparedProject::from_command(¤t, candidate)?;
|
||||
let (stored, changed) = store.replace(id, &expected, prepared)?;
|
||||
Ok(response(
|
||||
&stored,
|
||||
StatusCode::OK,
|
||||
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
|
||||
@@ -12,8 +12,6 @@ use tokio::{sync::oneshot, task::JoinHandle};
|
||||
mod data_contracts;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
mod semantic_transitions;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
|
||||
@@ -2004,7 +2002,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
||||
let no_op = server
|
||||
.client
|
||||
|
||||
@@ -185,7 +185,7 @@ async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
@@ -218,7 +218,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
|
||||
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let stored = server.get(resource).await;
|
||||
let report = stored.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 5));
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(report.generated_interface_allocations.len(), 1);
|
||||
assert!(!report.semantic_defaults.is_empty());
|
||||
let validated: Value = server
|
||||
@@ -241,7 +241,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
|
||||
1
|
||||
);
|
||||
assert_eq!(stored.source_project.files.len(), 2);
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 5;"));
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 4;"));
|
||||
for text in [
|
||||
"// Event rationale",
|
||||
"// Interface rationale",
|
||||
@@ -283,9 +283,9 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let opened = server.get(resource).await;
|
||||
assert_eq!(opened.revision, 17);
|
||||
assert_eq!(opened.model.schema_version, 5);
|
||||
assert_eq!(opened.model.schema_version, 4);
|
||||
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 5;"));
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert_pair(&opened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
||||
assert_eq!(
|
||||
@@ -315,420 +315,3 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
|
||||
{
|
||||
use oa_core::{
|
||||
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
|
||||
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
|
||||
};
|
||||
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = model.elements[&occurrence].shared()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
values
|
||||
}
|
||||
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
|
||||
let mut element = model.elements[&occurrence].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = element.shared_mut()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
*values = next;
|
||||
element
|
||||
}
|
||||
let mut model = documented_data();
|
||||
let operation = id(&model, "navigate");
|
||||
let coordinate = id(&model, "coordinate");
|
||||
let located = id(&model, "located");
|
||||
let latitude = id(&model, "latitude");
|
||||
let origin = id(&model, "origin");
|
||||
let destination = id(&model, "destination");
|
||||
let accepted = id(&model, "accepted");
|
||||
// The second parameter inherits the very same latitude identity.
|
||||
let Some(SharedBody::DataField { type_ref, .. }) =
|
||||
model.elements.get_mut(&destination).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*type_ref = TypeRef::Named(located);
|
||||
let next = model
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Sa,
|
||||
"Next coordinate",
|
||||
Some(coordinate),
|
||||
)
|
||||
.unwrap();
|
||||
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(coordinate),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
unit: String::new(),
|
||||
default: None,
|
||||
};
|
||||
let requester = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
|
||||
.unwrap();
|
||||
let provider = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (port, contract) in [
|
||||
(
|
||||
output,
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![operation],
|
||||
},
|
||||
),
|
||||
(
|
||||
input,
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![operation],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let (owner, name) = if port == output {
|
||||
(requester, "Navigation output")
|
||||
} else {
|
||||
(provider, "Navigation input")
|
||||
};
|
||||
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
|
||||
model.elements.insert(port, element);
|
||||
}
|
||||
let exchange = model.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Navigation request",
|
||||
requester,
|
||||
provider,
|
||||
);
|
||||
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: requester,
|
||||
target: provider,
|
||||
source_port: output,
|
||||
target_port: input,
|
||||
items: vec![operation],
|
||||
};
|
||||
let scenario = model
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
model.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::Functional;
|
||||
let sender = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
|
||||
.unwrap();
|
||||
let receiver = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
|
||||
.unwrap();
|
||||
for (line, represented) in [(sender, requester), (receiver, provider)] {
|
||||
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let call = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation call",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
let reply = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation reply",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
for (occurrence, message_kind, from, to, reply_to) in [
|
||||
(call, MessageKind::Call, sender, receiver, None),
|
||||
(reply, MessageKind::Reply, receiver, sender, Some(call)),
|
||||
] {
|
||||
*model
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(from),
|
||||
receiver: Some(to),
|
||||
message_kind,
|
||||
reply_to,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
model
|
||||
.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.extend([call, reply]);
|
||||
oa_core::validate_candidate(&model).unwrap();
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (resource, _) = server.import(&model).await;
|
||||
let before = server.get(resource).await;
|
||||
let call_values = vec![
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: " origin.latitude\n+ 0.25 ".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![destination],
|
||||
field: latitude,
|
||||
expression: "destination.latitude".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![origin, next],
|
||||
field: latitude,
|
||||
expression: "previous.latitude".into(),
|
||||
},
|
||||
];
|
||||
let reply_values = vec![MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "true".into(),
|
||||
}];
|
||||
let response = commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
|
||||
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let authored = server.get(resource).await;
|
||||
assert_eq!(authored.revision, before.revision + 1);
|
||||
assert_eq!(values(&authored.model, call), call_values);
|
||||
assert_eq!(values(&authored.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
authored.model.elements[&latitude],
|
||||
model.elements[&latitude]
|
||||
);
|
||||
assert_pair(&authored);
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
for (occurrence, value, code) in [
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "wrong call phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
reply,
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: "wrong reply phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![origin, accepted],
|
||||
field: latitude,
|
||||
expression: "unrelated hop".into(),
|
||||
},
|
||||
"message_value_field",
|
||||
),
|
||||
] {
|
||||
let invalid = edited_values(&authored.model, occurrence, vec![value]);
|
||||
let mut invalid_model = authored.model.clone();
|
||||
invalid_model.elements.insert(occurrence, invalid.clone());
|
||||
let response = commands(
|
||||
&server,
|
||||
&authored,
|
||||
vec![Command::EditElement(occurrence, invalid)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let problem: Value = response.json().await.unwrap();
|
||||
assert_eq!(problem["code"], "command_rejected", "{problem}");
|
||||
assert!(
|
||||
problem["detail"].as_str().unwrap().starts_with(code),
|
||||
"{problem}"
|
||||
);
|
||||
// Transactions report the first rejection; validation supplies located issues.
|
||||
let validation: Value = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{resource}/validation")))
|
||||
.json(&json!({"format":"json", "model":invalid_model}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validation["valid"], false);
|
||||
assert!(
|
||||
validation["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|issue| issue["code"] == code
|
||||
&& issue["object_id"] == json!(occurrence)
|
||||
&& issue["field"] == "values.path"),
|
||||
"{problem}"
|
||||
);
|
||||
let unchanged = server.get(resource).await;
|
||||
assert_eq!(unchanged.etag(), authored.etag());
|
||||
assert_eq!(unchanged.model, authored.model);
|
||||
assert_eq!(unchanged.source_project, authored.source_project);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
}
|
||||
|
||||
// Source transport changes one argument without conflating the shared leaf.
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let edited_source = source.replace(
|
||||
"\"destination.latitude\"",
|
||||
"\"destination.corrected_latitude\"",
|
||||
);
|
||||
assert_ne!(edited_source, source);
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{resource}/source")))
|
||||
.header("if-match", authored.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(edited_source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let source_edited = server.get(resource).await;
|
||||
assert_eq!(source_edited.revision, authored.revision + 1);
|
||||
let mut expected_call = call_values.clone();
|
||||
expected_call[1].expression = "destination.corrected_latitude".into();
|
||||
assert_eq!(values(&source_edited.model, call), expected_call);
|
||||
assert_eq!(values(&source_edited.model, reply), reply_values);
|
||||
assert_pair(&source_edited);
|
||||
|
||||
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
|
||||
let mapping = [origin, destination, next, latitude, coordinate]
|
||||
.into_iter()
|
||||
.map(|old| (old, Id::new_v4()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let response = commands(
|
||||
&server,
|
||||
&source_edited,
|
||||
mapping
|
||||
.iter()
|
||||
.map(|(old, new)| Command::Reidentify(*old, *new))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(changed.revision, source_edited.revision + 1);
|
||||
for value in &mut expected_call {
|
||||
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
|
||||
value.field = mapping[&value.field];
|
||||
}
|
||||
assert_eq!(values(&changed.model, call), expected_call);
|
||||
assert_eq!(values(&changed.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
changed.model.elements[&mapping[&latitude]].parent,
|
||||
Some(mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
|
||||
}) if *base == mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
oa_core::data::effective_fields(&changed.model, located)
|
||||
.iter()
|
||||
.any(|field| field.id == mapping[&latitude])
|
||||
);
|
||||
for old in mapping.keys() {
|
||||
assert!(!changed.model.contains(*old));
|
||||
}
|
||||
assert!(
|
||||
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
|
||||
}) if *correlation == call)
|
||||
);
|
||||
assert_pair(&changed);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
|
||||
let saved = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.etag(), changed.etag());
|
||||
assert_eq!(reopened.model, changed.model);
|
||||
assert_eq!(reopened.source_project, changed.source_project);
|
||||
assert_eq!(values(&reopened.model, call), expected_call);
|
||||
assert_eq!(values(&reopened.model, reply), reply_values);
|
||||
assert_pair(&reopened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), saved);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
use super::*;
|
||||
use oa_core::{semantic::*, transitions::*, *};
|
||||
|
||||
fn fixture() -> Project {
|
||||
oa_core::persistence::decode(include_bytes!(
|
||||
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
fn key(n: u128) -> Id {
|
||||
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
|
||||
}
|
||||
fn request(
|
||||
source: Id,
|
||||
target: Id,
|
||||
decisions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let direction = if source == key(3) {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: decisions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution { facet, decision })
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
request
|
||||
}
|
||||
async fn post(
|
||||
server: &TestServer,
|
||||
id: Id,
|
||||
operation: &str,
|
||||
etag: Option<&str>,
|
||||
value: &impl serde::Serialize,
|
||||
) -> reqwest::Response {
|
||||
let mut call = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
|
||||
.json(value);
|
||||
if let Some(etag) = etag {
|
||||
call = call.header("if-match", etag);
|
||||
}
|
||||
call.send().await.unwrap()
|
||||
}
|
||||
async fn ok(response: reqwest::Response) -> Value {
|
||||
let status = response.status();
|
||||
let value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{value}");
|
||||
value
|
||||
}
|
||||
async fn preview(
|
||||
server: &TestServer,
|
||||
stored: &StoredProject,
|
||||
request: &SemanticTransitionRequest,
|
||||
) -> Value {
|
||||
ok(post(
|
||||
server,
|
||||
stored.resource_id,
|
||||
"preview",
|
||||
Some(&stored.etag()),
|
||||
request,
|
||||
)
|
||||
.await)
|
||||
.await
|
||||
}
|
||||
fn assert_pair(stored: &StoredProject) {
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
stored.model
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, _) = server.import(&fixture()).await;
|
||||
let mut stored = server.get(id).await;
|
||||
let mut bundle = stored.source_project.clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Authored review rationale\n");
|
||||
let changed = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source-project")))
|
||||
.header("if-match", stored.etag())
|
||||
.json(&bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), StatusCode::OK);
|
||||
stored = server.get(id).await;
|
||||
let disk_path = directory.path().join(format!("{id}.json"));
|
||||
let before_bytes = std::fs::read(&disk_path).unwrap();
|
||||
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
|
||||
let queried = ok(post(&server, id, "query", None, &query).await).await;
|
||||
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
|
||||
let req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
(
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::TakeSource,
|
||||
),
|
||||
],
|
||||
);
|
||||
let plan = preview(&server, &stored, &req).await;
|
||||
assert_eq!(plan["plan"]["conflicts"], json!([]));
|
||||
assert_eq!(
|
||||
plan["plan"]["reviews"][0]["acknowledged"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
|
||||
assert_eq!(server.get(id).await.etag(), stored.etag());
|
||||
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
|
||||
assert_eq!(applied["revision"], stored.revision + 1);
|
||||
let committed = server.get(id).await;
|
||||
assert_pair(&committed);
|
||||
assert!(
|
||||
committed.source_project.files[&committed.source_project.entry]
|
||||
.contains("// Authored review rationale")
|
||||
);
|
||||
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
|
||||
assert!(
|
||||
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
|
||||
);
|
||||
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
|
||||
let facts: Vec<FacetDrift> =
|
||||
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
facts
|
||||
.iter()
|
||||
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
|
||||
.unwrap()
|
||||
.drift,
|
||||
Drift::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
facts
|
||||
.iter()
|
||||
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
|
||||
.unwrap()
|
||||
.drift,
|
||||
Drift::Unreviewed
|
||||
);
|
||||
assert!(
|
||||
queried["items"][0]["drift"]["own_unreviewed"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
);
|
||||
let repeat = preview(&server, &committed, &req).await;
|
||||
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
|
||||
assert_eq!(reapplied["changed"], false);
|
||||
assert_eq!(reapplied["revision"], committed.revision);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(id).await;
|
||||
assert_eq!(reopened.model, committed.model);
|
||||
assert_eq!(reopened.source_project, committed.source_project);
|
||||
assert_eq!(reopened.etag(), committed.etag());
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, etag) = server.import(&fixture()).await;
|
||||
let stored = server.get(id).await;
|
||||
let disk_path = directory.path().join(format!("{id}.json"));
|
||||
let before = std::fs::read(&disk_path).unwrap();
|
||||
let req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
let plan = preview(&server, &stored, &req).await;
|
||||
for operation in ["preview", "apply"] {
|
||||
let body = if operation == "preview" {
|
||||
serde_json::to_value(&req).unwrap()
|
||||
} else {
|
||||
plan.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
post(&server, id, operation, None, &body).await.status(),
|
||||
StatusCode::PRECONDITION_REQUIRED
|
||||
);
|
||||
}
|
||||
let mut malformed = serde_json::to_value(&req).unwrap();
|
||||
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
|
||||
assert_eq!(
|
||||
post(&server, id, "preview", Some(&etag), &malformed)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
malformed = serde_json::to_value(&req).unwrap();
|
||||
malformed["unexpected"] = true.into();
|
||||
assert_eq!(
|
||||
post(&server, id, "preview", Some(&etag), &malformed)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
for (query, expected) in [
|
||||
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
|
||||
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
|
||||
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
|
||||
(
|
||||
json!({"traces":[key(4)],"revision":99}),
|
||||
StatusCode::CONFLICT,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
post(&server, id, "query", None, &query).await.status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
for mutation in [
|
||||
"commands",
|
||||
"base_fingerprint",
|
||||
"prepared_fingerprint",
|
||||
"base_revision",
|
||||
] {
|
||||
let mut rejected = plan.clone();
|
||||
let expected = if mutation == "base_revision" {
|
||||
rejected[mutation] = 99.into();
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
} else {
|
||||
rejected["plan"][mutation] = if mutation == "commands" {
|
||||
json!([])
|
||||
} else {
|
||||
json!("altered")
|
||||
};
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
};
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&etag), &rejected)
|
||||
.await
|
||||
.status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut duplicate = req.clone();
|
||||
let repeated = duplicate.reviews[0].resolutions[0].clone();
|
||||
duplicate.reviews[0].resolutions.push(repeated);
|
||||
let conflicted = preview(&server, &stored, &duplicate).await;
|
||||
assert!(
|
||||
!conflicted["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&etag), &conflicted)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
|
||||
assert_eq!(server.get(id).await.etag(), etag);
|
||||
let mut bundle = stored.source_project.clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// Source-only concurrent edit\n");
|
||||
let changed = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source-project")))
|
||||
.header("if-match", &etag)
|
||||
.json(&bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), StatusCode::OK);
|
||||
let current = server.get(id).await;
|
||||
assert_eq!(current.model, stored.model);
|
||||
let bytes = std::fs::read(&disk_path).unwrap();
|
||||
for precondition in [&etag, ¤t.etag()] {
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(precondition), &plan)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let mut model = fixture();
|
||||
let mut second = model.traces[&key(4)].clone();
|
||||
second.id = key(5);
|
||||
second.alias = "second_review".into();
|
||||
second.kind = TraceKind::Manual;
|
||||
model.traces.insert(second.id, second);
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let mut req = request(
|
||||
key(3),
|
||||
key(2),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SemanticDecision::TakeSource,
|
||||
),
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
),
|
||||
],
|
||||
);
|
||||
let ambiguous = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!ambiguous["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
req.traces.insert(key(3), key(5));
|
||||
let mut candidate = model.elements[&key(2)].clone();
|
||||
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
|
||||
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
|
||||
*unit = "cm".into();
|
||||
}
|
||||
req.candidates
|
||||
.push(SemanticRecord::Element(candidate.clone()));
|
||||
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
|
||||
let envelope = preview(&server, &stored, &req).await;
|
||||
assert_eq!(
|
||||
envelope["plan"]["request"],
|
||||
serde_json::to_value(&req).unwrap()
|
||||
);
|
||||
assert_eq!(envelope["plan"]["conflicts"], json!([]));
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
|
||||
let applied = server.get(id).await;
|
||||
assert_eq!(applied.model.elements[&key(2)], candidate);
|
||||
assert!(
|
||||
applied.model.traces[&key(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
applied.model.traces[&key(5)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.is_some()
|
||||
);
|
||||
assert_pair(&applied);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let mut model = Project::empty();
|
||||
let source = model
|
||||
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
|
||||
.unwrap();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let req = SemanticTransitionRequest::new(TransitionRequest::new(
|
||||
TransitionDirection::OaToSa,
|
||||
vec![source],
|
||||
));
|
||||
let envelope = preview(&server, &stored, &req).await;
|
||||
assert_eq!(envelope["plan"]["conflicts"], json!([]));
|
||||
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
|
||||
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
|
||||
assert_eq!(server.get(id).await.model, expected);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let model = fixture();
|
||||
let reviewed = oa_core::semantic::plan_semantic_transition(
|
||||
&model,
|
||||
request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
(
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
|
||||
let mut req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
)],
|
||||
);
|
||||
let mut candidate = model.elements[&key(3)].clone();
|
||||
candidate.name = "Unselected incidental edit".into();
|
||||
req.candidates.push(SemanticRecord::Element(candidate));
|
||||
let invalid = preview(&server, &stored, &req).await;
|
||||
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&stored.etag()), &invalid)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
before
|
||||
);
|
||||
req = request(key(2), key(3), vec![]);
|
||||
req.reviews[0]
|
||||
.reset
|
||||
.push(FacetSelector::Data(DataFacet::Unit));
|
||||
let reset = preview(&server, &stored, &req).await;
|
||||
assert_eq!(
|
||||
reset["plan"]["request"]["reviews"][0]["reset"],
|
||||
json!([{"Data":"Unit"}])
|
||||
);
|
||||
assert_eq!(reset["plan"]["conflicts"], json!([]));
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
|
||||
let current = server.get(id).await;
|
||||
let facts = ¤t.model.traces[&key(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets;
|
||||
assert_eq!(facts.len(), 1);
|
||||
assert_eq!(
|
||||
facts[0].selector(),
|
||||
FacetSelector::Header(HeaderFacet::Name)
|
||||
);
|
||||
assert_eq!(current.model.elements, model.elements);
|
||||
assert_pair(¤t);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
fn cleanup_fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
|
||||
.unwrap();
|
||||
let base = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
|
||||
.unwrap();
|
||||
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let sender = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let receiver = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Location report",
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
let RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&exchange).unwrap().body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
items.push(item);
|
||||
for id in [*source_port, *target_port] {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
} => incoming_items.push(item),
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
} => outgoing_items.push(item),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
|
||||
.unwrap();
|
||||
let message = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![MessageValue {
|
||||
path: vec![field],
|
||||
field: leaf,
|
||||
expression: "10".into(),
|
||||
}],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(message);
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "record_trace".into(),
|
||||
name: "Record correspondence".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
(p, oa, sa, message)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (model, oa, sa, message) = cleanup_fixture();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let path = directory.path().join(format!("{id}.json"));
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let mut req = request(
|
||||
oa,
|
||||
sa,
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
let unresolved = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!unresolved["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let impacts: Vec<SemanticImpact> =
|
||||
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
|
||||
assert_eq!(impacts.len(), 1);
|
||||
assert_eq!(impacts[0].object, message);
|
||||
assert_ne!(impacts[0].before, impacts[0].after);
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
let mut inaccurate = impacts.clone();
|
||||
inaccurate[0].after = inaccurate[0].before.clone();
|
||||
req.accepted_impacts = inaccurate;
|
||||
let inaccurate = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!inaccurate["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
req.accepted_impacts = impacts;
|
||||
let approved = preview(&server, &stored, &req).await;
|
||||
assert_eq!(approved["plan"]["conflicts"], json!([]));
|
||||
assert_eq!(
|
||||
approved["plan"]["request"]["accepted_impacts"],
|
||||
serde_json::to_value(&req.accepted_impacts).unwrap()
|
||||
);
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
|
||||
let current = server.get(id).await;
|
||||
assert_eq!(current.revision, stored.revision + 1);
|
||||
assert!(
|
||||
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
|
||||
);
|
||||
assert_pair(¤t);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
assert_eq!(server.get(id).await.model, current.model);
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Validate actual local HTTP responses against served and checked-in OpenAPI.
|
||||
|
||||
Requires jsonschema. Usage: python semantic_http_contract.py /path/to/arcadia-server
|
||||
The supplied binary is launched on loopback with a fresh temporary store.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
local = json.loads((ROOT / "docs/openapi.json").read_text(encoding="utf-8"))
|
||||
for schema in local["components"]["schemas"].values():
|
||||
Draft202012Validator.check_schema(schema)
|
||||
|
||||
|
||||
def validator(spec, name):
|
||||
return Draft202012Validator({"$ref": "#/components/schemas/" + name, "components": spec["components"]},
|
||||
format_checker=FormatChecker())
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="arcadia-semantic-http-") as directory:
|
||||
env = dict(os.environ)
|
||||
env.pop("ARCADIA_SERVER_TOKEN", None)
|
||||
process = subprocess.Popen([sys.argv[1], "--bind", "127.0.0.1:0", "--data-dir", directory],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
||||
try:
|
||||
banner = process.stderr.readline()
|
||||
address = re.search(r"listening on (127\.0\.0\.1:\d+)", banner)
|
||||
assert address, banner
|
||||
base = "http://" + address.group(1)
|
||||
|
||||
def call(method, path, body=None, etag=None, expected=200):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
request = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(),
|
||||
headers=headers, method=method)
|
||||
try:
|
||||
response = urllib.request.urlopen(request, timeout=15)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
data = json.load(response)
|
||||
assert response.status == expected, (response.status, data)
|
||||
return data, response.headers.get("ETag")
|
||||
|
||||
served, _ = call("GET", "/openapi.json")
|
||||
assert served == local, "Server binary embeds a different OpenAPI file; rebuild it"
|
||||
checked = 0
|
||||
|
||||
def validate(name, value):
|
||||
global checked
|
||||
for spec in (local, served):
|
||||
validator(spec, name).validate(value)
|
||||
checked += 1
|
||||
|
||||
old = json.loads((ROOT / "crates/oa-core/tests/fixtures/v4-reviewed-prose.json").read_text())
|
||||
imported, etag = call("POST", "/api/v1/projects/import", old, expected=201)
|
||||
path = "/api/v1/projects/" + imported["resource_id"]
|
||||
stored, _ = call("GET", path)
|
||||
validate("StoredProject", stored)
|
||||
validate("Project", stored["model"])
|
||||
trace = next(iter(stored["model"]["traces"]))
|
||||
oa = stored["model"]["traces"][trace]["oa"]
|
||||
sa = stored["model"]["traces"][trace]["sa"]
|
||||
query = {"traces": [trace], "references": {oa: sa}, "revision": stored["revision"]}
|
||||
validate("SemanticQueryRequest", query)
|
||||
queried, _ = call("POST", path + "/semantic-transitions/query", query)
|
||||
validate("SemanticQueryResult", queried)
|
||||
request = {"transition": {"direction": "OaToSa", "sources": [oa], "scope": "SelectedOnly",
|
||||
"mappings": [{"source": oa, "target": sa, "target_kind": None}], "fields": [],
|
||||
"resolutions": [], "destination_diagram": None}, "traces": {oa: trace},
|
||||
"references": {}, "reviews": [{"source": oa, "resolutions": [
|
||||
{"facet": {"Header": "Name"}, "decision": "TakeSource"},
|
||||
{"facet": {"Data": "Unit"}, "decision": "KeepTarget"}], "reset": []}],
|
||||
"candidates": [], "accepted_impacts": []}
|
||||
validate("SemanticTransitionRequest", request)
|
||||
one_sided = copy.deepcopy(request)
|
||||
one_sided["reviews"][0]["resolutions"] = [{"facet": {"Literal": {"oa": "Only source"}}, "decision": "KeepTarget"}]
|
||||
validate("SemanticTransitionRequest", one_sided)
|
||||
admitted, _ = call("POST", path + "/semantic-transitions/preview", one_sided, etag)
|
||||
validate("SemanticTransitionEnvelope", admitted)
|
||||
assert admitted["plan"]["conflicts"] # Valid typed input, inapplicable to the integer/real fixture.
|
||||
preview, _ = call("POST", path + "/semantic-transitions/preview", request, etag)
|
||||
validate("SemanticTransitionEnvelope", preview)
|
||||
assert not preview["plan"]["conflicts"], preview
|
||||
result, updated_etag = call("POST", path + "/semantic-transitions/apply", preview, etag)
|
||||
validate("MutationResult", result)
|
||||
stored, _ = call("GET", path)
|
||||
validate("StoredProject", stored)
|
||||
validate("Project", stored["model"])
|
||||
baseline = stored["model"]["traces"][trace]["baseline"]
|
||||
validate("TraceBaseline", baseline)
|
||||
validate("SemanticBaseline", baseline["semantic"])
|
||||
query.pop("revision")
|
||||
queried, _ = call("POST", path + "/semantic-transitions/query", query)
|
||||
validate("SemanticQueryResult", queried)
|
||||
problem, _ = call("POST", path + "/semantic-transitions/apply", preview, etag, expected=412)
|
||||
validate("Problem", problem)
|
||||
assert updated_etag != etag
|
||||
|
||||
invalid = [
|
||||
("FacetSelector", {"path": ["definition", "minimum"]}),
|
||||
("FacetSelector", {"Data": "Unsupported"}),
|
||||
("FacetReview", {"Data": {"IntegerMinimum": {"oa": {"Value": "wrong"}, "sa": {"Value": None}}}}),
|
||||
("FacetReview", {"Data": {"Unit": {"oa": {"Value": 42}, "sa": "NotApplicable"}}}),
|
||||
("FacetSelector", {"Literal": {"oa": "literal", "sa": None, "index": 0}}),
|
||||
("SemanticQueryRequest", {"traces": [trace, trace]}),
|
||||
("SemanticQueryRequest", {"traces": []}),
|
||||
("SemanticTransitionEnvelope", {**preview, "unknown": True}),
|
||||
]
|
||||
obsolete = copy.deepcopy(stored["model"])
|
||||
obsolete["schema_version"] = 4
|
||||
invalid.append(("Project", obsolete))
|
||||
for name, value in invalid:
|
||||
assert not validator(local, name).is_valid(value), (name, value)
|
||||
assert not validator(served, name).is_valid(value), (name, value)
|
||||
# NotApplicable is distinct from Value(null); each leaf fixes the optionality.
|
||||
validate("FacetReview", {"Data": {"IntegerMinimum": {"oa": "NotApplicable", "sa": {"Value": None}}}})
|
||||
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}, "sa": None}})
|
||||
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}}})
|
||||
validate("SemanticImpact", {"object": oa, "after": {"Element": stored["model"]["elements"][oa]}})
|
||||
print(f"{len(local['components']['schemas'])} valid schemas; {checked} actual/typed values validated against served+local contracts; {len(invalid)} invalid shapes rejected")
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
|
||||
|
||||
Run from any directory with Python 3: semantic_schema.py [--check].
|
||||
This generates documentation only, never runtime selectors or model mutations.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PATH = ROOT / "docs/openapi.json"
|
||||
spec = json.loads(PATH.read_text(encoding="utf-8"))
|
||||
schemas = spec["components"]["schemas"]
|
||||
types = (ROOT / "crates/oa-core/src/semantic/types.rs").read_text(encoding="utf-8")
|
||||
model = (ROOT / "crates/oa-core/src/model.rs").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def ref(name):
|
||||
return {"$ref": f"#/components/schemas/{name}"}
|
||||
|
||||
|
||||
def obj(properties, required=None):
|
||||
return {"type": "object", "properties": properties,
|
||||
"required": list(properties) if required is None else required,
|
||||
"additionalProperties": False}
|
||||
|
||||
|
||||
def array(item, maximum=None):
|
||||
result = {"type": "array", "items": item}
|
||||
if maximum is not None:
|
||||
result["maxItems"] = maximum
|
||||
return result
|
||||
|
||||
|
||||
def optional(item):
|
||||
return {"anyOf": [item, {"type": "null"}]}
|
||||
|
||||
|
||||
def enum(names):
|
||||
return {"type": "string", "enum": names}
|
||||
|
||||
|
||||
def variant(name, value):
|
||||
return obj({name: value})
|
||||
|
||||
|
||||
def typed(name):
|
||||
if name.startswith("Option<"):
|
||||
return optional(typed(name[7:-1]))
|
||||
if name.startswith("Vec<"):
|
||||
return array(typed(name[4:-1]))
|
||||
primitives = {"String": {"type": "string"}, "bool": {"type": "boolean"},
|
||||
"u32": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"i64": {"type": "integer", "minimum": -9223372036854775808,
|
||||
"maximum": 9223372036854775807}, "f64": {"type": "number"}}
|
||||
return primitives[name] if name in primitives else ref(name)
|
||||
|
||||
|
||||
def reviewed(name):
|
||||
key = "Reviewed" + re.sub(r"[^A-Za-z0-9]", "", name)
|
||||
observation = {"oneOf": [enum(["NotApplicable"]), variant("Value", typed(name))]}
|
||||
schemas[key] = obj({"oa": observation, "sa": copy.deepcopy(observation)})
|
||||
return ref(key)
|
||||
|
||||
|
||||
def keypair(name):
|
||||
key = "KeyPair" + name
|
||||
schemas[key] = obj({"oa": optional(typed(name)), "sa": optional(typed(name))}, [])
|
||||
return ref(key)
|
||||
|
||||
|
||||
for name in ["DataConstructor", "PortConstructor", "OccurrenceConstructor", "EventConstructor",
|
||||
"MessageConstructor", "ReferenceCollection", "OrderedCollection", "IncidentEnd",
|
||||
"ArtifactFacet", "ComponentPortKind", "ComponentPortOrientation", "PortDirection",
|
||||
"FragmentOperator", "StateActionKind", "ArtifactRelation"]:
|
||||
source = types if f"pub enum {name}" in types else model
|
||||
body = re.search(r"pub enum " + name + r"\s*\{([^}]+)\}", source).group(1)
|
||||
body = re.sub(r"//[^\n]*|#\[[^\]]*\]", "", body)
|
||||
names = [value.strip() for value in body.split(",") if value.strip()]
|
||||
assert all(re.fullmatch(r"[A-Za-z]+", value) for value in names), (name, names)
|
||||
schemas[name] = enum(names)
|
||||
|
||||
catalog = types.split("macro_rules! scalar_families", 1)[1].split("pub(super) use", 1)[0]
|
||||
families = re.findall(r"(\w+), (\w+), (\w+) \{([^}]+)\}", catalog)
|
||||
assert len(families) == 13
|
||||
selectors, reviews = [], []
|
||||
leaf_count = 0
|
||||
for family, selector, review, body in families:
|
||||
leaves = re.findall(r"(\w+): ([\w<>]+)", body)
|
||||
leaf_count += len(leaves)
|
||||
schemas[selector] = enum([leaf for leaf, _ in leaves])
|
||||
schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]}
|
||||
selectors.append(variant(family, ref(selector)))
|
||||
reviews.append(variant(family, ref(review)))
|
||||
assert leaf_count == 84
|
||||
schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")})
|
||||
id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey")
|
||||
artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id",
|
||||
"Relation": "ArtifactRelation", "Uri": "String", "Revision": "Option<String>",
|
||||
"Fragment": "Option<String>"}
|
||||
schemas["ArtifactReview"] = {"oneOf": [variant(k, reviewed(v)) for k, v in artifact_types.items()]}
|
||||
selectors += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys})),
|
||||
variant("Order", ref("OrderedCollection")), variant("Literal", text_keys),
|
||||
variant("Annotation", annotation_keys), variant("Binding", id_keys), variant("Child", id_keys),
|
||||
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd")})),
|
||||
variant("Artifact", obj({"keys": id_keys, "facet": ref("ArtifactFacet")}))]
|
||||
reviews += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys, "review": reviewed("bool")})),
|
||||
variant("Order", obj({"collection": ref("OrderedCollection"), "review": reviewed("Vec<Id>")})),
|
||||
variant("Literal", obj({"keys": text_keys, "review": reviewed("bool")})),
|
||||
variant("Annotation", obj({"keys": annotation_keys, "review": reviewed("Option<MessageValue>")})),
|
||||
variant("Binding", obj({"keys": id_keys, "review": reviewed("Option<LifelineBinding>")})),
|
||||
variant("Child", obj({"keys": id_keys, "review": reviewed("bool")})),
|
||||
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd"), "review": reviewed("bool")})),
|
||||
variant("Artifact", obj({"keys": id_keys, "review": ref("ArtifactReview")}))]
|
||||
schemas["FacetSelector"] = {"oneOf": selectors}
|
||||
schemas["FacetReview"] = {"oneOf": reviews}
|
||||
schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))})
|
||||
schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None,
|
||||
"description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."}
|
||||
schemas["Project"]["properties"]["schema_version"]["const"] = 5
|
||||
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4]
|
||||
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 5
|
||||
schemas["MigrationReport"]["description"] = schemas["MigrationReport"]["description"].replace("V1/V2/V3 to V4", "V1/V2/V3/V4 to V5")
|
||||
spec["info"]["description"] = spec["info"]["description"].replace("schema-4", "schema-5")
|
||||
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-5 native JSON is described below. Schema-1/2/3/4 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Prior prose reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 5 / arcadia 5. See docs/language.rst."
|
||||
schemas["SourceBundle"]["description"] = schemas["SourceBundle"]["description"].replace("source4 workspace (source3 migrates explicitly)", "source5 workspace (source3/4 migrate explicitly)")
|
||||
schemas["Drift"] = enum(["Unreviewed", "Unchanged", "OaChanged", "SaChanged", "BothChanged"])
|
||||
schemas["FacetDrift"] = obj({"selector": ref("FacetSelector"), "current": ref("FacetReview"), "drift": ref("Drift")})
|
||||
schemas["SemanticDrift"] = obj({"own": ref("Drift"), "linked": ref("Drift"),
|
||||
"own_unreviewed": typed("bool"), "linked_unreviewed": typed("bool")})
|
||||
schemas["LinkedSemanticImpact"] = obj({"objects": id_keys, "traces": array(ref("Id")),
|
||||
"drift": ref("Drift"), "unreviewed": typed("bool")})
|
||||
for name, members in [("SemanticRecord", ["Element", "Relation", "Artifact"]),
|
||||
("ImpactRecord", ["Element", "Relation", "Artifact", "Diagram", "Trace"])]:
|
||||
schemas[name] = {"oneOf": [variant(n, ref("ArtifactLink" if n == "Artifact" else n)) for n in members]}
|
||||
schemas["SemanticImpact"] = obj({"object": ref("Id"), "before": optional(ref("ImpactRecord")), "after": optional(ref("ImpactRecord"))}, ["object"])
|
||||
schemas["SemanticDecision"] = enum(["KeepTarget", "TakeSource", "Candidate"])
|
||||
schemas["FacetResolution"] = obj({"facet": ref("FacetSelector"), "decision": ref("SemanticDecision")})
|
||||
schemas["SemanticReviewRequest"] = obj({"source": ref("Id"), "resolutions": array(ref("FacetResolution"), 5000),
|
||||
"reset": {**array(ref("FacetSelector"), 5000), "default": []}}, ["source", "resolutions"])
|
||||
|
||||
|
||||
def mappings(maximum):
|
||||
return {"type": "object", "propertyNames": {"format": "uuid"},
|
||||
"additionalProperties": ref("Id"), "maxProperties": maximum, "default": {}}
|
||||
|
||||
|
||||
schemas["SemanticTransitionRequest"] = obj({"transition": ref("TransitionRequest"), "traces": mappings(100),
|
||||
"references": mappings(5000), "reviews": {**array(ref("SemanticReviewRequest"), 100), "default": []},
|
||||
"candidates": {**array(ref("SemanticRecord"), 200), "default": []},
|
||||
"accepted_impacts": {**array(ref("SemanticImpact"), 200), "default": []}}, ["transition"])
|
||||
schemas["SemanticTransitionRequest"]["description"] = "Canonical OA-to-SA references include historical UUIDs. Traces map selected source IDs to trace IDs. Combined resolutions/reset limit: 5,000. Candidates must equal exactly the result of their selected facets; accepted_impacts explicitly acknowledge exact induced changes."
|
||||
schemas["SemanticReviewPreview"] = obj({"source": ref("Id"), "target": ref("Id"), "facets": array(ref("FacetDrift")),
|
||||
"acknowledged": array(ref("FacetSelector")), "retired": array(ref("FacetSelector")), "linked_impacts": array(ref("LinkedSemanticImpact"))})
|
||||
schemas["SemanticTransitionPlan"] = obj({"base_fingerprint": typed("String"), "request": ref("SemanticTransitionRequest"),
|
||||
"transition": ref("TransitionPlan"), "reviews": array(ref("SemanticReviewPreview")), "impacts": array(ref("SemanticImpact")),
|
||||
"conflicts": array(typed("String")), "commands": array(ref("Command"), 200), "prepared_fingerprint": optional(typed("String"))})
|
||||
schemas["SemanticTransitionPlan"]["required"].remove("prepared_fingerprint")
|
||||
schemas["SemanticTransitionEnvelope"] = obj({"base_revision": ref("Revision"), "plan": ref("SemanticTransitionPlan")})
|
||||
schemas["SemanticQueryRequest"] = obj({"traces": {**array(ref("Id"), 100), "minItems": 1, "uniqueItems": True},
|
||||
"references": mappings(5000), "revision": optional(ref("Revision"))}, ["traces"])
|
||||
schemas["SemanticQueryItem"] = obj({"trace": ref("Id"), "oa": ref("Id"), "sa": ref("Id"),
|
||||
"facets": array(ref("FacetDrift")), "drift": ref("SemanticDrift"), "linked_impacts": array(ref("LinkedSemanticImpact"))})
|
||||
schemas["SemanticQueryResult"] = obj({"revision": ref("Revision"), "items": array(ref("SemanticQueryItem"), 100)})
|
||||
for operation, request, result in [("query", "SemanticQueryRequest", "SemanticQueryResult"),
|
||||
("preview", "SemanticTransitionRequest", "SemanticTransitionEnvelope"),
|
||||
("apply", "SemanticTransitionEnvelope", "MutationResult")]:
|
||||
endpoint = copy.deepcopy(spec["paths"]["/api/v1/projects/{id}/transitions/" + ("apply" if operation == "apply" else "preview")])
|
||||
post = endpoint["post"]
|
||||
post["operationId"] = operation + "SemanticTransition"
|
||||
post["summary"] = {"query": "Query typed trace drift and linked impacts without mutation",
|
||||
"preview": "Preview and source-validate a typed semantic transition without writing",
|
||||
"apply": "Conditionally apply a prepared semantic candidate in one model/source revision"}[operation]
|
||||
post["requestBody"]["content"]["application/json"]["schema"] = ref(request)
|
||||
post["responses"]["200"]["content"]["application/json"]["schema"] = ref(result)
|
||||
post["description"] = "Uses the frozen core typed semantic contract. A supplied plan is a client-owned candidate, not a signed receipt; fingerprints check consistency, not original review authorship. Preview validates source writeback when conflict-free. Apply preserves preview-generated IDs and does not replan random identities."
|
||||
if operation == "query":
|
||||
post["parameters"] = [p for p in post["parameters"] if p["in"] != "header"]
|
||||
post["responses"].pop("412", None)
|
||||
post["responses"].pop("428", None)
|
||||
post["responses"]["409"] = {"$ref": "#/components/responses/Problem"}
|
||||
post["description"] = "Read one immutable snapshot. Optional revision mismatch returns409; missing trace returns404; duplicate/empty trace selection returns400. References are canonical OA-to-SA correspondence. No write, baseline refresh or external artifact fetch."
|
||||
spec["paths"]["/api/v1/projects/{id}/semantic-transitions/" + operation] = endpoint
|
||||
|
||||
|
||||
def check_refs(value):
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value and value["$ref"].startswith("#/components/schemas/"):
|
||||
assert value["$ref"].split("/")[-1] in schemas, value
|
||||
for child in value.values():
|
||||
check_refs(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
check_refs(child)
|
||||
|
||||
|
||||
check_refs(spec)
|
||||
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
|
||||
if "--check" in sys.argv:
|
||||
assert json.loads(PATH.read_text(encoding="utf-8")) == spec, "Run semantic_schema.py to refresh OpenAPI"
|
||||
else:
|
||||
PATH.write_text(rendered, encoding="utf-8")
|
||||
print(f"Closed catalog: {len(families)} families, {leaf_count} scalar leaves; {len(schemas)} schemas")
|
||||
+6
-114
@@ -763,18 +763,6 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/transitions/apply</span></span></p></td>
|
||||
<td><p>Conditionally apply that client-held plan and base_revision.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/query</span></span></p></td>
|
||||
<td><p>Read selected traces' typed facts, own drift and linked impacts.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/preview</span></span></p></td>
|
||||
<td><p>Prepare typed semantic decisions and source writeback without writing.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/apply</span></span></p></td>
|
||||
<td><p>Apply that prepared candidate under its original shared revision/ETag.</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><span class="docutils literal"><span class="pre">Content-Type:</span> application/json</span> is required for JSON requests.
|
||||
@@ -796,8 +784,8 @@ candidate validation reports the same diagnostic in its 200 response.
|
||||
Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.</p>
|
||||
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
|
||||
@@ -808,7 +796,7 @@ Only documented query parameters are accepted. In particular, appending
|
||||
<pre class="literal-block">{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}</pre>
|
||||
@@ -819,7 +807,7 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.</p>
|
||||
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
@@ -1011,7 +999,7 @@ contract in the same transaction when removing a used interface allocation.</p>
|
||||
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema5/source5. Reading an old envelope does not write it or
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
</section>
|
||||
<section id="validation">
|
||||
@@ -1021,7 +1009,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"arcadia", "source":"complete source document"}</pre>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
@@ -1105,102 +1093,6 @@ authenticated as an untouched server preview; the same authenticated client
|
||||
can submit ordinary typed commands. The core fingerprint is a change detector,
|
||||
not a security digest. There is no plan token, expiration store or receipt.</p>
|
||||
</section>
|
||||
<section id="typed-semantic-transition-review">
|
||||
<h2>Typed semantic transition review</h2>
|
||||
<p>The sibling <span class="docutils literal"><span class="pre">semantic-transitions</span></span> routes use the <a class="reference external" href="language.html#typed-semantic-review-facts">typed review semantics</a> in <span class="docutils literal">language.rst</span> sections 5.1–5.5.
|
||||
The original prose transition routes keep
|
||||
their existing contract. All new routes use JSON and the existing authentication,
|
||||
capacity, worker and error boundaries.</p>
|
||||
<p>Query accepts <span class="docutils literal"><span class="pre">{"traces":["trace-uuid"],"references":{},"revision":1}</span></span>.
|
||||
<span class="docutils literal">references</span> is an optional map of canonical OA UUIDs to SA UUIDs, including
|
||||
historical deleted identities. It never guesses correspondences from names.
|
||||
<span class="docutils literal">revision</span> is optional; a mismatch returns 409. Select 1–100 unique existing
|
||||
trace IDs; an empty or duplicate selection returns 400 and a missing trace
|
||||
returns 404. The response includes the current revision and items containing
|
||||
<span class="docutils literal">trace</span>, <span class="docutils literal">oa</span>, <span class="docutils literal">sa</span>, typed <span class="docutils literal">facets</span>, <span class="docutils literal">drift</span> and <span class="docutils literal">linked_impacts</span>.
|
||||
Own and linked drift and their unreviewed flags remain distinct. Query reads one
|
||||
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.</p>
|
||||
<p>Preview accepts <span class="docutils literal">SemanticTransitionRequest</span>:</p>
|
||||
<pre class="literal-block">{
|
||||
"transition": {
|
||||
"direction": "OaToSa",
|
||||
"sources": ["40000000-0000-4000-8000-000000000002"],
|
||||
"scope": "SelectedOnly",
|
||||
"mappings": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"target": "40000000-0000-4000-8000-000000000003",
|
||||
"target_kind": null
|
||||
}],
|
||||
"fields": [], "resolutions": [], "destination_diagram": null
|
||||
},
|
||||
"traces": {}, "references": {},
|
||||
"reviews": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"resolutions": [
|
||||
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
|
||||
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
|
||||
],
|
||||
"reset": []
|
||||
}],
|
||||
"candidates": [], "accepted_impacts": []
|
||||
}</pre>
|
||||
<p>The example maps an existing OA data type to its traced SA counterpart, takes
|
||||
only its name and acknowledges intentionally different units. Other facets stay
|
||||
unreviewed or retain their earlier drift. Select an explicit trace through the
|
||||
<span class="docutils literal">traces</span> map (source UUID to trace UUID) when multiple trace kinds connect the
|
||||
pair. <span class="docutils literal">reset</span> deliberately invalidates exact old selectors after changed
|
||||
correspondence. The optional collections default to empty. Semantic header
|
||||
decisions use typed facets; leave legacy <span class="docutils literal">transition.fields</span> and
|
||||
<span class="docutils literal">transition.resolutions</span> empty.</p>
|
||||
<p>Facet selectors are closed enums, for example <span class="docutils literal"><span class="pre">{"Data":"IntegerMinimum"}</span></span>
|
||||
or <span class="docutils literal"><span class="pre">{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}</span></span>.
|
||||
Annotation keys contain the entire path and field; enum literal keys are exact
|
||||
strings. <span class="docutils literal">KeepTarget</span>, <span class="docutils literal">TakeSource</span> and <span class="docutils literal">Candidate</span> operate on independently
|
||||
selected facts. Candidate records use existing Element/Relation/ArtifactLink
|
||||
types, must preserve identity/alias, and must equal precisely the result of
|
||||
the selected facets. Constructor review covers its coupled introduced/removed
|
||||
fields. Membership review does not silently acknowledge order; target-only
|
||||
members remain independent. OpenAPI defines every selector and typed payload.</p>
|
||||
<p>Preview requires <span class="docutils literal"><span class="pre">If-Match</span></span> and returns <span class="docutils literal"><span class="pre">{"base_revision":1,"plan":{...}}</span></span>.
|
||||
The plan contains the original typed request, mapping plan, review facts,
|
||||
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
|
||||
commands, and base/prepared fingerprints. A conflict-free preview also prepares
|
||||
the complete authored source bundle and checks its model equivalence and capacity.
|
||||
No preview changes the model, source, revision or stored bytes.</p>
|
||||
<p>If ordinary preparation induces additional cleanup, the plan reports exact typed
|
||||
before/after impacts and a conflict. Review those impacts, place the accepted
|
||||
records in <span class="docutils literal">accepted_impacts</span>, and request another preview. Acceptance cannot
|
||||
override a contradictory selected facet. Missing mappings, ambiguous order,
|
||||
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
|
||||
This is the shared core planner and command preparation, with no server-specific
|
||||
semantic mutation engine.</p>
|
||||
<p>Apply accepts the entire preview envelope under the same <span class="docutils literal"><span class="pre">If-Match</span></span>. It checks
|
||||
<span class="docutils literal">base_revision</span>, the model fingerprint, unresolved conflicts, the prepared
|
||||
candidate fingerprint, ordinary model validation and coupled source preparation,
|
||||
then atomically stores one accepted model/source revision. The final store check
|
||||
also catches a concurrent edit during preparation. Source-only edits invalidate
|
||||
the preview through the shared ETag even when the model fingerprint is unchanged.
|
||||
Stale ETag/revision returns 412; semantic rejection returns 422 with code
|
||||
<span class="docutils literal">semantic_transition_rejected</span>. Exact no-op candidates preserve revision.
|
||||
Read and preview again after a successful changed apply before reviewing further.</p>
|
||||
<p>A supplied plan remains a client-owned candidate. Fingerprints detect stale or
|
||||
inconsistent proposals; they are not tamper-proof signatures, authentication
|
||||
receipts or proof of original review authorship. Apply preserves preview-generated
|
||||
identities and does not rerun the planner with fresh random IDs. Authenticated
|
||||
clients already have ordinary model/source editing privileges. The server does
|
||||
not persist plans, tokens or review sessions.</p>
|
||||
<p>Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
|
||||
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
|
||||
resolutions plus resets across all reviews. The legacy 400 prose-resolution
|
||||
limit and 200-command transaction limit still apply. Query has the same 5,000
|
||||
reference-map limit. Core bounded linked traversal errors remain observable as
|
||||
semantic rejection; no truncated result silently claims complete review.</p>
|
||||
<p>Schema/source 5 adds <span class="docutils literal">TraceBaseline.semantic</span>. Absence or null is Unreviewed;
|
||||
migrating older prose baselines does not fabricate semantic history. The persisted
|
||||
baseline contains only closed typed <span class="docutils literal">FacetReview</span> facts, not whole candidate
|
||||
records or a generic JSON value tree. Historical UUIDs remain historical, including
|
||||
after deletion, and source uses explicit <span class="docutils literal">uuid</span> tokens for them.</p>
|
||||
</section>
|
||||
<section id="error-contract">
|
||||
<h2>Error contract</h2>
|
||||
<p>All errors have <span class="docutils literal"><span class="pre">Content-Type:</span> application/problem+json</span>. Example:</p>
|
||||
|
||||
+6
-124
@@ -124,15 +124,6 @@ bearer token, if any. Non-loopback binding requires a configured token.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/transitions/apply``
|
||||
- Conditionally apply that client-held plan and base_revision.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/query``
|
||||
- Read selected traces' typed facts, own drift and linked impacts.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/preview``
|
||||
- Prepare typed semantic decisions and source writeback without writing.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/apply``
|
||||
- Apply that prepared candidate under its original shared revision/ETag.
|
||||
|
||||
``Content-Type: application/json`` is required for JSON requests.
|
||||
``text/plain; charset=utf-8`` is used for Arcadia source. The service does not
|
||||
@@ -155,8 +146,8 @@ Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.
|
||||
|
||||
Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
``?revision=...`` to model/source export is an error, not historical retrieval.
|
||||
@@ -169,7 +160,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
|
||||
{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}
|
||||
@@ -181,7 +172,7 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.
|
||||
|
||||
@@ -403,7 +394,7 @@ contract in the same transaction when removing a used interface allocation.
|
||||
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema5/source5. Reading an old envelope does not write it or
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.
|
||||
|
||||
Validation
|
||||
@@ -419,7 +410,7 @@ or::
|
||||
|
||||
or::
|
||||
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
|
||||
|
||||
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
@@ -515,115 +506,6 @@ authenticated as an untouched server preview; the same authenticated client
|
||||
can submit ordinary typed commands. The core fingerprint is a change detector,
|
||||
not a security digest. There is no plan token, expiration store or receipt.
|
||||
|
||||
Typed semantic transition review
|
||||
--------------------------------
|
||||
|
||||
The sibling ``semantic-transitions`` routes use the `typed review semantics
|
||||
<language.html#typed-semantic-review-facts>`_ in ``language.rst`` sections 5.1–5.5.
|
||||
The original prose transition routes keep
|
||||
their existing contract. All new routes use JSON and the existing authentication,
|
||||
capacity, worker and error boundaries.
|
||||
|
||||
Query accepts ``{"traces":["trace-uuid"],"references":{},"revision":1}``.
|
||||
``references`` is an optional map of canonical OA UUIDs to SA UUIDs, including
|
||||
historical deleted identities. It never guesses correspondences from names.
|
||||
``revision`` is optional; a mismatch returns 409. Select 1–100 unique existing
|
||||
trace IDs; an empty or duplicate selection returns 400 and a missing trace
|
||||
returns 404. The response includes the current revision and items containing
|
||||
``trace``, ``oa``, ``sa``, typed ``facets``, ``drift`` and ``linked_impacts``.
|
||||
Own and linked drift and their unreviewed flags remain distinct. Query reads one
|
||||
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.
|
||||
|
||||
Preview accepts ``SemanticTransitionRequest``::
|
||||
|
||||
{
|
||||
"transition": {
|
||||
"direction": "OaToSa",
|
||||
"sources": ["40000000-0000-4000-8000-000000000002"],
|
||||
"scope": "SelectedOnly",
|
||||
"mappings": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"target": "40000000-0000-4000-8000-000000000003",
|
||||
"target_kind": null
|
||||
}],
|
||||
"fields": [], "resolutions": [], "destination_diagram": null
|
||||
},
|
||||
"traces": {}, "references": {},
|
||||
"reviews": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"resolutions": [
|
||||
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
|
||||
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
|
||||
],
|
||||
"reset": []
|
||||
}],
|
||||
"candidates": [], "accepted_impacts": []
|
||||
}
|
||||
|
||||
The example maps an existing OA data type to its traced SA counterpart, takes
|
||||
only its name and acknowledges intentionally different units. Other facets stay
|
||||
unreviewed or retain their earlier drift. Select an explicit trace through the
|
||||
``traces`` map (source UUID to trace UUID) when multiple trace kinds connect the
|
||||
pair. ``reset`` deliberately invalidates exact old selectors after changed
|
||||
correspondence. The optional collections default to empty. Semantic header
|
||||
decisions use typed facets; leave legacy ``transition.fields`` and
|
||||
``transition.resolutions`` empty.
|
||||
|
||||
Facet selectors are closed enums, for example ``{"Data":"IntegerMinimum"}``
|
||||
or ``{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}``.
|
||||
Annotation keys contain the entire path and field; enum literal keys are exact
|
||||
strings. ``KeepTarget``, ``TakeSource`` and ``Candidate`` operate on independently
|
||||
selected facts. Candidate records use existing Element/Relation/ArtifactLink
|
||||
types, must preserve identity/alias, and must equal precisely the result of
|
||||
the selected facets. Constructor review covers its coupled introduced/removed
|
||||
fields. Membership review does not silently acknowledge order; target-only
|
||||
members remain independent. OpenAPI defines every selector and typed payload.
|
||||
|
||||
Preview requires ``If-Match`` and returns ``{"base_revision":1,"plan":{...}}``.
|
||||
The plan contains the original typed request, mapping plan, review facts,
|
||||
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
|
||||
commands, and base/prepared fingerprints. A conflict-free preview also prepares
|
||||
the complete authored source bundle and checks its model equivalence and capacity.
|
||||
No preview changes the model, source, revision or stored bytes.
|
||||
|
||||
If ordinary preparation induces additional cleanup, the plan reports exact typed
|
||||
before/after impacts and a conflict. Review those impacts, place the accepted
|
||||
records in ``accepted_impacts``, and request another preview. Acceptance cannot
|
||||
override a contradictory selected facet. Missing mappings, ambiguous order,
|
||||
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
|
||||
This is the shared core planner and command preparation, with no server-specific
|
||||
semantic mutation engine.
|
||||
|
||||
Apply accepts the entire preview envelope under the same ``If-Match``. It checks
|
||||
``base_revision``, the model fingerprint, unresolved conflicts, the prepared
|
||||
candidate fingerprint, ordinary model validation and coupled source preparation,
|
||||
then atomically stores one accepted model/source revision. The final store check
|
||||
also catches a concurrent edit during preparation. Source-only edits invalidate
|
||||
the preview through the shared ETag even when the model fingerprint is unchanged.
|
||||
Stale ETag/revision returns 412; semantic rejection returns 422 with code
|
||||
``semantic_transition_rejected``. Exact no-op candidates preserve revision.
|
||||
Read and preview again after a successful changed apply before reviewing further.
|
||||
|
||||
A supplied plan remains a client-owned candidate. Fingerprints detect stale or
|
||||
inconsistent proposals; they are not tamper-proof signatures, authentication
|
||||
receipts or proof of original review authorship. Apply preserves preview-generated
|
||||
identities and does not rerun the planner with fresh random IDs. Authenticated
|
||||
clients already have ordinary model/source editing privileges. The server does
|
||||
not persist plans, tokens or review sessions.
|
||||
|
||||
Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
|
||||
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
|
||||
resolutions plus resets across all reviews. The legacy 400 prose-resolution
|
||||
limit and 200-command transaction limit still apply. Query has the same 5,000
|
||||
reference-map limit. Core bounded linked traversal errors remain observable as
|
||||
semantic rejection; no truncated result silently claims complete review.
|
||||
|
||||
Schema/source 5 adds ``TraceBaseline.semantic``. Absence or null is Unreviewed;
|
||||
migrating older prose baselines does not fabricate semantic history. The persisted
|
||||
baseline contains only closed typed ``FacetReview`` facts, not whole candidate
|
||||
records or a generic JSON value tree. Historical UUIDs remain historical, including
|
||||
after deletion, and source uses explicit ``uuid`` tokens for them.
|
||||
|
||||
Error contract
|
||||
--------------
|
||||
|
||||
|
||||
+39
-411
@@ -623,7 +623,7 @@ a { text-decoration: none; }
|
||||
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
|
||||
<dl class="docinfo simple">
|
||||
<dt class="version">Version<span class="colon">:</span></dt>
|
||||
<dd class="version">5</dd>
|
||||
<dd class="version">4</dd>
|
||||
<dt class="status">Status<span class="colon">:</span></dt>
|
||||
<dd class="status">Normative current implementation contract; first-release integration</dd>
|
||||
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
|
||||
@@ -637,14 +637,12 @@ a { text-decoration: none; }
|
||||
<h2>1. Scope and conformance</h2>
|
||||
<p>This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.</p>
|
||||
<p>Schema 5 adds closed typed semantic review facts to trace baselines, including
|
||||
historical references that may no longer exist in the live model. It retains
|
||||
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
|
||||
qualified values, specialization, interface allocations and associations, and
|
||||
schema 3 typed ports and state views. Explicit older readers preserve meanings
|
||||
and identities without inventing previously reviewed semantic values.
|
||||
<p>Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.</p>
|
||||
<p>OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
@@ -663,7 +661,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (5), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (4), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -986,10 +984,9 @@ declares <span class="docutils literal">outgoing_items</span>; <span class="docu
|
||||
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||
exchange MUST reference output/input ports owned by its respective functions,
|
||||
and each carried item MUST fit both declarations. Empty declarations accept
|
||||
no items. A carried item satisfies an identical declaration or a declaration
|
||||
of one of its explicit base items. A base item does not satisfy a more
|
||||
specialized declaration; names imply no compatibility. Port declarations
|
||||
remain independent of incident exchanges after creation.</p>
|
||||
no items. Item compatibility is identity equality; future specialization is
|
||||
not inferred from names. Port declarations remain independent of incident
|
||||
exchanges after creation.</p>
|
||||
<p>A <span class="docutils literal">Component</span> contract belongs to an SA System or SystemActor and stores
|
||||
<span class="docutils literal">kind</span> (Standard or Flow) and <span class="docutils literal">orientation</span> (Unset, In, Out, InOut).
|
||||
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
|
||||
@@ -1017,12 +1014,12 @@ pickers do not offer it. Resolution to a typed SA contract retains this record
|
||||
without treating it as another active constraint. OA generic ports remain
|
||||
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||
Deleting a referenced legacy interface clears that optional live reference.</p>
|
||||
<p>A current-source functional exchange MUST specify both port aliases; the parser does
|
||||
<p>A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
|
||||
<p>For example:</p>
|
||||
<pre class="literal-block">arcadia 5;
|
||||
<pre class="literal-block">arcadia 4;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
@@ -1044,8 +1041,7 @@ project ports_example "Typed function contract" {
|
||||
sa_port preserved "Unresolved original" {
|
||||
parent @send; contract Legacy;
|
||||
legacy { direction InOut; interface @old_service; };
|
||||
}
|
||||
}</pre>
|
||||
}</pre>
|
||||
<p>An exclusive region has at most one initial state. Missing initial state is
|
||||
a completeness warning. Initial states have no incoming transitions and at
|
||||
most one outgoing transition; their outgoing transition has no trigger/guard.
|
||||
@@ -1397,7 +1393,7 @@ invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.</p>
|
||||
<p>This complete data contract example is also exercised through the REST API:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
@@ -1460,7 +1456,7 @@ remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
|
||||
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
|
||||
<p>This complete source example places one state from an existing machine:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -1627,11 +1623,9 @@ are invalid.</p>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>A nonempty baseline retains the required prose records <span class="docutils literal">oa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; sa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> };</span> and optionally
|
||||
<span class="docutils literal">semantic</span> (default none), defined below. The original prose records and typed
|
||||
semantic facts are distinct review histories. Importing an older prose baseline
|
||||
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
|
||||
snapshot, structural synchronization, or a lossless inverse transition.
|
||||
<p>A nonempty baseline is <span class="docutils literal">{ oa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; sa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; }</span>. It records last-reviewed names
|
||||
and descriptions, not a full structural snapshot. Implementations MUST NOT
|
||||
present it as structural synchronization or a lossless inverse transition.
|
||||
Explicit counterpart creation, linking, comparison, and reviewed property
|
||||
update may start in either layer. Ordinary edits MUST NOT automatically
|
||||
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.</p>
|
||||
@@ -1643,358 +1637,15 @@ instead of silently creating a different mapping. Required/subtree process and
|
||||
chain transitions include their contextual links, owned contributions and
|
||||
referenced dependencies. New process/chain contribution mappings use Sequence
|
||||
traces; older Scenario-kind occurrence mappings remain accepted. The current
|
||||
prose baseline alone does not reconcile later topology changes.</p>
|
||||
<p>In the prose-only workflow, name and description are compared independently
|
||||
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
name/description baseline does not reconcile later topology changes.</p>
|
||||
<p>Name and description are compared independently against each endpoint's
|
||||
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
different target value. A source-only change proposes an update; different
|
||||
changes on both sides require an explicit decision to take the source, keep
|
||||
the target, or enter a merged value. An explicit decision records both current
|
||||
values as reviewed, even when they differ. A target-only edit is preserved
|
||||
without advancing that field's baseline, so it remains eligible to propagate
|
||||
in the reverse direction. Equal current values can be recorded as converged.</p>
|
||||
<section id="typed-semantic-review-facts">
|
||||
<h3>5.1. Typed semantic review facts</h3>
|
||||
<p><span class="docutils literal">baseline.semantic</span> is either none or <span class="docutils literal">{ facets [facet, <span class="pre">...];</span> }</span>.
|
||||
Each facet is a closed, self-tagged typed record, not a free-form path/value
|
||||
entry. List position does not identify a facet. A scalar fact is exactly
|
||||
<span class="docutils literal">{ Family { Leaf { oa observation; sa observation; }; }; }</span>. Each observation
|
||||
is either <span class="docutils literal">NotApplicable</span> or <span class="docutils literal">{ Value value; }</span>, with the value type fixed
|
||||
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.</p>
|
||||
<p><span class="docutils literal">{ Value none; }</span> means an applicable optional field was absent;
|
||||
<span class="docutils literal">NotApplicable</span> means the field's branch or record was not applicable.
|
||||
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
|
||||
side is compared with its own last-reviewed value; deliberately unequal values
|
||||
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
|
||||
maximum, unit, default, header or referenced type definition.</p>
|
||||
<p>Historical facts are independently acknowledged observations. They need not
|
||||
compose one coherent former model: two bounds can have different review times.
|
||||
They MAY outlive the endpoint's former record family, constructor and referenced
|
||||
objects. Historical admission checks their closed payload types, finite values,
|
||||
text, keys and uniqueness; it MUST NOT require a currently existing referenced
|
||||
object or current family applicability. In contrast, a new graphical or planned
|
||||
resolution MUST address the appropriate current record family and have at least
|
||||
one currently applicable side. Both-side NotApplicable facts are invalid persisted
|
||||
acknowledgements. A grouped constructor decision retires such obsolete branch facts.</p>
|
||||
<p>The following table is the exhaustive scalar catalog. <span class="docutils literal">T?</span> denotes an optional
|
||||
T, <span class="docutils literal">T[]</span> a list, and <span class="docutils literal">Id</span> a historical UUID. Existing enum and typed payload
|
||||
definitions in sections 3–4 apply. Every listed leaf wraps <span class="docutils literal">{ oa <span class="pre">...;</span> sa <span class="pre">...;</span> }</span>.</p>
|
||||
<table>
|
||||
<caption>Closed scalar semantic facets</caption>
|
||||
<colgroup>
|
||||
<col style="width: 15.0%" />
|
||||
<col style="width: 85.0%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Family</p></th>
|
||||
<th class="head"><p>Leaves and observation value types</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Header</p></td>
|
||||
<td><p>Name, Description: string; Parent: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>ExchangeItem</p></td>
|
||||
<td><p>Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Interface</p></td>
|
||||
<td><p>Supertype: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Data</p></td>
|
||||
<td><p>Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Field</p></td>
|
||||
<td><p>Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Port</p></td>
|
||||
<td><p>Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Scenario</p></td>
|
||||
<td><p>Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Lifeline</p></td>
|
||||
<td><p>Represented: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Occurrence</p></td>
|
||||
<td><p>Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Event</p></td>
|
||||
<td><p>Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>State</p></td>
|
||||
<td><p>Kind: StateKind; Entry, Exit, DoActivity: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Constraint</p></td>
|
||||
<td><p>Expression: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Relation</p></td>
|
||||
<td><p>Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section id="members-rows-order-and-attached-records">
|
||||
<h3>5.2. Members, rows, order, and attached records</h3>
|
||||
<p>The remaining facet forms are closed records. <span class="docutils literal">keys</span> means <span class="docutils literal">{ oa <span class="pre">key-or-none;</span> sa <span class="pre">key-or-none;</span> }</span>; at least one key MUST be present. <span class="docutils literal">review</span> means the same
|
||||
required OA/SA observation pair used above.</p>
|
||||
<table>
|
||||
<caption>Closed keyed and ordered semantic facets</caption>
|
||||
<colgroup>
|
||||
<col style="width: 17.0%" />
|
||||
<col style="width: 45.0%" />
|
||||
<col style="width: 38.0%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Outer tag</p></th>
|
||||
<th class="head"><p>Required fields</p></th>
|
||||
<th class="head"><p>Key / reviewed value type</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Member</p></td>
|
||||
<td><p>collection; keys; review</p></td>
|
||||
<td><p>Id / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Order</p></td>
|
||||
<td><p>collection; review</p></td>
|
||||
<td><p>No keys / Id[]</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Literal</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Exact case-sensitive string / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Annotation</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p><span class="docutils literal">{ path <span class="pre">Id[];</span> field Id; }</span> / MessageValue?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Binding</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Referenced lifeline Id / LifelineBinding?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Child</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Direct child Id / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Incident</p></td>
|
||||
<td><p>keys; end; review</p></td>
|
||||
<td><p>Relation Id, end Source or Target / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Artifact</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Artifact Id / nested artifact facet below</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
|
||||
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
|
||||
TransitionEffects or Constrained. Order.collection is exactly Steps or
|
||||
FragmentBranches. Order is independent of membership and MUST contain each
|
||||
surviving member exactly once when applied. Membership Keep does not refresh
|
||||
order history. An automatic insertion requires a unique unchanged mapped anchor;
|
||||
ambiguous insertion or conflicting inferred orders requires an explicit final
|
||||
target order. Independent removals can filter the order to surviving members.</p>
|
||||
<p>Annotation identity is the complete <span class="docutils literal">(path, field)</span> pair. Its MessageValue
|
||||
payload contains <span class="docutils literal">path</span>, <span class="docutils literal">field</span> and <span class="docutils literal">expression</span>; the payload key MUST
|
||||
match its side's selector key. Binding payloads contain <span class="docutils literal">referenced</span> and
|
||||
<span class="docutils literal">local</span>; <span class="docutils literal">referenced</span> MUST match that side's key. Each row is one atomic
|
||||
fact. Changing its key is an explicit old-row removal/new-row addition, not an
|
||||
expression-only edit. Defaults containing repeated DataValues are atomic optional
|
||||
vectors because those values have no stable member identity.</p>
|
||||
<p>Artifact.review is <span class="docutils literal">{ Leaf { oa observation; sa observation; }; }</span> where Leaf
|
||||
is Presence: bool; Name, Description or Uri: string; Owner: Id;
|
||||
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
|
||||
are authorized on the artifact's attachment in the initial project. An explicit
|
||||
Owner candidate may move a target-only link and record its resulting Owner and
|
||||
other selected metadata. Presence remains relative to the trace owner. Subsequent
|
||||
metadata decisions require its new owner trace; historical facts remain attached
|
||||
to the trace that recorded them. No review fetches artifact content.</p>
|
||||
<p>Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
|
||||
the same SA slot are invalid, even when the other side's key differs. Historical
|
||||
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
|
||||
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
|
||||
steps per baseline. Exceeding these implementation work limits produces a
|
||||
diagnostic; truncation MUST NOT be accepted as a complete review.</p>
|
||||
</section>
|
||||
<section id="constructor-groups-and-decisions">
|
||||
<h3>5.3. Constructor groups and decisions</h3>
|
||||
<p>Persisted constructors are discriminator words only:</p>
|
||||
<ul class="simple">
|
||||
<li><p>DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.</p></li>
|
||||
<li><p>PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.</p></li>
|
||||
<li><p>OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
|
||||
ScenarioUse, State, Fragment, Branch.</p></li>
|
||||
<li><p>EventConstructor: Signal, Time, Change.</p></li>
|
||||
<li><p>MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.</p></li>
|
||||
</ul>
|
||||
<p>When a discriminator has no prior fact after explicit resets, both current sides
|
||||
are applicable and equal, and the proposed target discriminator is unchanged,
|
||||
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
|
||||
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
|
||||
retire branch history. Descendant facets may be independently selected in the
|
||||
same review; their decisions retain their own scopes and final-value audits.
|
||||
Candidate records still MUST NOT change an unselected facet. Missing applicability
|
||||
on either side does not qualify as an equal discriminator acknowledgement.</p>
|
||||
<p>A grouped constructor resolution uses a transient typed target record, then
|
||||
records the discriminator and each acknowledged descendant as separate facts.
|
||||
It MUST NOT persist a second whole constructor payload overlapping those leaves.
|
||||
Data includes its branch fields, literals and coupled default, but excludes Unit.
|
||||
Port includes its contract, Legacy fields and item members. Event includes its
|
||||
branch fields and Signal items. Occurrence includes its variant payload, members,
|
||||
order, annotations and bindings, but excludes outer Precondition/Postcondition.
|
||||
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
|
||||
is its own structural facet even though it changes the element's derived kind.
|
||||
Other dependent changes caused by preparation remain explicit impacts.</p>
|
||||
<p>A grouped constructor Keep requires an actual discriminator proposal or a
|
||||
discriminator change since its prior review. Repeating Keep on an already reviewed
|
||||
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
|
||||
reconsider a previously kept difference. When the target already has the proposed
|
||||
constructor, reconciliation MUST preserve target-only members rather than replace
|
||||
its whole list. Removed branch facts participate in agreement as NotApplicable;
|
||||
facts with neither side remaining applicable are retired.</p>
|
||||
<p>Semantic review supports these explicit decisions in either direction:</p>
|
||||
<ul class="simple">
|
||||
<li><p>KeepTarget records the current source and retained target values for only the
|
||||
selected facet, or the explicitly displayed constructor group.</p></li>
|
||||
<li><p>TakeSource maps the selected source value through the frozen correspondence and
|
||||
applies only that facet's delta. Unrelated target edits and target-only collection
|
||||
members, rows, children and artifacts remain intact. Removing membership unlinks
|
||||
the target member; it does not authorize deleting the target semantic object.</p></li>
|
||||
<li><p>Candidate supplies final existing typed Element, Relation or Artifact records.
|
||||
Only selected facets may differ from the result. Identity, alias and layer are
|
||||
protected. New artifact creation requires a complete candidate and review of
|
||||
every introduced metadata facet; removal also reviews the metadata retirement.</p></li>
|
||||
</ul>
|
||||
<p>Headers have explicit semantic Name/Description facets; the original prose-only
|
||||
workflow remains supported with its own baseline. Ordinary source editing can
|
||||
explicitly edit a valid baseline; neither baseline format attests that a human
|
||||
performed a particular review workflow.
|
||||
Decision requests, final candidates and prepared impacts are transient editor/API
|
||||
data. Source persists the resulting ordinary model and typed baseline facts;
|
||||
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.</p>
|
||||
</section>
|
||||
<section id="correspondence-agreement-and-atomic-preparation">
|
||||
<h3>5.4. Correspondence, agreement, and atomic preparation</h3>
|
||||
<p>Correspondence is an explicit canonical OA-to-SA Id map, including historical
|
||||
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
|
||||
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
|
||||
pair require an explicit trace selection. Changing a keyed pairing requires an
|
||||
explicit reset naming the prior selector; old pairs are never silently replaced.
|
||||
If a previously paired member or branch disappears on one side, retain its pair
|
||||
with the absent/NotApplicable observation, suppressing a redundant one-sided
|
||||
projection. A newly appearing pair still requires reset of old one-sided history.</p>
|
||||
<p>All selected source proposals use one immutable initial project. Resets precede
|
||||
catalog and constructor-scope derivation. Many sources may affect one target only
|
||||
when their desired values agree on every actual canonical target slot, including
|
||||
removals and members introduced by another proposal. Child membership and the
|
||||
actual child's Parent share one slot; incidents share the relation's Source or
|
||||
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
|
||||
Conflicting desired values MUST reject independently of request order.</p>
|
||||
<p>Materialize the agreed writes once, then audit each desired selected value.
|
||||
Prepare one ordinary complete Command batch and compare its actual project with
|
||||
the explicitly reviewed candidate. Additional cleanup, such as inherited message
|
||||
annotation pruning, is reported as exact object <span class="docutils literal">before</span>/<span class="docutils literal">after</span> typed records
|
||||
(or absence for creation/deletion). Every induced impact requires explicit review;
|
||||
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
|
||||
selected-value audit after preparation. Baseline updates belong to the same atomic
|
||||
transaction and source-preserving writeback as model edits.</p>
|
||||
<p>A plan's base fingerprint detects a stale model; its prepared fingerprint checks
|
||||
candidate consistency. The plan is client-supplied data, not an authenticated
|
||||
receipt or proof of its original decisions. Applying it validates the complete
|
||||
ordinary candidate and preserves generated identities; it does not re-run its
|
||||
request to generate new identities. Hosted clients additionally use the revision
|
||||
preconditions defined in <span class="docutils literal">api.rst</span>. Invalid/stale plans or failed source writeback
|
||||
leave the accepted model, source, baseline, revision and history unchanged.</p>
|
||||
<p>Own drift and linked impacts are separate, with incomplete review explicitly
|
||||
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
|
||||
Shared data/interface/event definitions are reviewed on their own traces; their
|
||||
bounded derived impact walk follows reusable definitions and interface allocations,
|
||||
not arbitrary architectural neighborhoods, and does not copy them into every
|
||||
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
|
||||
is a diagnostic. Successful review remains one undoable transaction. Replanning
|
||||
unchanged, already acknowledged leaf facts is a no-op only while their requests
|
||||
remain applicable. A completed constructor switch or first acknowledgement of an
|
||||
equal discriminator MUST be removed from subsequent requests; repeating it when
|
||||
no switch remains is rejected. After an artifact moves to another owner,
|
||||
metadata review through the old owner's trace is no longer
|
||||
applicable and is rejected. An old plan becomes stale after an actual committed
|
||||
change.</p>
|
||||
</section>
|
||||
<section id="source-examples-and-historical-token-rules">
|
||||
<h3>5.5. Source examples and historical token rules</h3>
|
||||
<p>This valid source records deliberately different constructor/unit observations.
|
||||
Its historical integer minimum is absent; the Real side has no integer branch:</p>
|
||||
<pre class="literal-block">arcadia 5;
|
||||
project reviewed_dimensions "Reviewed dimensions" {
|
||||
oa_data_type oa_dimension "Operational distance" {
|
||||
definition { Integer { minimum 0; maximum 10; }; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "System distance" {
|
||||
definition { Real { minimum 0; maximum 20; }; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension review" {
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description ""; };
|
||||
sa { name "Approved SA name"; description ""; };
|
||||
semantic { facets [
|
||||
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
|
||||
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
|
||||
{ Data { Unit { oa { Value "m"; }; sa { Value "ft"; }; }; }; }
|
||||
]; };
|
||||
};
|
||||
}
|
||||
}</pre>
|
||||
<p>Historical Id slots require <span class="docutils literal">uuid <span class="pre">"..."</span></span> even if the object still exists.
|
||||
Only typed reference slots inside <span class="docutils literal">baseline.semantic</span> accept that atom.
|
||||
For example, the following is a valid historical facet value, including a deleted
|
||||
named type; <span class="docutils literal">none</span> and bare enum Boolean retain their different typed meanings:</p>
|
||||
<pre class="literal-block">{ Field { Type {
|
||||
oa { Value { Named uuid "40000000-0000-4000-8000-000000000090"; }; };
|
||||
sa { Value Boolean; };
|
||||
}; }; }</pre>
|
||||
<p>A qualified annotation uses the same historical UUID atoms in its key and payload.
|
||||
This complete facet records one old row and an applicable absent target row:</p>
|
||||
<pre class="literal-block">{ Annotation {
|
||||
keys {
|
||||
oa {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
};
|
||||
sa none;
|
||||
};
|
||||
review {
|
||||
oa { Value {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
expression "latitude + 1";
|
||||
}; };
|
||||
sa { Value none; };
|
||||
};
|
||||
}; }</pre>
|
||||
<p>Historical enums require bare words. Ordinary strings, including UUID-looking
|
||||
units, literal keys and expressions, require quoted strings and remain text
|
||||
during identity remapping. <span class="docutils literal">@alias</span> is invalid anywhere inside a semantic
|
||||
baseline. Live parents, trace endpoints and all other live references still
|
||||
require <span class="docutils literal">@alias</span> and MUST NOT accept the historical UUID atom.</p>
|
||||
<p>The following substitutions in these examples are invalid:</p>
|
||||
<ul class="simple">
|
||||
<li><p><span class="docutils literal">oa "NotApplicable";</span> or <span class="docutils literal">oa { Value "Integer"; };</span> in the Constructor
|
||||
review: enum observations require bare words of the correct typed enum.</p></li>
|
||||
<li><p><span class="docutils literal">Named <span class="pre">"40000000-0000-4000-8000-000000000090";</span></span> or <span class="docutils literal">Named @oa_dimension;</span>:
|
||||
a historical Id requires the explicit UUID atom.</p></li>
|
||||
<li><p><span class="docutils literal">Unit { oa { Value uuid <span class="pre">"40000000-0000-4000-8000-000000000090";</span> }; ... }</span>:
|
||||
a text field cannot hold a reference atom.</p></li>
|
||||
<li><p>Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
|
||||
as a quoted string: closed typed admission rejects the whole candidate.</p></li>
|
||||
<li><p>Changing only the annotation payload's field UUID suffix from 092 to 093,
|
||||
leaving the key at 092: the row's key and payload disagree.</p></li>
|
||||
<li><p>Changing the document header to <span class="docutils literal">arcadia 4;</span> while retaining <span class="docutils literal">semantic</span>:
|
||||
the frozen earlier schema does not admit a future baseline shape.</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
<section id="text-grammar-and-resolution">
|
||||
<h2>6. Text grammar and resolution</h2>
|
||||
@@ -2014,7 +1665,7 @@ spellings can be used in alias positions because references begin with <span cla
|
||||
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
|
||||
keywords listed above. Typed field
|
||||
sets and enum constructors are closed by sections 3–5.</p>
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "5" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "4" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -2026,8 +1677,7 @@ element-body = "{" (field-name value ";" | element)* "}
|
||||
identity = "id" uuid-string
|
||||
fields = "{" (field-name value ";")* "}"
|
||||
value = string | number | "true" | "false" | "none"
|
||||
| enum-word | "@" alias | historical-id | list | fields
|
||||
historical-id = "uuid" uuid-string
|
||||
| enum-word | "@" alias | list | fields
|
||||
list = "[" (value ("," value)*)? "]"</code></pre>
|
||||
<p>The project description defaults to empty and may appear once. There is no
|
||||
semicolon after a declaration or project closing brace. Every field inside a
|
||||
@@ -2045,8 +1695,6 @@ bare words cannot substitute for reference-valued fields. Alias resolution
|
||||
produces UUID references; the alias itself remains stored on its declaration.
|
||||
Changing an alias in source requires updating its uses. Display names never
|
||||
resolve references.</p>
|
||||
<p><span class="docutils literal"><span class="pre">historical-id</span></span> is context-restricted to the typed semantic baseline reference
|
||||
slots in section 5.5; it is not an alternative spelling of a live reference.</p>
|
||||
<p>Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
|
||||
enum spellings, duplicate aliases/identities, and structural validation errors
|
||||
reject the entire candidate. No declaration silently replaces another.
|
||||
@@ -2058,12 +1706,12 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
|
||||
<h3>6.1. Modular source projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source5 project header above. A project entry
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source4 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example:</p>
|
||||
<pre class="literal-block">arcadia 5;
|
||||
<pre class="literal-block">arcadia 4;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -2121,17 +1769,12 @@ the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3, source4 and
|
||||
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
An authored source4 bundle upgrades through the strict prose-only baseline
|
||||
adapter; after any missing UUID stabilization, the version token alone changes
|
||||
to 5. Existing prose, comments, includes and nested ownership remain intact.
|
||||
Source5 semantic facet comments follow their closed selector identity through
|
||||
row reordering and reidentification; UUID-looking text does not act as an Id.</p>
|
||||
allocation identities are deterministic on subsequent compilation.</p>
|
||||
</section>
|
||||
<section id="nested-element-ownership">
|
||||
<h3>6.2. Nested element ownership</h3>
|
||||
@@ -2335,17 +1978,9 @@ explicitly before Apply succeeds.</p>
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
|
||||
the accepted V5 source bundle (<span class="docutils literal">arcadia 5</span>); other suffixes use V5 JSON. Both forms preserve the same
|
||||
the accepted V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.</p>
|
||||
<p>The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
|
||||
envelope with closed fields. It reuses unchanged typed elements, relations,
|
||||
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
|
||||
their names/descriptions remain exact even when live endpoint values differ.
|
||||
The adapter MUST NOT infer semantic review from live values, and version3/4
|
||||
inputs MUST reject semantic baseline fields. The migration report retains the
|
||||
original from_schema and current to_schema 5. Reopening a current5 save produces
|
||||
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.</p>
|
||||
<p>The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
@@ -2365,9 +2000,9 @@ reuse, retain the old annotation with a located warning rather than guessing.</p
|
||||
description), <span class="docutils literal">generated_interface_allocations</span> (id, source, target, alias,
|
||||
order) and <span class="docutils literal">unresolved_message_values</span> (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
|
||||
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 5; reading/migrating alone never saves or changes revisions.</p>
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.</p>
|
||||
<p>Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -2391,7 +2026,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.</p>
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
@@ -2408,13 +2043,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 5. Model/source exports contain only the canonical model. Migration
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V5 graph are
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4-to-5 stages.</p>
|
||||
model and then uses these same V2-to-3-to-4 stages.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
@@ -2430,20 +2065,20 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V5 result, including repeated occurrences.
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.</p>
|
||||
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V5 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
V4 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
</section>
|
||||
<section id="complete-authoring-example">
|
||||
<h2>10. Complete authoring example</h2>
|
||||
<p>This document intentionally omits IDs for new authoring. Apply and Save write
|
||||
the assigned UUIDs explicitly.</p>
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
@@ -2480,13 +2115,6 @@ stable aliases, strict syntax/reference failures, Unicode source spans,
|
||||
deterministic V1 migration, occurrence identity, coordinate bits, persistence,
|
||||
and model-history integration. Core validation and session tests cover domain
|
||||
mutation rules; native UI and API verification are separate evidence.</p>
|
||||
<p><span class="docutils literal">cargo test <span class="pre">-p</span> <span class="pre">oa-core</span> <span class="pre">--test</span> r3_semantic <span class="pre">--test</span> r3_source <span class="pre">--test</span> r3_migration <span class="pre">--locked</span></span>
|
||||
checks the closed facet catalog, partial decisions, constructor scopes, canonical
|
||||
agreement, target-only preservation, historic references, exact prepared impacts,
|
||||
source/JSON parity, comment ownership and explicit original1–4 migration. The
|
||||
source suite includes actual included-file save/reopen and prepared review
|
||||
writeback. These checks establish core/source behavior; they do not establish
|
||||
graphical or hosted API acceptance by themselves.</p>
|
||||
<p>The research record in <span class="docutils literal"><span class="pre">docs/research-v2-semantics.md</span></span> distinguishes method
|
||||
requirements from Studio design choices and links to original UI evidence.
|
||||
The following sources inform this language without extending its scope:</p>
|
||||
|
||||
+38
-403
@@ -1,7 +1,7 @@
|
||||
Arcadia Studio OA / SA Language and Editing Contract
|
||||
====================================================
|
||||
|
||||
:Version: 5
|
||||
:Version: 4
|
||||
:Status: Normative current implementation contract; first-release integration
|
||||
:Core implementation: ``crates/oa-core``
|
||||
:Text suffix: ``.arcadia``
|
||||
@@ -11,15 +11,13 @@ Arcadia Studio OA / SA Language and Editing Contract
|
||||
|
||||
This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.
|
||||
|
||||
Schema 5 adds closed typed semantic review facts to trace baselines, including
|
||||
historical references that may no longer exist in the live model. It retains
|
||||
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
|
||||
qualified values, specialization, interface allocations and associations, and
|
||||
schema 3 typed ports and state views. Explicit older readers preserve meanings
|
||||
and identities without inventing previously reviewed semantic values.
|
||||
Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.
|
||||
|
||||
OA describes stakeholders, operational work and needs. SA describes the
|
||||
@@ -41,7 +39,7 @@ Actual verification evidence is separate from this contract.
|
||||
2. One authoritative model
|
||||
--------------------------
|
||||
|
||||
A Project stores ``schema_version`` (5), ``id``, ``alias``, ``name``,
|
||||
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
|
||||
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
||||
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.
|
||||
@@ -313,10 +311,9 @@ declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
|
||||
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||
exchange MUST reference output/input ports owned by its respective functions,
|
||||
and each carried item MUST fit both declarations. Empty declarations accept
|
||||
no items. A carried item satisfies an identical declaration or a declaration
|
||||
of one of its explicit base items. A base item does not satisfy a more
|
||||
specialized declaration; names imply no compatibility. Port declarations
|
||||
remain independent of incident exchanges after creation.
|
||||
no items. Item compatibility is identity equality; future specialization is
|
||||
not inferred from names. Port declarations remain independent of incident
|
||||
exchanges after creation.
|
||||
|
||||
A ``Component`` contract belongs to an SA System or SystemActor and stores
|
||||
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
|
||||
@@ -349,14 +346,14 @@ without treating it as another active constraint. OA generic ports remain
|
||||
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||
Deleting a referenced legacy interface clears that optional live reference.
|
||||
|
||||
A current-source functional exchange MUST specify both port aliases; the parser does
|
||||
A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.
|
||||
|
||||
For example::
|
||||
|
||||
arcadia 5;
|
||||
arcadia 4;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
@@ -379,7 +376,6 @@ For example::
|
||||
parent @send; contract Legacy;
|
||||
legacy { direction InOut; interface @old_service; };
|
||||
}
|
||||
}
|
||||
|
||||
An exclusive region has at most one initial state. Missing initial state is
|
||||
a completeness warning. Initial states have no incoming transitions and at
|
||||
@@ -739,7 +735,7 @@ This complete data contract example is also exercised through the REST API:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 5;
|
||||
arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
@@ -808,7 +804,7 @@ This complete source example places one state from an existing machine:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 5;
|
||||
arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -978,12 +974,10 @@ are invalid.
|
||||
* - Manual
|
||||
- Engineer-defined relationship between OA/SA elements or relations
|
||||
|
||||
A nonempty baseline retains the required prose records ``oa { name "...";
|
||||
description "..."; }; sa { name "..."; description "..."; };`` and optionally
|
||||
``semantic`` (default none), defined below. The original prose records and typed
|
||||
semantic facts are distinct review histories. Importing an older prose baseline
|
||||
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
|
||||
snapshot, structural synchronization, or a lossless inverse transition.
|
||||
A nonempty baseline is ``{ oa { name "..."; description "..."; };
|
||||
sa { name "..."; description "..."; }; }``. It records last-reviewed names
|
||||
and descriptions, not a full structural snapshot. Implementations MUST NOT
|
||||
present it as structural synchronization or a lossless inverse transition.
|
||||
Explicit counterpart creation, linking, comparison, and reviewed property
|
||||
update may start in either layer. Ordinary edits MUST NOT automatically
|
||||
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.
|
||||
@@ -996,10 +990,10 @@ instead of silently creating a different mapping. Required/subtree process and
|
||||
chain transitions include their contextual links, owned contributions and
|
||||
referenced dependencies. New process/chain contribution mappings use Sequence
|
||||
traces; older Scenario-kind occurrence mappings remain accepted. The current
|
||||
prose baseline alone does not reconcile later topology changes.
|
||||
name/description baseline does not reconcile later topology changes.
|
||||
|
||||
In the prose-only workflow, name and description are compared independently
|
||||
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
Name and description are compared independently against each endpoint's
|
||||
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
different target value. A source-only change proposes an update; different
|
||||
changes on both sides require an explicit decision to take the source, keep
|
||||
the target, or enter a merged value. An explicit decision records both current
|
||||
@@ -1007,339 +1001,6 @@ values as reviewed, even when they differ. A target-only edit is preserved
|
||||
without advancing that field's baseline, so it remains eligible to propagate
|
||||
in the reverse direction. Equal current values can be recorded as converged.
|
||||
|
||||
5.1. Typed semantic review facts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``baseline.semantic`` is either none or ``{ facets [facet, ...]; }``.
|
||||
Each facet is a closed, self-tagged typed record, not a free-form path/value
|
||||
entry. List position does not identify a facet. A scalar fact is exactly
|
||||
``{ Family { Leaf { oa observation; sa observation; }; }; }``. Each observation
|
||||
is either ``NotApplicable`` or ``{ Value value; }``, with the value type fixed
|
||||
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.
|
||||
|
||||
``{ Value none; }`` means an applicable optional field was absent;
|
||||
``NotApplicable`` means the field's branch or record was not applicable.
|
||||
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
|
||||
side is compared with its own last-reviewed value; deliberately unequal values
|
||||
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
|
||||
maximum, unit, default, header or referenced type definition.
|
||||
|
||||
Historical facts are independently acknowledged observations. They need not
|
||||
compose one coherent former model: two bounds can have different review times.
|
||||
They MAY outlive the endpoint's former record family, constructor and referenced
|
||||
objects. Historical admission checks their closed payload types, finite values,
|
||||
text, keys and uniqueness; it MUST NOT require a currently existing referenced
|
||||
object or current family applicability. In contrast, a new graphical or planned
|
||||
resolution MUST address the appropriate current record family and have at least
|
||||
one currently applicable side. Both-side NotApplicable facts are invalid persisted
|
||||
acknowledgements. A grouped constructor decision retires such obsolete branch facts.
|
||||
|
||||
The following table is the exhaustive scalar catalog. ``T?`` denotes an optional
|
||||
T, ``T[]`` a list, and ``Id`` a historical UUID. Existing enum and typed payload
|
||||
definitions in sections 3–4 apply. Every listed leaf wraps ``{ oa ...; sa ...; }``.
|
||||
|
||||
.. list-table:: Closed scalar semantic facets
|
||||
:header-rows: 1
|
||||
:widths: 15 85
|
||||
|
||||
* - Family
|
||||
- Leaves and observation value types
|
||||
* - Header
|
||||
- Name, Description: string; Parent: Id?
|
||||
* - ExchangeItem
|
||||
- Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?
|
||||
* - Interface
|
||||
- Supertype: Id?
|
||||
* - Data
|
||||
- Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string
|
||||
* - Field
|
||||
- Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?
|
||||
* - Port
|
||||
- Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?
|
||||
* - Scenario
|
||||
- Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string
|
||||
* - Lifeline
|
||||
- Represented: Id?
|
||||
* - Occurrence
|
||||
- Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string
|
||||
* - Event
|
||||
- Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string
|
||||
* - State
|
||||
- Kind: StateKind; Entry, Exit, DoActivity: string
|
||||
* - Constraint
|
||||
- Expression: string
|
||||
* - Relation
|
||||
- Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind
|
||||
|
||||
5.2. Members, rows, order, and attached records
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The remaining facet forms are closed records. ``keys`` means ``{ oa key-or-none;
|
||||
sa key-or-none; }``; at least one key MUST be present. ``review`` means the same
|
||||
required OA/SA observation pair used above.
|
||||
|
||||
.. list-table:: Closed keyed and ordered semantic facets
|
||||
:header-rows: 1
|
||||
:widths: 17 45 38
|
||||
|
||||
* - Outer tag
|
||||
- Required fields
|
||||
- Key / reviewed value type
|
||||
* - Member
|
||||
- collection; keys; review
|
||||
- Id / bool
|
||||
* - Order
|
||||
- collection; review
|
||||
- No keys / Id[]
|
||||
* - Literal
|
||||
- keys; review
|
||||
- Exact case-sensitive string / bool
|
||||
* - Annotation
|
||||
- keys; review
|
||||
- ``{ path Id[]; field Id; }`` / MessageValue?
|
||||
* - Binding
|
||||
- keys; review
|
||||
- Referenced lifeline Id / LifelineBinding?
|
||||
* - Child
|
||||
- keys; review
|
||||
- Direct child Id / bool
|
||||
* - Incident
|
||||
- keys; end; review
|
||||
- Relation Id, end Source or Target / bool
|
||||
* - Artifact
|
||||
- keys; review
|
||||
- Artifact Id / nested artifact facet below
|
||||
|
||||
Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
|
||||
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
|
||||
TransitionEffects or Constrained. Order.collection is exactly Steps or
|
||||
FragmentBranches. Order is independent of membership and MUST contain each
|
||||
surviving member exactly once when applied. Membership Keep does not refresh
|
||||
order history. An automatic insertion requires a unique unchanged mapped anchor;
|
||||
ambiguous insertion or conflicting inferred orders requires an explicit final
|
||||
target order. Independent removals can filter the order to surviving members.
|
||||
|
||||
Annotation identity is the complete ``(path, field)`` pair. Its MessageValue
|
||||
payload contains ``path``, ``field`` and ``expression``; the payload key MUST
|
||||
match its side's selector key. Binding payloads contain ``referenced`` and
|
||||
``local``; ``referenced`` MUST match that side's key. Each row is one atomic
|
||||
fact. Changing its key is an explicit old-row removal/new-row addition, not an
|
||||
expression-only edit. Defaults containing repeated DataValues are atomic optional
|
||||
vectors because those values have no stable member identity.
|
||||
|
||||
Artifact.review is ``{ Leaf { oa observation; sa observation; }; }`` where Leaf
|
||||
is Presence: bool; Name, Description or Uri: string; Owner: Id;
|
||||
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
|
||||
are authorized on the artifact's attachment in the initial project. An explicit
|
||||
Owner candidate may move a target-only link and record its resulting Owner and
|
||||
other selected metadata. Presence remains relative to the trace owner. Subsequent
|
||||
metadata decisions require its new owner trace; historical facts remain attached
|
||||
to the trace that recorded them. No review fetches artifact content.
|
||||
|
||||
Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
|
||||
the same SA slot are invalid, even when the other side's key differs. Historical
|
||||
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
|
||||
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
|
||||
steps per baseline. Exceeding these implementation work limits produces a
|
||||
diagnostic; truncation MUST NOT be accepted as a complete review.
|
||||
|
||||
5.3. Constructor groups and decisions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Persisted constructors are discriminator words only:
|
||||
|
||||
* DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.
|
||||
* PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.
|
||||
* OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
|
||||
ScenarioUse, State, Fragment, Branch.
|
||||
* EventConstructor: Signal, Time, Change.
|
||||
* MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.
|
||||
|
||||
When a discriminator has no prior fact after explicit resets, both current sides
|
||||
are applicable and equal, and the proposed target discriminator is unchanged,
|
||||
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
|
||||
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
|
||||
retire branch history. Descendant facets may be independently selected in the
|
||||
same review; their decisions retain their own scopes and final-value audits.
|
||||
Candidate records still MUST NOT change an unselected facet. Missing applicability
|
||||
on either side does not qualify as an equal discriminator acknowledgement.
|
||||
|
||||
A grouped constructor resolution uses a transient typed target record, then
|
||||
records the discriminator and each acknowledged descendant as separate facts.
|
||||
It MUST NOT persist a second whole constructor payload overlapping those leaves.
|
||||
Data includes its branch fields, literals and coupled default, but excludes Unit.
|
||||
Port includes its contract, Legacy fields and item members. Event includes its
|
||||
branch fields and Signal items. Occurrence includes its variant payload, members,
|
||||
order, annotations and bindings, but excludes outer Precondition/Postcondition.
|
||||
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
|
||||
is its own structural facet even though it changes the element's derived kind.
|
||||
Other dependent changes caused by preparation remain explicit impacts.
|
||||
|
||||
A grouped constructor Keep requires an actual discriminator proposal or a
|
||||
discriminator change since its prior review. Repeating Keep on an already reviewed
|
||||
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
|
||||
reconsider a previously kept difference. When the target already has the proposed
|
||||
constructor, reconciliation MUST preserve target-only members rather than replace
|
||||
its whole list. Removed branch facts participate in agreement as NotApplicable;
|
||||
facts with neither side remaining applicable are retired.
|
||||
|
||||
Semantic review supports these explicit decisions in either direction:
|
||||
|
||||
* KeepTarget records the current source and retained target values for only the
|
||||
selected facet, or the explicitly displayed constructor group.
|
||||
* TakeSource maps the selected source value through the frozen correspondence and
|
||||
applies only that facet's delta. Unrelated target edits and target-only collection
|
||||
members, rows, children and artifacts remain intact. Removing membership unlinks
|
||||
the target member; it does not authorize deleting the target semantic object.
|
||||
* Candidate supplies final existing typed Element, Relation or Artifact records.
|
||||
Only selected facets may differ from the result. Identity, alias and layer are
|
||||
protected. New artifact creation requires a complete candidate and review of
|
||||
every introduced metadata facet; removal also reviews the metadata retirement.
|
||||
|
||||
Headers have explicit semantic Name/Description facets; the original prose-only
|
||||
workflow remains supported with its own baseline. Ordinary source editing can
|
||||
explicitly edit a valid baseline; neither baseline format attests that a human
|
||||
performed a particular review workflow.
|
||||
Decision requests, final candidates and prepared impacts are transient editor/API
|
||||
data. Source persists the resulting ordinary model and typed baseline facts;
|
||||
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.
|
||||
|
||||
5.4. Correspondence, agreement, and atomic preparation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Correspondence is an explicit canonical OA-to-SA Id map, including historical
|
||||
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
|
||||
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
|
||||
pair require an explicit trace selection. Changing a keyed pairing requires an
|
||||
explicit reset naming the prior selector; old pairs are never silently replaced.
|
||||
If a previously paired member or branch disappears on one side, retain its pair
|
||||
with the absent/NotApplicable observation, suppressing a redundant one-sided
|
||||
projection. A newly appearing pair still requires reset of old one-sided history.
|
||||
|
||||
All selected source proposals use one immutable initial project. Resets precede
|
||||
catalog and constructor-scope derivation. Many sources may affect one target only
|
||||
when their desired values agree on every actual canonical target slot, including
|
||||
removals and members introduced by another proposal. Child membership and the
|
||||
actual child's Parent share one slot; incidents share the relation's Source or
|
||||
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
|
||||
Conflicting desired values MUST reject independently of request order.
|
||||
|
||||
Materialize the agreed writes once, then audit each desired selected value.
|
||||
Prepare one ordinary complete Command batch and compare its actual project with
|
||||
the explicitly reviewed candidate. Additional cleanup, such as inherited message
|
||||
annotation pruning, is reported as exact object ``before``/``after`` typed records
|
||||
(or absence for creation/deletion). Every induced impact requires explicit review;
|
||||
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
|
||||
selected-value audit after preparation. Baseline updates belong to the same atomic
|
||||
transaction and source-preserving writeback as model edits.
|
||||
|
||||
A plan's base fingerprint detects a stale model; its prepared fingerprint checks
|
||||
candidate consistency. The plan is client-supplied data, not an authenticated
|
||||
receipt or proof of its original decisions. Applying it validates the complete
|
||||
ordinary candidate and preserves generated identities; it does not re-run its
|
||||
request to generate new identities. Hosted clients additionally use the revision
|
||||
preconditions defined in ``api.rst``. Invalid/stale plans or failed source writeback
|
||||
leave the accepted model, source, baseline, revision and history unchanged.
|
||||
|
||||
Own drift and linked impacts are separate, with incomplete review explicitly
|
||||
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
|
||||
Shared data/interface/event definitions are reviewed on their own traces; their
|
||||
bounded derived impact walk follows reusable definitions and interface allocations,
|
||||
not arbitrary architectural neighborhoods, and does not copy them into every
|
||||
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
|
||||
is a diagnostic. Successful review remains one undoable transaction. Replanning
|
||||
unchanged, already acknowledged leaf facts is a no-op only while their requests
|
||||
remain applicable. A completed constructor switch or first acknowledgement of an
|
||||
equal discriminator MUST be removed from subsequent requests; repeating it when
|
||||
no switch remains is rejected. After an artifact moves to another owner,
|
||||
metadata review through the old owner's trace is no longer
|
||||
applicable and is rejected. An old plan becomes stale after an actual committed
|
||||
change.
|
||||
|
||||
5.5. Source examples and historical token rules
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This valid source records deliberately different constructor/unit observations.
|
||||
Its historical integer minimum is absent; the Real side has no integer branch::
|
||||
|
||||
arcadia 5;
|
||||
project reviewed_dimensions "Reviewed dimensions" {
|
||||
oa_data_type oa_dimension "Operational distance" {
|
||||
definition { Integer { minimum 0; maximum 10; }; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "System distance" {
|
||||
definition { Real { minimum 0; maximum 20; }; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension review" {
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description ""; };
|
||||
sa { name "Approved SA name"; description ""; };
|
||||
semantic { facets [
|
||||
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
|
||||
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
|
||||
{ Data { Unit { oa { Value "m"; }; sa { Value "ft"; }; }; }; }
|
||||
]; };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Historical Id slots require ``uuid "..."`` even if the object still exists.
|
||||
Only typed reference slots inside ``baseline.semantic`` accept that atom.
|
||||
For example, the following is a valid historical facet value, including a deleted
|
||||
named type; ``none`` and bare enum Boolean retain their different typed meanings::
|
||||
|
||||
{ Field { Type {
|
||||
oa { Value { Named uuid "40000000-0000-4000-8000-000000000090"; }; };
|
||||
sa { Value Boolean; };
|
||||
}; }; }
|
||||
|
||||
A qualified annotation uses the same historical UUID atoms in its key and payload.
|
||||
This complete facet records one old row and an applicable absent target row::
|
||||
|
||||
{ Annotation {
|
||||
keys {
|
||||
oa {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
};
|
||||
sa none;
|
||||
};
|
||||
review {
|
||||
oa { Value {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
expression "latitude + 1";
|
||||
}; };
|
||||
sa { Value none; };
|
||||
};
|
||||
}; }
|
||||
|
||||
Historical enums require bare words. Ordinary strings, including UUID-looking
|
||||
units, literal keys and expressions, require quoted strings and remain text
|
||||
during identity remapping. ``@alias`` is invalid anywhere inside a semantic
|
||||
baseline. Live parents, trace endpoints and all other live references still
|
||||
require ``@alias`` and MUST NOT accept the historical UUID atom.
|
||||
|
||||
The following substitutions in these examples are invalid:
|
||||
|
||||
* ``oa "NotApplicable";`` or ``oa { Value "Integer"; };`` in the Constructor
|
||||
review: enum observations require bare words of the correct typed enum.
|
||||
* ``Named "40000000-0000-4000-8000-000000000090";`` or ``Named @oa_dimension;``:
|
||||
a historical Id requires the explicit UUID atom.
|
||||
* ``Unit { oa { Value uuid "40000000-0000-4000-8000-000000000090"; }; ... }``:
|
||||
a text field cannot hold a reference atom.
|
||||
* Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
|
||||
as a quoted string: closed typed admission rejects the whole candidate.
|
||||
* Changing only the annotation payload's field UUID suffix from 092 to 093,
|
||||
leaving the key at 092: the row's key and payload disagree.
|
||||
* Changing the document header to ``arcadia 4;`` while retaining ``semantic``:
|
||||
the frozen earlier schema does not admit a future baseline shape.
|
||||
|
||||
6. Text grammar and resolution
|
||||
------------------------------
|
||||
|
||||
@@ -1364,7 +1025,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
document = "arcadia" "5" ";" project
|
||||
document = "arcadia" "4" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1376,8 +1037,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
identity = "id" uuid-string
|
||||
fields = "{" (field-name value ";")* "}"
|
||||
value = string | number | "true" | "false" | "none"
|
||||
| enum-word | "@" alias | historical-id | list | fields
|
||||
historical-id = "uuid" uuid-string
|
||||
| enum-word | "@" alias | list | fields
|
||||
list = "[" (value ("," value)*)? "]"
|
||||
|
||||
The project description defaults to empty and may appear once. There is no
|
||||
@@ -1399,9 +1059,6 @@ produces UUID references; the alias itself remains stored on its declaration.
|
||||
Changing an alias in source requires updating its uses. Display names never
|
||||
resolve references.
|
||||
|
||||
``historical-id`` is context-restricted to the typed semantic baseline reference
|
||||
slots in section 5.5; it is not an alternative spelling of a live reference.
|
||||
|
||||
Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
|
||||
enum spellings, duplicate aliases/identities, and structural validation errors
|
||||
reject the entire candidate. No declaration silently replaces another.
|
||||
@@ -1415,13 +1072,13 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
|
||||
|
||||
The pure ``source_project`` core API compiles a virtual source bundle, containing
|
||||
``entry`` and a ``files`` map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level ``.arcadia`` file with the source5 project header above. A project entry
|
||||
root-level ``.arcadia`` file with the source4 project header above. A project entry
|
||||
MAY also be ``include string ";"``. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example::
|
||||
|
||||
arcadia 5;
|
||||
arcadia 4;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1487,17 +1144,12 @@ spans never refer to concatenated or synthetic source.
|
||||
|
||||
Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3, source4 and
|
||||
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
An authored source4 bundle upgrades through the strict prose-only baseline
|
||||
adapter; after any missing UUID stabilization, the version token alone changes
|
||||
to 5. Existing prose, comments, includes and nested ownership remain intact.
|
||||
Source5 semantic facet comments follow their closed selector identity through
|
||||
row reordering and reidentification; UUID-looking text does not act as an Id.
|
||||
|
||||
6.2. Nested element ownership
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -1729,19 +1381,10 @@ explicitly before Apply succeeds.
|
||||
|
||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||
the accepted V5 source bundle (``arcadia 5``); other suffixes use V5 JSON. Both forms preserve the same
|
||||
the accepted V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.
|
||||
|
||||
The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
|
||||
envelope with closed fields. It reuses unchanged typed elements, relations,
|
||||
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
|
||||
their names/descriptions remain exact even when live endpoint values differ.
|
||||
The adapter MUST NOT infer semantic review from live values, and version3/4
|
||||
inputs MUST reject semantic baseline fields. The migration report retains the
|
||||
original from_schema and current to_schema 5. Reopening a current5 save produces
|
||||
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.
|
||||
|
||||
The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
@@ -1763,9 +1406,9 @@ Migration reports additionally contain ``semantic_defaults`` (object, field,
|
||||
description), ``generated_interface_allocations`` (id, source, target, alias,
|
||||
order) and ``unresolved_message_values`` (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
|
||||
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 5; reading/migrating alone never saves or changes revisions.
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.
|
||||
|
||||
Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
@@ -1791,7 +1434,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.
|
||||
|
||||
The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
@@ -1810,14 +1453,14 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 5. Model/source exports contain only the canonical model. Migration
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.
|
||||
|
||||
Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V5 graph are
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4-to-5 stages.
|
||||
model and then uses these same V2-to-3-to-4 stages.
|
||||
|
||||
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
@@ -1836,7 +1479,7 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V5 result, including repeated occurrences.
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.
|
||||
|
||||
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
@@ -1844,7 +1487,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V5 projects. Future versions remain errors until a separate migration exists.
|
||||
V4 projects. Future versions remain errors until a separate migration exists.
|
||||
|
||||
10. Complete authoring example
|
||||
------------------------------
|
||||
@@ -1854,7 +1497,7 @@ the assigned UUIDs explicitly.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 5;
|
||||
arcadia 4;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
@@ -1893,14 +1536,6 @@ deterministic V1 migration, occurrence identity, coordinate bits, persistence,
|
||||
and model-history integration. Core validation and session tests cover domain
|
||||
mutation rules; native UI and API verification are separate evidence.
|
||||
|
||||
``cargo test -p oa-core --test r3_semantic --test r3_source --test r3_migration --locked``
|
||||
checks the closed facet catalog, partial decisions, constructor scopes, canonical
|
||||
agreement, target-only preservation, historic references, exact prepared impacts,
|
||||
source/JSON parity, comment ownership and explicit original1–4 migration. The
|
||||
source suite includes actual included-file save/reopen and prepared review
|
||||
writeback. These checks establish core/source behavior; they do not establish
|
||||
graphical or hosted API acceptance by themselves.
|
||||
|
||||
The research record in ``docs/research-v2-semantics.md`` distinguishes method
|
||||
requirements from Studio design choices and links to original UI evidence.
|
||||
The following sources inform this language without extending its scope:
|
||||
|
||||
+7
-5129
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -8,13 +8,13 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|
||||
|---|---|
|
||||
| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
|
||||
| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links |
|
||||
| Data and interfaces | Information/material/energy nature and Flow/Event/Operation/SharedData mechanisms, bounded primitive/enum/record types, typed defaults, field multiplicity/units/direction/storage, specialization, identified interface allocations and reference associations, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
|
||||
| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
|
||||
| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions |
|
||||
| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
|
||||
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
||||
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
|
||||
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
|
||||
| Automation 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 |
|
||||
| Text and storage | `arcadia 3` 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 |
|
||||
|
||||
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 |
|
||||
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
|
||||
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
|
||||
| UI-coupled automation | Renderer-independent core, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
|
||||
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
|
||||
|
||||
## Required first-release work
|
||||
|
||||
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, 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.
|
||||
Direct state/scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, 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. Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
||||
- Complete Capella communication/scenario variants beyond the specified Studio profile.
|
||||
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. 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.
|
||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
|
||||
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
||||
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
|
||||
|
||||
|
||||
+4
-36
@@ -1,6 +1,6 @@
|
||||
# Arcadia Studio server
|
||||
|
||||
`arcadia-server` is a headless Rust REST service for the same schema-5 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
|
||||
`arcadia-server` is a headless Rust REST service for the same schema-3 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
|
||||
|
||||
The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract.
|
||||
|
||||
@@ -44,7 +44,7 @@ The data directory contains `.arcadia.lock` and one `<resource-id>.json` file pe
|
||||
|
||||
Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup.
|
||||
|
||||
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled schema3 envelopes explicitly migrate both representations in memory; the independently migrated graphs must agree. Current-format source must contain stable IDs. Inconsistent pairs are rejected at startup. Migration reports remain visible until a current-format edit/save; opening alone never rewrites the envelope. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
|
||||
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled envelopes must contain stable source IDs and matching model/source; inconsistent pairs are rejected at startup. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
|
||||
|
||||
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation, source writeback and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once. Both model and source bundle must be unchanged for `changed: false`; comment-only edits advance the same revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
|
||||
|
||||
@@ -85,41 +85,9 @@ 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.
|
||||
|
||||
## Edit hosted models in the desktop
|
||||
## Desktop and automation integration
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# C: finite typed facet reviews — executable comparison
|
||||
|
||||
Status: isolated proof, pending root freeze. Production remains at `959b83c78fcaa82b0184aade81a0508b47eb1e41`; B1 evidence is commit `8ac9f464eaf0ec813ee3ecf903404891ae2a3841`. No R6/schema/source contract changed.
|
||||
|
||||
## Result and proposed shape
|
||||
|
||||
**11 tests pass: the original 3 B1 tests plus 8 C tests.** C stores reviewed facts directly, without an own-record carrier, reviewed mask, order shadow, observation version or recursive value tree. `OwnRecord` is reused only for transient source/target candidates and existing fixture helpers. The exact types/adapter are in `src/typed_reviews.rs`; assertions are in `src/typed_reviews_tests.rs`.
|
||||
|
||||
```rust
|
||||
enum Observed<T> { NotApplicable, Value(T) }
|
||||
struct Reviewed<T> { oa: Observed<T>, sa: Observed<T> }
|
||||
struct KeyPair<K> { oa: Option<K>, sa: Option<K> }
|
||||
struct ValueKey { path: Vec<Id>, field: Id }
|
||||
struct Baseline { facets: Vec<FacetReview> }
|
||||
```
|
||||
|
||||
`FacetReview` is self-tagged: Data family leaves carry `Reviewed<Option<i64>>`, `Reviewed<Option<f64>>`, `Reviewed<Option<DataValue>>`, etc.; the tag identifies the facet. Other proof leaves are field minimum, state guard, mapped trigger/step membership, step order, atomic keyed annotations and atomic keyed bindings. Existing `MessageValue`, `LifelineBinding`, `DataValue` and `DataTypeDefinition` are reused. `T` is a concrete closed type in each variant, not an arbitrary recursively nested value.
|
||||
|
||||
An absent entry means Unreviewed. `Value(None)` means an applicable optional slot has no value (for example an unbounded Real minimum). `NotApplicable` means a selected constructor branch does not exist on that side. A mixed review keeping an SA Integer while acknowledging an OA Real records Real-minimum OA=`Value(None)`, SA=`NotApplicable`; Integer-maximum records the reverse applicability. A both-side-inapplicable leaf is rejected.
|
||||
|
||||
Recommendation remains **C for the full contract**, because facts are explicit and independent. This proof does **not** show fewer lines of implementation: see the measured cost below. B1 remains viable at restricted granularity; C removes its storage-overlap invariants rather than eliminating exhaustive adapter work.
|
||||
|
||||
## What the executable proof establishes
|
||||
|
||||
| Case | Observed assertions |
|
||||
| --- | --- |
|
||||
| Bounds/unit and field minimum | Keep acknowledges only selected facts; unselected maximum still drifts. Take maximum retains target unit/minimum. Field minimum retains target maximum. Repeat application is unchanged. |
|
||||
| Guard/triggers after deletion | Deleted source UUID remains in a typed historical key. Removal unlinks the mapped trigger but leaves its target object; extra target trigger and local guard survive. |
|
||||
| Ordered steps | Accepting membership leaves the order fact byte-for-byte unchanged. Explicit final order must contain all surviving target members exactly once. Target-only step object survives unchanged. |
|
||||
| Qualified annotation rows | Two paths with the same terminal latitude UUID remain separate. Updating/removing the first row does not acknowledge or overwrite the second row's expression. Removal acknowledges the whole selected row. |
|
||||
| Binding rows | Key is the referenced lifeline; changing `local` updates that row through frozen mapping. Other binding remains unchanged. Missing local mapping rejects. |
|
||||
| Mixed constructor group | Keep can acknowledge Real/Integer as intentionally different constructors, including correct applicable/not-applicable leaves. Take changes definition+default while preserving unit. A later explicit target switch can reconsider a previous Keep. Retained Record child blocks an incompatible switch atomically. |
|
||||
| Prepared induced changes | A real inherited-field removal causes core `prepare_command` to prune annotations. C compares the actual prepared project with the reviewed expected project and rejects these unreviewed changes. Project and baseline inputs remain unchanged. |
|
||||
| Adversarial admission | Exact duplicates, keys colliding on either side, mismatched frozen key mapping, changed pairing against old baseline, wrong-family/both-side-NA Keep, unchanged-constructor acknowledgement, invalid historical row/key combinations, empty key pairs and nonfinite values reject. Collision permutations both reject. Returned baseline is revalidated. |
|
||||
|
||||
Constructor grouping copies the definition and default in this subset; accepting a switch retires old definition facts and records the union of both resulting constructor branches. Unit stays independent. An unchanged constructor cannot be used as a shortcut to acknowledge changed bounds/defaults. Constructor and descendant decisions in one request reject as overlapping. Membership operations precede order regardless of request order.
|
||||
|
||||
Pair remapping is deliberately conservative: a new KeyPair colliding with an old baseline pair rejects with an explicit review-invalidation requirement. This probe does not implement the subsequent mapping-reset workflow or agreement-based many-to-one merges. It cannot silently accumulate a baseline that fails on its next application.
|
||||
|
||||
## Measured implementation cost
|
||||
|
||||
`measure-code.ps1` counts nonblank, non-comment rustfmt lines. B1 is read from its committed version, not a moving file. These are code-size measurements, not a normalized complexity or speed benchmark.
|
||||
|
||||
| Portion | B1 | C | Difference |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Public types/requests | 69 | 118 | +49 |
|
||||
| Adapter/admission/application | 256 | 778 | +522 |
|
||||
| Tests | 512 | 1,035 | +523 |
|
||||
|
||||
Both use the same 52 lines of basic own-record/list helpers; C does not call B1's carrier copy/compare/apply logic. C covers 18 selector cases versus B1's 9, including both keyed row kinds and all DataType constructor branches. C also adds admission and prepared-project checks absent from B1. Therefore attributing the entire difference to representation would be misleading. C's repeated capture/tag/write matches are real maintenance cost; one finite catalog should supply selector/type metadata during implementation, without introducing a general metamodel framework.
|
||||
|
||||
## Full coverage and minimal granularity recommendation
|
||||
|
||||
| Family | Facets required by the current model |
|
||||
| --- | --- |
|
||||
| Headers | Name, description, element parent. No incidental identity/alias/layer edits. |
|
||||
| OA/SA primitive bodies | No payload; Process/FunctionalChain step membership and order. |
|
||||
| ExchangeItem / Interface | Nature, mechanism, supertype; interface supertype. Allocations remain real relation records. |
|
||||
| DataType | Constructor, integer/real bounds, string lengths, enum literal membership, Record supertype, default, unit. |
|
||||
| DataField | Whole TypeRef, multiplicity bounds, unit, direction, storage, whole optional default vector. No unstable per-index default selection. |
|
||||
| Port | Contract constructor; input/output item membership; component kind/orientation; legacy direction/interface. Existing typed/Legacy resolution and split semantics remain authoritative. |
|
||||
| Scenario / Lifeline | Viewpoint, context, pre/postconditions, step membership/order; represented reference. |
|
||||
| Occurrence envelope | Constructor and separate common pre/postconditions. |
|
||||
| Exchange occurrence | Exchange, sender, receiver, message kind/Timer expression, reply correlation, annotations keyed by full `(path,field)`. |
|
||||
| Remaining occurrences | Action/action-lifeline; control kind; sequence reference; scenario reference/bindings; state/state-lifeline; fragment operator/branches; branch guard/ordered occurrences. |
|
||||
| Event / State | Event constructor and Signal items/Time kind+expression/Change expression; StateKind structural change and entry/exit/do prose. Machine/Region have no own payload. |
|
||||
| Every relation | Source and target endpoints, plus name/description. |
|
||||
| Relation payloads | Interface allocation order/protocols; association roles/multiplicities/navigation; exchange items/ports/orientation; capability-extend condition; sequence guard; transition kind/trigger+effect sets and three prose fields; StateAction phase. All other relation kinds are endpoint-only. |
|
||||
| External membership/artifacts | Direct children; incident relation identity+endpoint role; attached artifact membership and name/description/owner/relation/URI/revision/fragment. |
|
||||
|
||||
Atomic annotation and binding rows are the smallest useful contract: key changes are explicit remove/add, while all other rows remain independent. A separate expression-versus-presence checkpoint is unnecessary at this granularity. If such controls are later required, C can add separate typed facts without a second carrier state.
|
||||
|
||||
Steps/branch occurrences are ordered. Trigger alternatives, bindings and annotations must not accidentally acquire execution ordering semantics from vector position. Enum literals need string-keyed membership to preserve target-only literals; standalone whole-list `TakeSource` is explicitly rejected by this probe, pending that finite selector. A constructor conversion that would overwrite retained target enum literals also requires an explicit candidate. Field default vectors remain atomic.
|
||||
|
||||
Constructor scopes own discriminator and introduced/removed variant fields. Dependent changes outside that scope require explicit resolutions. In particular, changing Occurrence's constructor must not acknowledge its outer pre/postconditions; Call→Timer must expose exchange/reply/value effects; StateKind is semantic even though it changes `Element::kind()`. Prepared induced changes must be reviewed for every representation.
|
||||
|
||||
Current Trace endpoints/properties do not accept artifacts. Prefer attached artifact review facts owned by the semantic trace instead of silently widening trace compatibility. Missing artifact versus present artifact with `revision=None` needs distinct typed observations.
|
||||
|
||||
## Reuse the existing source decoder, with typed context
|
||||
|
||||
Baseline JSON roundtrips, including deleted historical UUIDs and mixed constructor observations. All accepted live candidates roundtrip through current Arcadia 4 source. **C baseline source syntax is not implemented or proved.**
|
||||
|
||||
The concrete reuse route is:
|
||||
|
||||
1. `language.rs::Parser::value_inner`: recognize a distinct historical `uuid "..."` AST literal. Do not convert it to an ordinary quoted string too early.
|
||||
2. `Ast::resolve`: accept an explicit slot context supplied by the finite facet catalog. Historical reference slots accept UUID literals; live reference slots keep `@alias` requirements. A new context must be entered only through the semantic-baseline subtree of a Trace declaration.
|
||||
3. Keep `resolve_declarations` and its existing `serde_json::from_value::<Trace>`/body decoding as the typed authority. Do not duplicate the live body factory/default merge logic for historical facts.
|
||||
4. `write_fields`/`write_value`: use the same slot metadata to emit historical UUIDs without current alias lookup. Generic `Reviewed<T>{oa,sa}` cannot pass through current field-name-only rules: `oa`/`sa` are currently classified as references even when T is a unit string. Renaming keys alone does not distinguish reference/string/enum types.
|
||||
5. `edit_positions`/`EditKind` and `source_project.rs::item_key`: retain full UUID token spans, historical identity remapping, and full annotation/binding keys for comment ownership.
|
||||
6. Extend finite historical validation/admission counts and explicit remap visitors; exclude baseline IDs from live dangling-reference and deletion traversal. Historical carrier/facet validation must not fetch old objects or impose cross-facet live range invariants.
|
||||
|
||||
This needs explicit schema/source migration and OpenAPI descriptions before acceptance. No source4 or data4 changes were made here.
|
||||
|
||||
## Commands, evidence, and stopping point
|
||||
|
||||
From `D:/CodexBuilds/Capella2-transition-probe`:
|
||||
|
||||
```powershell
|
||||
cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture 2>&1 | Tee-Object -FilePath target/r3-probe/c-tests.log; exit $LASTEXITCODE
|
||||
```
|
||||
|
||||
Exit 0: **11 passed, 0 failed**, no compiler warnings. The vendor config is unchanged from the B1 report. `& experiments/r3-facet-carrier/measure-code.ps1` reproduces counts. Logs, `code-counts.json` and generated `evidence/c-*-baseline.json` / `c-*-candidate.arcadia` remain under `target/r3-probe/`.
|
||||
|
||||
Still outside this proof: complete facet catalog; reverse direction; multiple target records; historical identity remapping; mapping-reset and agreeing many-to-one resolution; unambiguous automatic ordering; source baseline parser/formatter; OpenAPI/migration; size budgets for every new fact; and production baseline+undo/revision/ETag/disk atomicity. String literal/row keys and historical UUID admission need full adversarial coverage at implementation time. No claim of full R3 implementation or acceptance is made.
|
||||
|
||||
Stop here for the root's freeze decision. No native UI or other checkout/target was used.
|
||||
Generated
+396
@@ -0,0 +1,396 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "r3-facet-carrier-probe"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user