Add typed scalar semantic review to desktop workflow

This commit is contained in:
2026-09-06 06:07:39 +03:00
parent 6bb2583b4a
commit 036276813c
12 changed files with 2417 additions and 12 deletions
+46
View File
@@ -106,6 +106,7 @@ pub enum Message {
SelectProject,
Search(String),
StartTransition,
StartSemanticReview(Id),
Workflow(crate::workflow::Event),
CollapseExplorer(Id),
ExplorerGroup(crate::explorer::Group),
@@ -1315,12 +1316,57 @@ 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 {
+30
View File
@@ -130,8 +130,22 @@ impl Draft {
}
}
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// 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;
};
@@ -1370,6 +1384,22 @@ 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,
+1
View File
@@ -19,6 +19,7 @@ mod port_edit;
mod port_preview;
mod port_workflow;
mod route_edit;
mod semantic_review;
mod source_navigation;
mod source_tree;
mod source_workspace;
+6 -1
View File
@@ -415,10 +415,15 @@ 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 / update counterpart…",
"Create counterpart / review prose…",
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…",
+547
View File
@@ -0,0 +1,547 @@
//! 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 scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
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,
}
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 draft: Option<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(),
draft: None,
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.selected = None;
self.accepted_impacts.clear();
self.references.clear();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
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 {
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(&path, value)),
Event::Variant(path, value) => self.edit(project, &path, |d| {
if d.materialize()?
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array(&path, index, direction))
}
Event::Append(path, value) => self.edit(project, &path, |d| d.append(&path, value)),
Event::ValuePath(path, key) => self.edit(project, &path, |d| d.value_path(&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(()),
};
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 root = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// 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 {
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& !scalar::grouped(facet)
}
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) 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;
}
self.facts
.iter()
.find(|f| f.selector == *facet)
.is_some_and(|f| {
scalar::value(&f.current, Layer::Oa).is_some()
&& scalar::observation(&f.current, Layer::Oa)
== scalar::observation(&f.current, 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: vec![],
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
.decisions
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let original =
Draft::from_project(project, target).ok_or("Target has no editable record.")?;
let mut final_value = original.value;
let scopes: Vec<_> = self
.decisions
.iter()
.filter(|(_, decision)| **decision == SemanticDecision::Candidate)
.filter_map(|(facet, _)| scalar::binding(project, target, facet))
.collect();
let draft = self
.draft
.as_ref()
.ok_or("Target draft is unavailable.")?
.materialize_scopes(&scopes)?;
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);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => draft
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?,
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
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)
}
pub fn refresh(&mut self, project: &Project) {
self.plan = None;
self.proposed = None;
self.error = None;
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) 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(facts) => 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() {
return Err("Choose at least one property to review.".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.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))
}
@@ -0,0 +1,288 @@
//! 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
}
@@ -0,0 +1,784 @@
use super::*;
use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn apply(app: &mut App) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
}
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)
}
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
}
fn start(app: &mut App, source: Id) {
let _ = app.update(Message::StartSemanticReview(source));
}
fn facet(value: DataFacet) -> FacetSelector {
FacetSelector::Data(value)
}
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 scalar")
);
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(
facet(DataFacet::Constructor),
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);
}
@@ -0,0 +1,580 @@
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>| {
format!(
"{} ↔ {}",
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
)
};
match facet {
FacetSelector::Member {
collection,
keys: pair,
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
FacetSelector::Literal(pair) => format!(
"Literal · {} ↔ {}",
pair.oa.as_deref().unwrap_or("Absent"),
pair.sa.as_deref().unwrap_or("Absent")
),
FacetSelector::Annotation(pair) => format!(
"Message value · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent"),
pair.sa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent")
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
FacetSelector::Artifact { keys: pair, facet } => {
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
}
_ => {
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 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 {
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(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),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
Tab::References => self.references_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!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.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 side = |layer, label: &str| {
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(observed(project, previous, layer)).size(12),
text("Current").size(10).color(style::MUTED),
text(observed(project, Some(&fact.current), layer)).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 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(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) {
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(
"Set final value…",
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
));
detail = detail.push(buttons).push(action(
"Leave unselected",
Event::Decide(fact.selector.clone(), None),
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = &self.draft
&& let Some(path) = self
.target(project)
.and_then(|id| scalar::binding(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12)).push(
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
.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) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
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 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 reset in the structured workflow.").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()
}
}
+4 -1
View File
@@ -250,7 +250,10 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(app.source_snapshot.starts_with("arcadia 4;"));
assert!(
app.source_snapshot
.starts_with(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+36 -9
View File
@@ -247,6 +247,13 @@ 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 {
@@ -299,15 +306,27 @@ 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(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()
})
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()
})
)
]
.into()
@@ -714,10 +733,14 @@ fn traces(app: &App) -> View<'_, Message> {
"Connect operational needs, system responsibilities and development evidence."
),
row![
button(text("Create / update counterpart…").size(12))
button(text("Create counterpart / review prose…").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)
@@ -740,10 +763,14 @@ fn traces(app: &App) -> View<'_, Message> {
.spacing(8)
.align_y(iced::Center),
row![
label(format!("{:?} · {current}", t.kind)),
label(format!("{:?} · Prose: {current}", t.kind)),
Space::new().width(Fill),
action("Review link", Message::Select(t.id))
action("Review semantics…", Message::StartSemanticReview(t.id)),
action("Edit 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),
+13 -1
View File
@@ -15,6 +15,7 @@ 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),
@@ -33,6 +34,7 @@ pub enum Event {
Preview,
}
pub enum Workflow {
Semantic(Box<crate::semantic_review::State>),
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
@@ -333,6 +335,11 @@ 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,
@@ -571,6 +578,7 @@ 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",
@@ -584,6 +592,9 @@ 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);
}
@@ -594,7 +605,7 @@ impl Workflow {
None
};
match self {
Self::Ports(_) => unreachable!("Port dialog returned above"),
Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
Self::ManualTrace {
name,
source,
@@ -1073,6 +1084,7 @@ 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),
+82
View File
@@ -0,0 +1,82 @@
# Semantic desktop review — milestones 1 and 2
Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI
parity, native acceptance or release acceptance.
Base: `d130013`. Dedicated build target:
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
## Scope and decisions
- Add an explicit Review semantics action for existing traced counterparts, with
canonical Operational/System comparisons in either application direction.
Preserve the existing counterpart creation and prose workflow with clear labels.
- Review one explicitly selected trace per dialog. Display grouped current and
historical facts, own/linked drift and incomplete review. Names never infer
reference correspondence; provide explicit reference and trace choices.
- Support every applicable scalar leaf through Keep, Take and typed final-value
forms. Candidate records contain the complete combined selected result. Identity,
alias and layer are protected. The closed core remains the validation boundary.
- State.Kind is a single structural scalar. Other constructor switches, keyed
membership/order/annotation/binding/attachment decisions and pairing resets belong
to milestone 3; show them read-only with an honest explanation.
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
previously reviewed constructors remain governed by the core's switch rules.
- Show exact additional prepared impacts and require explicit acceptance. Apply
validates the stored semantic plan and executes its exact command batch through
Document, preserving source, baselines and one-step undo/redo. Do not place or
reveal diagram objects by mutating the model after preview.
- Retain typed drafts and review choices on invalid input, planning conflicts,
stale plans and failed source writeback. Display actionable errors in the dialog.
## Milestones and acceptance
1. Grouped read-only review, named trace/reference choices, exact comparisons and
honest traceability rollups; compact layout usable at 1100 by 700.
2. Scalar decisions and typed candidates; immutable initial observations; combined
final records; exact impact acceptance; atomic document/source commit. Focused
App event/source/history tests, strict Clippy and a dedicated desktop build.
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
multi-source review and remaining source/REST parity cases. Still outstanding.
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
## Verification
Verified against core `6bb2583`, using only the dedicated desktop target:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
cargo test --locked -p oa-desktop --quiet
cargo clippy --locked -p oa-desktop --all-targets -- -D warnings
cargo build --locked -p oa-desktop
```
All **272 desktop tests passed**, including **11 new semantic review tests**.
Strict Clippy and the desktop build passed. The existing migration test's header
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
Coverage includes both-direction Keep/Take/Candidate with exact comments and
source/model/baseline undo/redo; independent selected trace semantic histories;
invalid numeric and protected-property edits; stale-plan and source-writeback
rejection; typed reference mapping mixed with candidates; honest partial rollups;
equal-kind acknowledgment without implicit descendants; State.Kind candidates;
relationship role/bound candidates with retained identity/endpoints; and exact
inherited-annotation cleanup approval, cleared when decisions change. Unselected
invalid scalar drafts remain retained without contaminating another candidate.
Built executable:
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
The build target is idle. No native application was launched by this task.
## Remaining work
Milestone 3 must add explicit constructor closure/replacement, keyed and ordered
decisions, historical pairing reset, complete artifact lifecycle and multi-source
review. These facts are currently inspectable, with unsupported writes visibly
identified. The reference table supplies explicit mappings for scalar reference
Take; it does not claim to complete a reset or establish keyed acknowledgments.
Fresh independent scalar GUI review and root-owned native verification are still
required before merging this milestone into release acceptance. In particular,
validate the 1100×700 layout, keyboard/focus continuity, long historical values,
reference selection, source reopening and prepared-impact inspection in the app.