Integrate release recovery, semantic review, coverage and Windows packaging

This commit is contained in:
Codex
2026-09-06 16:31:55 -07:00
parent c091f80e6e
commit 2b2d2bc18b
174 changed files with 27635 additions and 127 deletions
+74 -3
View File
@@ -5,7 +5,7 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Lifeline,
Message,
@@ -26,7 +26,7 @@ impl Tool {
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum MessageStyle {
Asynchronous,
Call,
@@ -160,7 +160,8 @@ struct CallChoiceContext {
before: Option<Id>,
endpoints: Option<(Option<Id>, Option<Id>)>,
}
#[derive(Clone)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
owner: Id,
parent: Id,
@@ -178,8 +179,11 @@ struct Draft {
second_guard: String,
operator: FragmentOperator,
bindings: BTreeMap<Id, Id>,
#[serde(with = "recovery_values")]
values: BTreeMap<crate::data_values::FieldPathKey, String>,
#[serde(with = "recovery_field_key")]
value_focus: Option<crate::data_values::FieldPathKey>,
#[serde(with = "recovery_field_key")]
value_browse: Option<crate::data_values::FieldPathKey>,
value_browse_page: usize,
value_authored_page: usize,
@@ -187,6 +191,22 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self {
draft: Some(draft),
..Default::default()
})
}
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
ScrollArea::Canvas => 0,
@@ -436,6 +456,57 @@ impl State {
Ok(None)
}
}
mod recovery_values {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
values: &BTreeMap<crate::data_values::FieldPathKey, String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
values
.iter()
.map(|(key, value)| (&key.path, key.field, value))
.collect::<Vec<_>>()
.serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<crate::data_values::FieldPathKey, String>, D::Error> {
let values = Vec::<(Vec<Id>, Id, String)>::deserialize(deserializer)?;
let mut result = BTreeMap::new();
for (path, field, value) in values {
if result
.insert(crate::data_values::FieldPathKey { path, field }, value)
.is_some()
{
return Err(serde::de::Error::custom(
"Duplicate recovered scenario value path",
));
}
}
Ok(result)
}
}
mod recovery_field_key {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
key: &Option<crate::data_values::FieldPathKey>,
serializer: S,
) -> Result<S::Ok, S::Error> {
key.as_ref()
.map(|key| (&key.path, key.field))
.serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<crate::data_values::FieldPathKey>, D::Error> {
Ok(Option::<(Vec<Id>, Id)>::deserialize(deserializer)?
.map(|(path, field)| crate::data_values::FieldPathKey { path, field }))
}
}
fn viewpoint(p: &Project, owner: Id) -> ScenarioViewpoint {
match p.elements.get(&owner).and_then(|e| e.shared()) {
Some(SharedBody::Scenario { viewpoint, .. }) => *viewpoint,
+190 -5
View File
@@ -99,6 +99,7 @@ struct PaintKey {
enum Hit {
Node { placement: Id, element: Id },
Transition(Id),
StateAction(Id),
}
impl State {
@@ -357,6 +358,63 @@ fn internal_rows<'a>(project: &'a Project, node: &ProjectedNode) -> Vec<(&'a Rel
.collect()
}
/// These rows represent maintained StateAction relations, not containment of
/// the referenced work. Their identity remains selectable independently.
fn state_action_rows<'a>(
project: &'a Project,
node: &ProjectedNode,
) -> Vec<(&'a Relation, Rectangle)> {
if node.collapsed
|| !project
.elements
.get(&node.element)
.is_some_and(|e| e.kind().is_stable_state())
{
return vec![];
}
let mut actions: Vec<_> = project.relations.values().filter(|r| matches!(r.body, RelationBody::StateAction { source, .. } if source == node.element)).collect();
actions.sort_by_key(|r| match r.body {
RelationBody::StateAction { phase, .. } => (
match phase {
StateActionKind::Entry => 0,
StateActionKind::Do => 1,
StateActionKind::Exit => 2,
},
r.id,
),
_ => unreachable!(),
});
let internal_height = internal_rows(project, node).len() as f32 * 56.;
let top = node.position.y + projection::state_header_height(project, node.element)
- internal_height
- actions.len() as f32 * 28.;
actions
.into_iter()
.enumerate()
.map(|(index, relation)| {
(
relation,
Rectangle::new(
Point::new(node.position.x + 8., top + index as f32 * 28.),
Size::new((node.size.width - 16.).max(1.), 26.),
),
)
})
.collect()
}
fn state_action_label(project: &Project, relation: &Relation) -> String {
let RelationBody::StateAction { target, phase, .. } = relation.body else {
return String::new();
};
let phase = match phase {
StateActionKind::Entry => "entry",
StateActionKind::Do => "do",
StateActionKind::Exit => "exit",
};
format!("{phase} / {}", project.named(target))
}
impl StateCanvas<'_> {
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
@@ -486,6 +544,12 @@ impl StateCanvas<'_> {
fn hit(&self, scene: &Scene, world: Point) -> Option<Hit> {
let node = self.node_at(scene, world);
if let Some(node) = node {
if let Some((relation, _)) = state_action_rows(self.project, node)
.into_iter()
.find(|(_, r)| r.contains(world))
{
return Some(Hit::StateAction(relation.id));
}
if let Some((relation, _)) = internal_rows(self.project, node)
.into_iter()
.find(|(_, r)| r.contains(world))
@@ -643,9 +707,11 @@ impl StateCanvas<'_> {
},
None => "Draw transition · click a source, then a compatible target, or drag between vertices".into(),
},
_ => if let Some(Hit::Transition(id)) = world.and_then(|p| self.hit(scene, p)) {
self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default()
} else { "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into() },
_ => match world.and_then(|p| self.hit(scene, p)) {
Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(),
Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(),
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
},
}
}
}
@@ -754,7 +820,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.reset();
let (object, placement) = match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id)) => (id, None),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
};
return Some(
@@ -799,7 +865,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.and_capture(),
)
}
Some(Hit::Transition(id)) => {
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
Some(Action::publish(Event::Select(self.context, id)).and_capture())
}
None => {
@@ -1378,6 +1444,22 @@ fn paint_node(
);
}
}
for (relation, row) in state_action_rows(project, node) {
if relation.id == selected {
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
}
label_text(
frame,
short(&state_action_label(project, relation), row.width - 12., 11.),
row.position() + Vector::new(6., 5.),
11.,
if relation.id == selected {
style::TEAL
} else {
style::MUTED
},
);
}
for (relation, row) in internal_rows(project, node) {
if relation.id == selected {
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
@@ -2012,6 +2094,109 @@ mod tests {
assert_eq!(accepted.relations[&relation].target(), f.c);
}
#[test]
fn typed_state_actions_are_named_selectable_rows_before_internal_transitions_and_regions() {
let mut f = fixture();
let mut action_ids = vec![];
for (phase, name) in [
(StateActionKind::Exit, "Release resources"),
(StateActionKind::Entry, "Restore settings"),
(StateActionKind::Do, "Monitor pressure"),
] {
let work = f
.project
.add_element(Kind::Function, Layer::Sa, name, None)
.unwrap();
let id = f
.project
.relate(RelationKind::StateAction, "Action link", f.a, work);
if let RelationBody::StateAction { phase: value, .. } =
&mut f.project.relations.get_mut(&id).unwrap().body
{
*value = phase;
}
action_ids.push(id);
}
if let Some(SharedBody::State { entry, .. }) =
f.project.elements.get_mut(&f.a).unwrap().shared_mut()
{
*entry = "Authored entry notes".into();
}
let internal = f
.project
.relate(RelationKind::StateTransition, "Update status", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
let before = f.project.clone();
let canvas = canvas(&f, Tool::Select, 0.75);
let scene = canvas.scene(&State::default()).unwrap();
let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
let rows = state_action_rows(&f.project, a);
assert_eq!(
rows.iter()
.map(|(r, _)| state_action_label(&f.project, r))
.collect::<Vec<_>>(),
[
"entry / Restore settings",
"do / Monitor pressure",
"exit / Release resources"
]
);
assert_eq!(
projection::state_header_height(&f.project, f.a),
64. + 28. + 3. * 28. + 56.
);
let internal_top = internal_rows(&f.project, a)[0].1.y;
assert!(rows[0].1.y >= a.position.y + 92.);
assert!(rows.last().unwrap().1.y + 28. <= internal_top);
let child_region = scene
.view
.nodes
.iter()
.find(|n| n.visual_parent == Some(a.id))
.unwrap();
assert!(
child_region.position.y
>= a.position.y + projection::state_header_height(&f.project, f.a)
);
for (relation, row) in rows {
assert_eq!(
canvas.hit(&scene, row.center()),
Some(Hit::StateAction(relation.id))
);
let events = click(&canvas, &mut State::default(), row.center());
assert!(
matches!(events.as_slice(), [Event::Select(context, id)] if *context == canvas.context && *id == relation.id),
"Clicking a typed action row must select its relation in this view: {events:?}"
);
assert!(
!scene
.view
.nodes
.iter()
.any(|n| n.element == relation.target())
);
assert!(!scene.routed.edges.contains_key(&relation.id));
}
let mut collapsed = a.clone();
collapsed.collapsed = true;
assert!(state_action_rows(&f.project, &collapsed).is_empty());
assert_eq!(
f.project, before,
"Reading and selecting action rows cannot mutate work or ownership"
);
let target = f.project.relations[&action_ids[1]].target();
f.project.elements.get_mut(&target).unwrap().name = "Reload settings".into();
assert_eq!(
state_action_label(&f.project, &f.project.relations[&action_ids[1]]),
"entry / Reload settings"
);
}
#[test]
fn internal_rows_and_pseudostate_hits_use_shared_persisted_geometry() {
let mut f = fixture();
+16 -1
View File
@@ -2,7 +2,7 @@
use super::*;
use iced::widget::{checkbox, column, text_input};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Vertex,
Region,
@@ -46,6 +46,8 @@ pub enum Event {
pub struct State {
draft: Option<Draft>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
machine: Id,
tool: Tool,
@@ -184,6 +186,19 @@ impl Draft {
}
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self { draft: Some(draft) })
}
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.machine)
}
+39 -2
View File
@@ -12,7 +12,7 @@ pub(crate) use direct::{
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Work,
Exchange,
@@ -76,12 +76,16 @@ pub struct State {
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
search: String,
page: usize,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(with = "recovery_insertion")]
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
@@ -97,6 +101,22 @@ struct Draft {
control: ControlKind,
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self {
draft: Some(draft),
..Default::default()
})
}
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.owner)
}
@@ -198,6 +218,23 @@ impl State {
.clamp(0.05, 1.)
}
}
mod recovery_insertion {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
value: &Insertion,
serializer: S,
) -> Result<S::Ok, S::Error> {
(value.link, value.before).serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Insertion, D::Error> {
let (link, before) = <(Option<Id>, Option<Id>)>::deserialize(deserializer)?;
Ok(Insertion { link, before })
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.parent == Some(owner)