Files
ARCADIA-Studio/crates/oa-desktop/src/semantic_review/batch_tests.rs
T

808 lines
27 KiB
Rust

use super::*;
use crate::{app::App, document::Document};
struct Ranges {
project: Project,
sources: [Id; 2],
targets: [Id; 2],
traces: [Id; 2],
direction: TransitionDirection,
}
fn ranges(reverse: bool, converge: bool) -> Ranges {
let mut project = Project::empty();
let direction = if reverse {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
};
let mut add = |name, layer| {
let id = project
.add_element(Kind::DataType, layer, name, None)
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(100),
},
default: None,
unit: "degrees".into(),
};
id
};
let sources = [
add("Normal operation", direction.source_layer()),
add("Emergency operation", direction.source_layer()),
];
let first = add("Controller limit", direction.target_layer());
let second = if converge {
first
} else {
add("Backup controller limit", direction.target_layer())
};
let targets = [first, second];
let traces = std::array::from_fn(|i| {
let (oa, sa) = if reverse {
(targets[i], sources[i])
} else {
(sources[i], targets[i])
};
tests::add_trace(
&mut project,
oa,
sa,
if i == 0 {
"Normal limit rationale"
} else {
"Emergency limit rationale"
},
)
});
Ranges {
project,
sources,
targets,
traces,
direction,
}
}
fn address(f: &Ranges, i: usize) -> RowAddress {
RowAddress {
source: f.sources[i],
target: f.targets[i],
trace: f.traces[i],
}
}
fn send(state: &mut State, project: &Project, event: RowEvent) {
let event = state.address_event(event);
state.update(project, event);
}
fn open(state: &mut State, project: &Project, trace: Id) {
state.update(project, Event::ChooseTrace(view::choice(project, trace)));
state.update(project, Event::OpenTrace);
}
fn path(direction: TransitionDirection, leaf: &str) -> String {
format!(
"/body/{}/Shared/DataType/definition/Integer/{leaf}",
if direction.target_layer() == Layer::Oa {
"Oa"
} else {
"Sa"
}
)
}
fn set_bound(state: &mut State, f: &Ranges, facet: DataFacet, raw: &str) {
let selector = FacetSelector::Data(facet);
send(state, &f.project, RowEvent::Select(selector.clone()));
send(
state,
&f.project,
RowEvent::Decide(selector, Some(SemanticDecision::Candidate)),
);
send(
state,
&f.project,
RowEvent::Input(
path(
f.direction,
if facet == DataFacet::IntegerMinimum {
"minimum"
} else {
"maximum"
},
),
raw.into(),
),
);
}
fn bounds(project: &Project, target: Id) -> (Option<i64>, Option<i64>) {
match project.elements[&target].shared().unwrap() {
SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, maximum },
..
} => (*minimum, *maximum),
other => panic!("Unexpected target {other:?}"),
}
}
fn errors(state: &State) -> String {
format!(
"{:?} / {:?}",
state.error,
state.plan.as_ref().map(|plan| &plan.conflicts)
)
}
#[test]
fn batch_joint_bounds_use_one_candidate_and_match_an_independent_atomic_core_oracle() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "120");
assert!(!state.can_apply(), "A single 120..100 row is invalid");
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
assert!(state.can_apply(), "{}", errors(&state));
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
(Some(120), Some(150))
);
let mut transition = TransitionRequest::new(f.direction, f.sources.to_vec());
transition.mappings = (0..2)
.map(|i| TransitionMapping {
source: f.sources[i],
target: Some(f.targets[i]),
target_kind: None,
})
.collect();
let mut expected = SemanticTransitionRequest::new(transition);
expected.references = (0..2).map(|i| (f.sources[i], f.targets[i])).collect();
expected.traces = (0..2).map(|i| (f.sources[i], f.traces[i])).collect();
expected.reviews = [DataFacet::IntegerMinimum, DataFacet::IntegerMaximum]
.into_iter()
.enumerate()
.map(|(i, facet)| SemanticReviewRequest {
source: f.sources[i],
resolutions: vec![FacetResolution {
facet: FacetSelector::Data(facet),
decision: SemanticDecision::Candidate,
}],
reset: vec![],
})
.collect();
let mut candidate = f.project.elements[&f.targets[0]].clone();
let SharedBody::DataType { definition, .. } = candidate.shared_mut().unwrap() else {
unreachable!()
};
*definition = DataTypeDefinition::Integer {
minimum: Some(120),
maximum: Some(150),
};
expected.candidates.push(SemanticRecord::Element(candidate));
let oracle = plan_semantic_transition(&f.project, expected).unwrap();
assert!(oracle.conflicts.is_empty(), "{:?}", oracle.conflicts);
assert_eq!(
state.proposed.as_ref().unwrap(),
&prepare_semantic_transition(&f.project, &oracle)
.unwrap()
.project
);
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 1);
let prepared = state.proposed.clone();
state.order.reverse();
state.refresh(&f.project);
assert_eq!(
state.proposed, prepared,
"Display order must not choose the winning draft"
);
}
#[test]
fn batch_candidate_conflict_keeps_both_raw_values_and_keep_is_a_real_constraint() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "7");
assert!(!state.can_apply());
let error = errors(&state);
assert!(
error.contains("Normal operation")
&& error.contains("Emergency operation")
&& error.contains("disagree"),
"{error}"
);
let minimum = path(f.direction, "minimum");
assert_eq!(
state
.row(address(&f, 0))
.unwrap()
.draft
.as_ref()
.unwrap()
.inputs[&minimum]
.raw,
"5"
);
assert_eq!(
state
.row(address(&f, 1))
.unwrap()
.draft
.as_ref()
.unwrap()
.inputs[&minimum]
.raw,
"7"
);
send(
&mut state,
&f.project,
RowEvent::Decide(
FacetSelector::Data(DataFacet::IntegerMinimum),
Some(SemanticDecision::KeepTarget),
),
);
assert!(
!state.can_apply(),
"Keeping zero must conflict with another row's five"
);
send(
&mut state,
&f.project,
RowEvent::Decide(FacetSelector::Data(DataFacet::IntegerMinimum), None),
);
assert!(state.can_apply(), "{}", errors(&state));
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
Some(5)
);
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
assert!(
state.can_apply(),
"Equal independently authored values agree"
);
}
#[test]
fn batch_excluded_invalid_row_survives_focus_reopen_and_v2_recovery() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "-");
assert!(!state.can_apply());
state.update(&f.project, Event::Include(address(&f, 1), false));
assert!(state.can_apply(), "{}", errors(&state));
state.update(&f.project, Event::Focus(address(&f, 0)));
open(&mut state, &f.project, f.traces[1]);
assert!(
!state.included.contains(&address(&f, 1)),
"Reopen is focus, not consent to include invalid work"
);
assert!(state.can_apply(), "{}", errors(&state));
send(
&mut state,
&f.project,
RowEvent::Select(FacetSelector::Data(DataFacet::IntegerMinimum)),
);
assert!(
state.can_apply(),
"Selecting an excluded property must not promote its draft error to a batch failure"
);
let saved = state.recovery_snapshot().unwrap();
assert_eq!(saved["version"], 2);
let restored = State::from_recovery(&f.project, saved.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
assert_eq!(
restored
.row(address(&f, 1))
.unwrap()
.draft
.as_ref()
.unwrap()
.inputs[&path(f.direction, "maximum")]
.raw,
"-"
);
assert!(restored.can_apply(), "{}", errors(&restored));
assert_eq!(restored.proposed, state.proposed);
}
#[test]
fn batch_delayed_text_targets_its_original_row_and_facet_without_switching_focus() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "6".into()));
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
state.update(&f.project, late);
assert_eq!(state.active_address, Some(address(&f, 1)));
assert_eq!(
state.selected,
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
);
assert!(state.can_apply(), "{}", errors(&state));
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
(Some(6), Some(150))
);
state.update(&f.project, Event::Focus(address(&f, 0)));
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "8".into()));
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
state.update(&f.project, late);
assert_eq!(
state.selected,
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
);
assert_eq!(
state.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
"8"
);
assert_eq!(
state.draft.as_ref().unwrap().inputs[&path(f.direction, "maximum")].raw,
"150"
);
}
#[test]
fn batch_reverse_distinct_targets_work_and_reverse_convergence_is_explicit() {
for converge in [false, true] {
let f = ranges(true, converge);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
if converge {
assert!(!state.can_apply());
assert!(
errors(&state).contains("Reverse convergence"),
"{}",
errors(&state)
);
assert_eq!(state.included_addresses().len(), 2);
} else {
assert!(state.can_apply(), "{}", errors(&state));
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
Some(5)
);
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[1]).1,
Some(150)
);
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 2);
}
}
}
#[test]
fn batch_v2_rejects_duplicate_included_sources_bad_addresses_and_stale_model() {
let mut f = ranges(false, false);
let alternate = tests::add_trace(
&mut f.project,
f.sources[0],
f.targets[0],
"Alternative rationale",
);
let mut state = State::new(&f.project, f.traces[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
open(&mut state, &f.project, alternate);
let alternate_address = RowAddress {
trace: alternate,
..address(&f, 0)
};
assert!(!state.included.contains(&alternate_address));
let saved = state.recovery_snapshot().unwrap();
let mut duplicate = saved.clone();
duplicate["rows"][1]["included"] = Value::Bool(true);
assert!(
State::from_recovery(&f.project, duplicate)
.err()
.unwrap()
.contains("multiple included")
);
let mut duplicate = saved.clone();
let extra = duplicate["rows"][0].clone();
duplicate["rows"].as_array_mut().unwrap().push(extra);
assert!(State::from_recovery(&f.project, duplicate).is_err());
let mut bad = saved.clone();
bad["active"]["target"] = serde_json::to_value(f.sources[0]).unwrap();
assert!(State::from_recovery(&f.project, bad).is_err());
let mut changed = f.project.clone();
changed.name.push_str(" newer accepted revision");
assert!(State::from_recovery(&changed, saved).is_err());
assert!(state.command(&changed).is_err());
let before = state
.row(address(&f, 0))
.unwrap()
.draft
.as_ref()
.unwrap()
.inputs[&path(f.direction, "minimum")]
.raw
.clone();
state.update(&changed, Event::Focus(address(&f, 0)));
assert_eq!(
state
.row(address(&f, 0))
.unwrap()
.draft
.as_ref()
.unwrap()
.inputs[&path(f.direction, "minimum")]
.raw,
before
);
}
#[test]
fn batch_constructor_retirement_conflicts_with_another_rows_kept_bound_in_both_orders() {
for first in [0, 1] {
let mut f = ranges(false, true);
*f.project
.elements
.get_mut(&f.targets[0])
.unwrap()
.shared_mut()
.unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.0),
maximum: Some(100.0),
},
default: None,
unit: "degrees".into(),
};
let mut state = State::new(&f.project, f.sources[first]).unwrap();
for i in [first, 1 - first] {
if state.active_address != Some(address(&f, i)) {
open(&mut state, &f.project, f.traces[i]);
}
let facet = FacetSelector::Data(if i == 0 {
DataFacet::Constructor
} else {
DataFacet::RealMinimum
});
send(&mut state, &f.project, RowEvent::Select(facet.clone()));
send(
&mut state,
&f.project,
RowEvent::Decide(
facet,
Some(if i == 0 {
SemanticDecision::TakeSource
} else {
SemanticDecision::KeepTarget
}),
),
);
}
assert!(
!state.can_apply(),
"A constructor must not retire another row's kept bound"
);
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
assert!(
state
.row(address(&f, 0))
.unwrap()
.decisions
.contains_key(&FacetSelector::Data(DataFacet::Constructor))
);
assert!(
state
.row(address(&f, 1))
.unwrap()
.decisions
.contains_key(&FacetSelector::Data(DataFacet::RealMinimum))
);
}
}
#[test]
fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
let f = ranges(false, true);
let mut app = App::with_project(f.project.clone());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(
0,
"// Two independent engineering rationales stay verbatim\r\n",
);
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
tests::start(&mut app, f.sources[0]);
tests::decide(
&mut app,
FacetSelector::Data(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
tests::send(
&mut app,
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
);
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
)));
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
Event::OpenTrace,
)));
tests::decide(
&mut app,
FacetSelector::Data(DataFacet::IntegerMaximum),
SemanticDecision::Candidate,
);
tests::send(
&mut app,
RowEvent::Input(path(f.direction, "maximum"), "150".into()),
);
assert!(
tests::state(&app).can_apply(),
"{}",
errors(tests::state(&app))
);
let expected = tests::state(&app).proposed.clone().unwrap();
tests::apply(&mut app);
assert_eq!(app.project(), &expected);
let after = app.document.snapshot();
assert!(
after.bundle.files[&after.bundle.entry]
.starts_with("// Two independent engineering rationales stay verbatim\r\n")
);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_eq!(
Document::from_bundle(
after.bundle.clone(),
source_project::ProjectLimits::default()
)
.unwrap()
.project(),
&expected
);
}
#[test]
fn category_family_and_members_are_independent_core_checked_facets_both_directions() {
for reverse in [false, true] {
let mut project = Project::empty();
let mut owners = vec![];
let mut exchanges = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let source = project
.add_element(work, layer, "Issue command", None)
.unwrap();
let target = project
.add_element(work, layer, "Perform command", None)
.unwrap();
let exchange = project.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Command",
source,
target,
);
let owner = project
.add_element(Kind::ExchangeCategory, layer, "Command concern", None)
.unwrap();
*project
.elements
.get_mut(&owner)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: oa_core::view_options::ExchangeFamily::Work,
exchanges: vec![exchange],
};
owners.push(owner);
exchanges.push(exchange);
}
tests::add_trace(&mut project, owners[0], owners[1], "Concern correspondence");
validate_candidate(&project).unwrap();
let mut state = State::new(&project, owners[usize::from(reverse)]).unwrap();
state.references.insert(exchanges[0], exchanges[1]);
state.refresh(&project);
let member = FacetSelector::Member {
collection: ReferenceCollection::CategoryExchanges,
keys: KeyPair {
oa: Some(exchanges[0]),
sa: Some(exchanges[1]),
},
};
let family = FacetSelector::Category(CategoryFacet::Family);
assert!(state.editable(&project, &family));
assert!(state.editable(&project, &member));
send(&mut state, &project, RowEvent::Select(family.clone()));
send(
&mut state,
&project,
RowEvent::Decide(family.clone(), Some(SemanticDecision::Candidate)),
);
let target = owners[usize::from(!reverse)];
let path = scalar::binding(&project, target, &family).unwrap();
send(
&mut state,
&project,
RowEvent::Shape(path, serde_json::json!("Participant")),
);
assert!(
!state.can_apply(),
"Work exchanges cannot belong to a Participant category"
);
send(&mut state, &project, RowEvent::Select(member.clone()));
send(
&mut state,
&project,
RowEvent::Decide(member.clone(), Some(SemanticDecision::Candidate)),
);
send(&mut state, &project, RowEvent::Presence(member, false));
assert!(state.can_apply(), "{}", errors(&state));
assert!(
matches!(state.proposed.as_ref().unwrap().elements[&target].shared().unwrap(), SharedBody::ExchangeCategory { family: oa_core::view_options::ExchangeFamily::Participant, exchanges } if exchanges.is_empty())
);
assert_eq!(
prepare_command(&project, &state.command(&project).unwrap())
.unwrap()
.project,
state.proposed.clone().unwrap()
);
let _ = state.view(&project);
}
}
#[test]
fn rejected_direction_switch_at_row_limit_preserves_every_recoverable_draft() {
let mut f = ranges(false, true);
let mut traces = vec![f.traces[0]];
for i in 1..batch::MAX_REVIEW_ROWS {
traces.push(tests::add_trace(
&mut f.project,
f.sources[0],
f.targets[0],
&format!("Alternative {i}"),
));
}
let mut state = State::new(&f.project, traces[0]).unwrap();
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "-");
for trace in traces.into_iter().skip(1) {
let row = ReviewRow::new(&f.project, trace).unwrap();
let address = RowAddress {
source: f.sources[0],
target: f.targets[0],
trace,
};
state.bank.insert(address, row);
state.order.push(address);
}
let before = state.recovery_snapshot().unwrap();
for event in [
Event::Direction(TransitionDirection::SaToOa),
state.address_event(RowEvent::Direction(TransitionDirection::SaToOa)),
] {
state.update(&f.project, event);
assert_eq!(state.direction, TransitionDirection::OaToSa);
assert!(state.error.as_ref().unwrap().contains("256"));
let saved = state.recovery_snapshot().unwrap();
assert_eq!(saved, before);
let restored = State::from_recovery(&f.project, saved).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), before);
assert_eq!(
restored.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
"-"
);
}
}
#[test]
fn batch_complete_constructor_cannot_erase_another_rows_new_literal() {
let mut f = ranges(false, true);
for id in f.sources.into_iter().chain(f.targets) {
*f.project
.elements
.get_mut(&id)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Enumeration {
literals: vec!["A".into()],
},
default: None,
unit: String::new(),
};
}
let constructor = FacetSelector::Data(DataFacet::Constructor);
let mut first = State::new(&f.project, f.sources[0]).unwrap();
send(
&mut first,
&f.project,
RowEvent::Decide(constructor.clone(), Some(SemanticDecision::KeepTarget)),
);
assert!(first.can_apply(), "{}", errors(&first));
f.project = first.proposed.unwrap();
let mut state = State::new(&f.project, f.sources[0]).unwrap();
send(
&mut state,
&f.project,
RowEvent::Decide(constructor, Some(SemanticDecision::TakeSource)),
);
open(&mut state, &f.project, f.traces[1]);
let literal = FacetSelector::Literal(KeyPair {
oa: None,
sa: Some("B".into()),
});
send(&mut state, &f.project, RowEvent::Select(literal.clone()));
send(
&mut state,
&f.project,
RowEvent::Decide(literal.clone(), Some(SemanticDecision::Candidate)),
);
send(&mut state, &f.project, RowEvent::Presence(literal, true));
assert!(!state.can_apply());
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
state.update(&f.project, Event::Include(address(&f, 0), false));
assert!(state.can_apply(), "{}", errors(&state));
assert!(
matches!(state.proposed.as_ref().unwrap().elements[&f.targets[0]].shared().unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Enumeration { literals }, .. } if literals == &vec!["A".to_string(), "B".to_string()])
);
}
#[test]
fn batch_first_equal_constructor_acknowledgement_allows_an_independent_bound() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.sources[0]).unwrap();
send(
&mut state,
&f.project,
RowEvent::Decide(
FacetSelector::Data(DataFacet::Constructor),
Some(SemanticDecision::KeepTarget),
),
);
open(&mut state, &f.project, f.traces[1]);
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
assert!(state.can_apply(), "{}", errors(&state));
assert_eq!(
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
(Some(5), Some(100))
);
}
#[test]
fn recovered_trace_picker_names_existing_trace_instead_of_deleted_reference() {
let f = ranges(false, true);
let mut state = State::new(&f.project, f.traces[0]).unwrap();
state.update(
&f.project,
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
);
let saved = state.recovery_snapshot().unwrap();
let restored = State::from_recovery(&f.project, saved).unwrap();
let selected = restored.add_trace.as_ref().unwrap();
assert_eq!(selected.id, f.traces[1]);
assert!(
selected.label.contains("Emergency limit rationale"),
"{}",
selected.label
);
assert!(!selected.label.contains("Deleted reference"));
assert_eq!(selected.label, state.add_trace.as_ref().unwrap().label);
assert!(
view::choice(&f.project, Id::new_v4())
.label
.contains("Deleted reference")
);
}