Integrate release recovery, semantic review, coverage and Windows packaging
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user