Add typed scalar semantic review to desktop workflow
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user