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, Option) { 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") ); }