Integrate release recovery, semantic review, coverage and Windows packaging
This commit is contained in:
@@ -556,7 +556,72 @@ pub struct State {
|
||||
states: states::State,
|
||||
}
|
||||
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecoveryDraft {
|
||||
version: u32,
|
||||
kind: RecoveryKind,
|
||||
draft: serde_json::Value,
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
enum RecoveryKind {
|
||||
State,
|
||||
Scenario,
|
||||
Process,
|
||||
}
|
||||
impl State {
|
||||
/// Capture only the pending authored form. Pointer gestures, cached choices,
|
||||
/// viewport geometry and document/revision tokens never cross a restart.
|
||||
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
let drafts = [
|
||||
(RecoveryKind::State, self.states.recovery_snapshot()?),
|
||||
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
|
||||
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
|
||||
];
|
||||
let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| {
|
||||
draft.map(|draft| RecoveryDraft {
|
||||
version: 1,
|
||||
kind,
|
||||
draft,
|
||||
})
|
||||
});
|
||||
let first = drafts.next();
|
||||
if drafts.next().is_some() {
|
||||
return Err(
|
||||
"Several behavior forms are pending; recovery cannot choose one to discard.".into(),
|
||||
);
|
||||
}
|
||||
first
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Restore raw fields without planning/executing them. Rejected values and
|
||||
/// incomplete endpoints must remain available for repair after a crash.
|
||||
pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> {
|
||||
let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.version != 1 {
|
||||
return Err(format!(
|
||||
"Unsupported behavior recovery version {}",
|
||||
saved.version
|
||||
));
|
||||
}
|
||||
let mut restored = Self::default();
|
||||
match saved.kind {
|
||||
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
|
||||
RecoveryKind::Scenario => {
|
||||
restored.scenario = scenario::State::from_recovery(saved.draft)?
|
||||
}
|
||||
RecoveryKind::Process => {
|
||||
restored.topology = topology::State::from_recovery(saved.draft)?
|
||||
}
|
||||
}
|
||||
restored.active_owner = restored.pending_owner();
|
||||
*self = restored;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.pending_owner().is_some()
|
||||
}
|
||||
@@ -2352,6 +2417,147 @@ mod direct_canvas_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovery_retains_each_incomplete_behavior_form_without_executing_it() {
|
||||
let p = Project::demo();
|
||||
let before = p.clone();
|
||||
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
|
||||
let machine = owner(Kind::StateMachine);
|
||||
let scenario = owner(Kind::Scenario);
|
||||
let process = owner(Kind::Process);
|
||||
let cases = [
|
||||
(
|
||||
machine,
|
||||
vec![
|
||||
Event::States(states::Event::Start(
|
||||
machine,
|
||||
states::Tool::Transition,
|
||||
machine,
|
||||
)),
|
||||
Event::States(states::Event::Name("Unfinished timeout".into())),
|
||||
Event::States(states::Event::Timed(true)),
|
||||
Event::States(states::Event::Expression("-".into())),
|
||||
Event::States(states::Event::Guard("temperature >".into())),
|
||||
],
|
||||
),
|
||||
(
|
||||
scenario,
|
||||
vec![
|
||||
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
|
||||
Event::Scenario(scenario::Event::Name("Unfinished request".into())),
|
||||
Event::Scenario(scenario::Event::Expression("-".into())),
|
||||
],
|
||||
),
|
||||
(
|
||||
process,
|
||||
vec![
|
||||
Event::Topology(topology::Event::Start(
|
||||
process,
|
||||
topology::Tool::Connect,
|
||||
None,
|
||||
)),
|
||||
Event::Topology(topology::Event::Name("Unfinished path".into())),
|
||||
Event::Topology(topology::Event::Guard("available &&".into())),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (owner, events) in cases {
|
||||
let mut original = State::default();
|
||||
for event in events {
|
||||
original.update(&p, event).unwrap();
|
||||
}
|
||||
assert!(original.prepare_pending(&p).is_err());
|
||||
let snapshot = original.recovery_snapshot().unwrap().unwrap();
|
||||
let bytes = serde_json::to_vec(&snapshot).unwrap();
|
||||
let mut recovered = State::default();
|
||||
recovered
|
||||
.restore_recovery(serde_json::from_slice(&bytes).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(recovered.pending_owner(), Some(owner));
|
||||
assert_eq!(recovered.active_owner, Some(owner));
|
||||
assert!(
|
||||
recovered.prepare_pending(&p).is_err(),
|
||||
"An incomplete recovered form is not applied or silently repaired"
|
||||
);
|
||||
assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot));
|
||||
assert_eq!(p, before);
|
||||
recovered.acknowledge_commit();
|
||||
assert!(recovered.recovery_snapshot().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() {
|
||||
let p = Project::demo();
|
||||
let scenario = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Scenario)
|
||||
.unwrap()
|
||||
.id;
|
||||
let field = Id::new_v4();
|
||||
let paths = [Id::new_v4(), Id::new_v4()];
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
|
||||
)
|
||||
.unwrap();
|
||||
for (path, value) in paths.into_iter().zip(["-", "3.4e"]) {
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Scenario(scenario::Event::Value(
|
||||
crate::data_values::FieldPathKey {
|
||||
path: vec![path],
|
||||
field,
|
||||
},
|
||||
value.into(),
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let original = state.recovery_snapshot().unwrap().unwrap();
|
||||
let mut recovered = State::default();
|
||||
recovered.restore_recovery(original.clone()).unwrap();
|
||||
assert_eq!(
|
||||
recovered.recovery_snapshot().unwrap(),
|
||||
Some(original.clone())
|
||||
);
|
||||
let values = original["draft"]["values"].as_array().unwrap();
|
||||
assert_eq!(values.len(), 2);
|
||||
assert_ne!(values[0][0], values[1][0]);
|
||||
assert_eq!(values[0][1], values[1][1]);
|
||||
for bad in [
|
||||
{
|
||||
let mut value = original.clone();
|
||||
value["version"] = 99.into();
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = original.clone();
|
||||
value["draft"]["unknown"] = true.into();
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = original.clone();
|
||||
value["draft"]["values"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(values[0].clone());
|
||||
value
|
||||
},
|
||||
] {
|
||||
assert!(recovered.restore_recovery(bad).is_err());
|
||||
assert_eq!(
|
||||
recovered.recovery_snapshot().unwrap(),
|
||||
Some(original.clone()),
|
||||
"Malformed recovery cannot discard the currently retained form"
|
||||
);
|
||||
}
|
||||
}
|
||||
use crate::document::{Document, DocumentError};
|
||||
use oa_core::source_project::{self, ProjectLimits};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user