Complete source 6 engineering workflows and preserve release validation evidence

This commit is contained in:
Codex
2026-09-06 21:49:04 -07:00
parent 2b2d2bc18b
commit 4b6b5fc197
290 changed files with 48271 additions and 1791 deletions
+5 -4
View File
@@ -519,11 +519,12 @@ mod tests {
};
state
}
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
let event = semantic_state(app).address_event(event);
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
use crate::semantic_review::Event;
use crate::semantic_review::RowEvent as Event;
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
semantic_send(
app,
@@ -548,7 +549,7 @@ mod tests {
#[test]
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
use crate::semantic_review::{Event, State};
use crate::semantic_review::{RowEvent as Event, State};
let mut p = Project::empty();
let oa = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
@@ -610,7 +611,7 @@ mod tests {
#[test]
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
use crate::semantic_review::Event;
use crate::semantic_review::RowEvent as Event;
for reverse in [false, true] {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let mut p = Project::empty();
+645
View File
@@ -0,0 +1,645 @@
//! Closed view intent and named category membership through ordinary property drafts.
use super::*;
use iced::widget::column;
use oa_core::view_options::{
self, CategorySelection, ContextSelection, Coverage, EdgeAvailability, EdgeKind,
ExchangeFamily, ExchangeLabels, OmissionReason, RelationKinds, ViewIndex, ViewOptions,
};
const PAGE_SIZE: usize = 24;
struct Candidate {
id: Id,
label: String,
detail: String,
}
fn choice<'a>(
label: &str,
path: &str,
selected: &'static str,
choices: Vec<(&'static str, &'static str)>,
) -> View<'a, Message> {
let path = path.to_owned();
let selected = choices
.iter()
.find(|(_, value)| *value == selected)
.map(|(label, _)| *label);
let labels: Vec<_> = choices.iter().map(|(label, _)| *label).collect();
column![
text(label.to_owned()).size(12).color(style::MUTED),
pick_list(labels, selected, move |label| Message::FieldShape(
path.clone(),
json!(
choices
.iter()
.find(|(candidate, _)| *candidate == label)
.unwrap()
.1
)
))
.text_size(12)
.padding(7)
.width(Fill)
]
.spacing(5)
.width(Fill)
.into()
}
fn named(project: &Project, id: Id, detail: impl Into<String>) -> Candidate {
let alias = project
.elements
.get(&id)
.map(|element| element.alias.as_str())
.or_else(|| {
project
.relations
.get(&id)
.map(|relation| relation.alias.as_str())
})
.or_else(|| project.traces.get(&id).map(|trace| trace.alias.as_str()))
.unwrap_or_default();
Candidate {
id,
label: format!("{} · {}", project.named(id), alias),
detail: detail.into(),
}
}
fn list_value(ids: &[Id], wrapper: Option<&str>) -> Value {
match wrapper {
Some(wrapper) => json!({wrapper: ids}),
None => json!(ids),
}
}
fn members<'a>(
draft: &'a Draft,
path: &str,
selected: &[Id],
mut candidates: Vec<Candidate>,
wrapper: Option<&'static str>,
verb: &'static str,
) -> View<'a, Message> {
// Preserve and expose now-ineligible identities until the engineer removes them.
for id in selected {
if !candidates.iter().any(|candidate| candidate.id == *id) {
candidates.push(Candidate {
id: *id,
label: format!("Retained reference · {id}"),
detail:
"Missing or outside the chosen layer/family. Remove or correct before Apply."
.into(),
});
}
}
candidates.sort_by_key(|candidate| (candidate.label.to_lowercase(), candidate.id));
let browser = draft.reference_browsers.get(path);
let query = browser.map(|browser| browser.query.as_str()).unwrap_or("");
let search = query.to_lowercase();
candidates.retain(|candidate| {
format!("{} {} {}", candidate.label, candidate.id, candidate.detail)
.to_lowercase()
.contains(&search)
});
let page = browser
.map(|browser| browser.page)
.unwrap_or(0)
.min(candidates.len().saturating_sub(1) / PAGE_SIZE);
let start = page * PAGE_SIZE;
let path_owned = path.to_owned();
let mut out = column![
text_input("Find by name, alias, identity or reason", query)
.on_input(move |query| Message::FieldReferenceBrowse(path_owned.clone(), query, 0))
.size(12)
.padding(7),
text(format!(
"{} {} · {} matching",
selected.len(),
if verb == "Hide" {
"explicitly hidden"
} else {
"selected"
},
candidates.len()
))
.size(11)
.color(style::MUTED)
]
.spacing(6)
.width(Fill);
if candidates.is_empty() {
out = out.push(text("No matching records.").size(12));
}
for candidate in candidates.iter().skip(start).take(PAGE_SIZE) {
let checked = selected.contains(&candidate.id);
let id = candidate.id;
let selected = selected.to_vec();
let path = path.to_owned();
out = out.push(
column![
checkbox(checked)
.label(format!("{verb} {}", candidate.label))
.size(14)
.text_size(12)
.on_toggle(move |enabled| {
let mut ids = selected.clone();
ids.retain(|existing| *existing != id);
if enabled {
ids.push(id);
}
Message::FieldShape(path.clone(), list_value(&ids, wrapper))
}),
text(candidate.detail.clone()).size(11).color(style::MUTED)
]
.spacing(3)
.width(Fill),
);
}
if candidates.len() > PAGE_SIZE {
let mut previous = button(text("Previous").size(11)).style(style::nav);
if page > 0 {
previous = previous.on_press(Message::FieldReferenceBrowse(
path.into(),
query.into(),
page - 1,
));
}
let mut next = button(text("Next").size(11)).style(style::nav);
if start + PAGE_SIZE < candidates.len() {
next = next.on_press(Message::FieldReferenceBrowse(
path.into(),
query.into(),
page + 1,
));
}
out = out.push(
row![
previous,
text(format!(
"Page {} of {}",
page + 1,
candidates.len().div_ceil(PAGE_SIZE)
))
.size(11),
next
]
.spacing(6),
);
}
out.into()
}
fn categories<'a>(
draft: &'a Draft,
project: &Project,
layer: Layer,
path: &str,
family: ExchangeFamily,
selection: &CategorySelection,
cross_layer: bool,
) -> View<'a, Message> {
let label = match family {
ExchangeFamily::Work => "Work exchange categories",
ExchangeFamily::Participant => "Participant exchange categories",
};
let all = matches!(selection, CategorySelection::All);
let path_owned = path.to_owned();
let mut out = column![
text(label).size(13),
checkbox(all)
.label("All, including uncategorized exchanges")
.text_size(12)
.on_toggle(move |all| Message::FieldShape(
path_owned.clone(),
if all {
json!(CategorySelection::All)
} else {
json!(CategorySelection::Any(vec![]))
}
))
]
.spacing(6)
.width(Fill);
if let CategorySelection::Any(selected) = selection {
out = out.push(
text("Selected categories combine by union. Selecting none shows none of this family.")
.size(11)
.color(style::MUTED),
);
let candidates = project
.elements
.values()
.filter_map(|element| {
let SharedBody::ExchangeCategory {
family: candidate_family,
exchanges,
} = element.shared()?
else {
return None;
};
((cross_layer || element.layer() == layer) && *candidate_family == family).then(
|| {
named(
project,
element.id,
format!("{} exchange members", exchanges.len()),
)
},
)
})
.collect();
out = out.push(members(
draft,
path,
selected,
candidates,
Some("Any"),
"Include",
));
}
out.into()
}
fn reason(reason: OmissionReason) -> &'static str {
match reason {
OmissionReason::ExplicitHide => "explicitly hidden",
OmissionReason::RelationKind => "relationship kind excluded",
OmissionReason::Category(ExchangeFamily::Work) => "outside selected work categories",
OmissionReason::Category(ExchangeFamily::Participant) => {
"outside selected participant categories"
}
OmissionReason::Coverage => "outside selected global behavior coverage",
OmissionReason::WorkContext => "outside selected process, chain or scenario",
OmissionReason::OutsideProjection => "outside this projection",
OmissionReason::EndpointNotShown {
source: true,
target: true,
} => "source and target are not shown",
OmissionReason::EndpointNotShown {
source: true,
target: false,
} => "source is not shown",
OmissionReason::EndpointNotShown {
source: false,
target: true,
} => "target is not shown",
OmissionReason::EndpointNotShown {
source: false,
target: false,
} => "endpoint placement unavailable",
OmissionReason::FoldedInternal => "internal to a collapsed or grouped node",
OmissionReason::WrongLayer => "outside this layer",
OmissionReason::UnknownEdge => "edge no longer exists",
}
}
fn options<'a>(
draft: &'a Draft,
project: &Project,
path: &str,
options: ViewOptions,
) -> View<'a, Message> {
let diagram = serde_json::from_value::<Diagram>(draft.context_value())
.ok()
.or_else(|| project.diagrams.get(&draft.original).cloned());
let Some(diagram) = diagram else {
return text("View options require a saved view.").size(12).into();
};
let layer = diagram.layer;
let cross_layer = diagram.projection == ProjectionKind::Traceability;
let kinds_path = format!("{path}/relation_kinds");
let all_kinds = matches!(options.relation_kinds, RelationKinds::All);
let toggle_path = kinds_path.clone();
let mut out = column![text("Saved view visibility").size(15),
text("Apply changes to this view. Model relationships, nesting and saved routes remain available.").size(11).color(style::MUTED),
choice("Exchange labels", &format!("{path}/exchange_labels"), match options.exchange_labels { ExchangeLabels::Name => "Name", ExchangeLabels::Items => "Items", ExchangeLabels::NameAndItems => "NameAndItems" }, vec![("Names", "Name"), ("Items", "Items"), ("Names and items", "NameAndItems")]),
checkbox(all_kinds).label("All relationship line kinds").text_size(12).on_toggle(move |all| Message::FieldShape(toggle_path.clone(), if all { json!(RelationKinds::All) } else { json!(RelationKinds::Only(vec![])) }))].spacing(10).width(Fill);
if let RelationKinds::Only(selected) = &options.relation_kinds {
out = out.push(text("No selected kinds means no relationship lines. Nesting and state action rows remain.").size(11).color(style::MUTED));
for kind in RelationKind::ALL
.into_iter()
.map(EdgeKind::Relation)
.chain([EdgeKind::Trace])
{
let label = match kind {
EdgeKind::Relation(kind) => kind.to_string(),
EdgeKind::Trace => "Realization traces".into(),
};
let checked = selected.contains(&kind);
let selected = selected.clone();
let path = kinds_path.clone();
out = out.push(checkbox(checked).label(label).text_size(12).on_toggle(
move |enabled| {
let mut kinds = selected.clone();
kinds.retain(|existing| *existing != kind);
if enabled {
kinds.push(kind);
}
Message::FieldShape(path.clone(), json!(RelationKinds::Only(kinds)))
},
));
}
}
out = out.push(categories(draft, project, layer, &format!("{path}/work_categories"), ExchangeFamily::Work, &options.work_categories, cross_layer))
.push(categories(draft, project, layer, &format!("{path}/participant_categories"), ExchangeFamily::Participant, &options.participant_categories, cross_layer))
.push(choice("Work exchange coverage across the whole model", &format!("{path}/coverage"), match options.coverage { Coverage::All => "All", Coverage::InBehavior => "InBehavior", Coverage::OutsideBehavior => "OutsideBehavior" }, vec![("All work exchanges", "All"), ("Used in behavior", "InBehavior"), ("Outside behavior", "OutsideBehavior")]))
.push(text("Coverage uses explicit exchange occurrences in processes, chains and scenarios, including reuse. It does not infer execution or participant transport coverage.").size(11).color(style::MUTED));
let context_path = format!("{path}/work_contexts");
let toggle_path = context_path.clone();
out = out.push(
checkbox(matches!(options.work_contexts, ContextSelection::All))
.label("All work contexts")
.text_size(12)
.on_toggle(move |all| {
Message::FieldShape(
toggle_path.clone(),
if all {
json!(ContextSelection::All)
} else {
json!(ContextSelection::Any(vec![]))
},
)
}),
);
if let ContextSelection::Any(selected) = &options.work_contexts {
let candidates = project
.elements
.values()
.filter(|element| {
(cross_layer || element.layer() == layer)
&& matches!(
element.kind(),
Kind::Process | Kind::FunctionalChain | Kind::Scenario
)
})
.map(|element| named(project, element.id, element.kind().label()))
.collect();
out = out.push(text("Show work exchanges used by any selected context, intersected with the coverage and category choices above. None selected shows no work exchanges.").size(11).color(style::MUTED))
.push(members(draft, &context_path, selected, candidates, Some("Any"), "Include"));
}
// Both expensive indexes are built once for the panel, never once per edge.
let index = ViewIndex::new(project);
let filter = if cross_layer {
index.prepare_cross_layer(layer, &options)
} else {
index.prepare(layer, &options)
};
let availability = projection::edge_availability(project, &diagram);
let mut shown = 0usize;
let candidates: Vec<_> = project
.relations
.keys()
.chain(project.traces.keys())
.filter(|id| {
cross_layer || project.layer_of(**id) == Some(layer) || project.traces.contains_key(id)
})
.map(|id| {
let reasons = filter.reasons(
*id,
availability
.get(id)
.copied()
.unwrap_or(EdgeAvailability::OutsideProjection),
);
shown += usize::from(reasons.is_empty());
named(
project,
*id,
if reasons.is_empty() {
"Shown by these settings".into()
} else {
reasons
.into_iter()
.map(reason)
.collect::<Vec<_>>()
.join("; ")
},
)
})
.collect();
out = out.push(text("Individual lines and omission reasons").size(13))
.push(text(format!("With these settings: {shown} graphical lines shown · {} lines omitted", candidates.len() - shown)).size(11).color(style::MUTED))
.push(text("Check Hide to retain an explicit hide. Unchecking removes only that hide; other listed reasons can still omit the line.").size(11).color(style::MUTED))
.push(button(text("Clear all explicit hides").size(11)).style(style::nav).on_press(Message::FieldShape(format!("{path}/hidden_edges"), json!([]))))
.push(members(draft, &format!("{path}/hidden_edges"), &options.hidden_edges, candidates, None, "Hide"));
out.into()
}
fn category_members<'a>(
draft: &'a Draft,
project: &Project,
path: &str,
value: &Value,
) -> View<'a, Message> {
let context = draft.context_value();
let parent = path
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or_default();
let family = context
.pointer(&format!("{parent}/family"))
.and_then(|value| serde_json::from_value::<ExchangeFamily>(value.clone()).ok())
.unwrap_or(ExchangeFamily::Work);
let layer = project.layer_of(draft.original).unwrap_or(Layer::Oa);
let Ok(selected) = serde_json::from_value::<Vec<Id>>(value.clone()) else {
return column![text("The retained member list contains an invalid reference. Clear it explicitly or cancel this property draft before choosing members.").size(12),
button(text("Clear invalid member list").size(11)).style(style::nav).on_press(Message::FieldShape(path.into(), json!([])))].spacing(6).width(Fill).into();
};
let candidates = project
.relations
.values()
.filter(|relation| {
view_options::validate_category(project, layer, None, family, &[relation.id]).is_empty()
})
.map(|relation| {
named(
project,
relation.id,
format!(
"{} → {} · {}",
project.named(relation.source()),
project.named(relation.target()),
relation.kind()
),
)
})
.collect();
column![text("Exchange members").size(13), text("Membership is a reusable concern. It does not allocate exchanges, change endpoints or imply execution. An empty category is valid.").size(11).color(style::MUTED),
members(draft, path, &selected, candidates, None, "Include")].spacing(6).width(Fill).into()
}
pub(super) fn property<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
) -> Option<View<'a, Message>> {
if path == "/view_options" {
return Some(match serde_json::from_value::<ViewOptions>(value.clone()) {
Ok(value) => options(draft, project, path, value),
Err(error) => text(format!("View settings are invalid: {error}. The draft remains available for correction in source.")).size(12).into(),
});
}
if path.ends_with("/ExchangeCategory") {
return Some(
column![
choice(
"Exchange family",
&format!("{path}/family"),
if value.get("family").and_then(Value::as_str) == Some("Participant") {
"Participant"
} else {
"Work"
},
vec![
("Work exchanges", "Work"),
("Participant exchanges", "Participant")
]
),
category_members(
draft,
project,
&format!("{path}/exchanges"),
value.get("exchanges").unwrap_or(&Value::Null)
)
]
.spacing(10)
.width(Fill)
.into(),
);
}
if path.contains("/ExchangeCategory/") {
if path.ends_with("/family") {
return Some(choice(
"Exchange family",
path,
if value.as_str() == Some("Participant") {
"Participant"
} else {
"Work"
},
vec![
("Work exchanges", "Work"),
("Participant exchanges", "Participant"),
],
));
}
if path.ends_with("/exchanges") {
return Some(category_members(draft, project, path, value));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selective_view_property_apply_recovery_and_undo_preserve_semantics_and_empty_choices() {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "Request", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
.unwrap();
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.place(diagram, a, Position { x: 10., y: 20. });
project.place(diagram, b, Position { x: 300., y: 20. });
let mut draft = Draft::from_project(&project, diagram).unwrap();
draft
.shape(
"/view_options/work_categories",
list_value(&[], Some("Any")),
)
.unwrap();
draft
.shape("/view_options/hidden_edges", list_value(&[exchange], None))
.unwrap();
draft
.shape("/view_options/exchange_labels", json!("NameAndItems"))
.unwrap();
draft.reference_browsers.insert(
"/view_options/hidden_edges".into(),
ReferenceBrowser {
query: "Request".into(),
page: 3,
},
);
let restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
assert_eq!(
restored.materialize().unwrap(),
draft.materialize().unwrap()
);
assert!(restored.reference_browsers.is_empty());
let mut session = Session::new(project.clone());
session.execute(restored.command().unwrap()).unwrap();
let accepted = session.project();
assert_eq!(accepted.elements, project.elements);
assert_eq!(accepted.relations, project.relations);
assert_eq!(
accepted.diagrams[&diagram].nodes,
project.diagrams[&diagram].nodes
);
assert_eq!(
accepted.diagrams[&diagram].view_options.work_categories,
CategorySelection::Any(vec![])
);
assert_eq!(
accepted.diagrams[&diagram].view_options.hidden_edges,
vec![exchange]
);
assert_eq!(
language::parse(&language::format(accepted)).unwrap(),
*accepted
);
session.undo();
assert_eq!(session.project(), &project);
}
#[test]
fn category_family_change_retains_members_until_explicit_correction() {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "Request", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
.unwrap();
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
let id = project
.add_element(Kind::ExchangeCategory, Layer::Oa, "Service concern", None)
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: vec![exchange],
};
let mut draft = Draft::from_project(&project, id).unwrap();
let path = "/body/Oa/Shared/ExchangeCategory";
draft
.shape(&format!("{path}/family"), json!("Participant"))
.unwrap();
assert_eq!(
draft
.materialize()
.unwrap()
.pointer(&format!("{path}/exchanges")),
Some(&json!([exchange]))
);
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
let mut restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
restored
.shape(&format!("{path}/exchanges"), list_value(&[], None))
.unwrap();
let accepted = execute_batch(&project, &[restored.command().unwrap()]).unwrap();
assert!(
matches!(accepted.elements[&id].shared(), Some(SharedBody::ExchangeCategory { family: ExchangeFamily::Participant, exchanges }) if exchanges.is_empty())
);
assert_eq!(accepted.relations, project.relations);
}
}