Fix nullable stored-history constructor switching
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
This commit is contained in:
@@ -449,6 +449,9 @@ impl Draft {
|
||||
None
|
||||
};
|
||||
let tag = |value: &Value| {
|
||||
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
|
||||
return history_fields::constructor_tag(path, value);
|
||||
}
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
|
||||
|
||||
@@ -382,6 +382,45 @@ fn branch_has_fields(schema: &'static Value) -> bool {
|
||||
.is_none_or(|values| values.len() > 1)
|
||||
}
|
||||
|
||||
/// Constructor identity is independent of a primitive's edited content. In
|
||||
/// particular, absent, an empty UUID/text, a number and a list are different
|
||||
/// branches. String enum tags and externally tagged objects retain their variant.
|
||||
pub(super) fn constructor_tag(path: &str, value: &Value) -> String {
|
||||
fn enum_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let Ok(schema) = resolve(schema) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(options) = alternatives(schema) {
|
||||
return options
|
||||
.iter()
|
||||
.any(|option| enum_value(option, value, depth + 1));
|
||||
}
|
||||
schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| values.contains(value))
|
||||
}
|
||||
match value {
|
||||
Value::Null => "null".into(),
|
||||
Value::Bool(_) => "boolean".into(),
|
||||
Value::Number(_) => "number".into(),
|
||||
Value::Array(_) => "array".into(),
|
||||
Value::String(word)
|
||||
if schema_at(path).is_some_and(|schema| enum_value(schema, value, 0)) =>
|
||||
{
|
||||
format!("enum:{word}")
|
||||
}
|
||||
Value::String(_) => "string".into(),
|
||||
Value::Object(fields) => format!(
|
||||
"object:{}",
|
||||
fields.keys().cloned().collect::<Vec<_>>().join("/")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) {
|
||||
let last = count.saturating_sub(1) / PAGE_SIZE;
|
||||
let page = draft
|
||||
|
||||
@@ -731,3 +731,181 @@ fn history_clear_discards_inactive_invalid_constructors_before_reusing_the_same_
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
|
||||
/// Exercise the same descriptor choice and FieldShape event emitted by the
|
||||
/// nested picker. Injecting an already complete Observed value skips this path.
|
||||
fn choose_history_constructor(draft: &mut Draft, path: &str, label: &str) {
|
||||
let schema = resolve(schema_at(path).expect("typed history field")).unwrap();
|
||||
let branch = alternatives(schema)
|
||||
.expect("constructor choices")
|
||||
.iter()
|
||||
.find(|branch| branch_label(branch) == label)
|
||||
.expect("visible constructor choice");
|
||||
let event = variant_message(
|
||||
path,
|
||||
branch_seed(branch).unwrap(),
|
||||
draft.value.pointer(path).is_some(),
|
||||
);
|
||||
let Message::FieldShape(path, value) = event else {
|
||||
panic!("constructor picker must emit FieldShape");
|
||||
};
|
||||
draft.shape(&path, value).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_nested_optional_number_events_switch_absent_present_and_recover_invalid_raw() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft
|
||||
.append(
|
||||
FACETS,
|
||||
choice(FacetSelector::Data(DataFacet::IntegerMinimum)),
|
||||
)
|
||||
.unwrap();
|
||||
let side = format!("{FACETS}/0/Data/IntegerMinimum/oa");
|
||||
let path = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
assert_eq!(draft.value.pointer(&path), Some(&Value::Null));
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&path),
|
||||
Some(&json!(0)),
|
||||
"null and numeric constructor identities must differ"
|
||||
);
|
||||
draft.input(&path, "-".into());
|
||||
choose_history_constructor(&mut draft, &path, "Absent (none)");
|
||||
assert_eq!(draft.value.pointer(&path), Some(&Value::Null));
|
||||
let mut draft = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(draft.inputs[&path].raw, "-");
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&path, "7".into());
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(Some(7)),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
choose_history_constructor(&mut draft, &path, "Absent (none)");
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(None),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(
|
||||
draft.inputs[&path].raw, "7",
|
||||
"returning to Value restores its edited contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_nested_uuid_and_list_events_preserve_present_values_and_tagged_item_branches() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft
|
||||
.append(FACETS, choice(FacetSelector::Header(HeaderFacet::Parent)))
|
||||
.unwrap();
|
||||
draft
|
||||
.append(FACETS, choice(FacetSelector::Field(FieldFacet::Default)))
|
||||
.unwrap();
|
||||
let side = format!("{FACETS}/0/Header/Parent/oa");
|
||||
let uuid = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&uuid),
|
||||
Some(&json!("")),
|
||||
"empty UUID input is distinct from absent"
|
||||
);
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&uuid, "incomplete-uuid".into());
|
||||
choose_history_constructor(&mut draft, &uuid, "Absent (none)");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(draft.inputs[&uuid].raw, "incomplete-uuid");
|
||||
let historical = Id::from_u128(8765);
|
||||
draft.input(&uuid, historical.to_string());
|
||||
choose_history_constructor(&mut draft, &uuid, "Absent (none)");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(draft.inputs[&uuid].raw, historical.to_string());
|
||||
|
||||
let side = format!("{FACETS}/1/Field/Default/oa");
|
||||
let list = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
choose_history_constructor(&mut draft, &list, "Set list");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&list),
|
||||
Some(&json!([])),
|
||||
"empty list is distinct from absent"
|
||||
);
|
||||
let array_schema = alternatives(resolve(schema_at(&list).unwrap()).unwrap())
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|branch| resolve(branch).unwrap())
|
||||
.find(|branch| branch["type"] == "array")
|
||||
.unwrap();
|
||||
let event = Message::FieldAppend(list.clone(), seed(&array_schema["items"], 0).unwrap());
|
||||
let Message::FieldAppend(path, value) = event else {
|
||||
unreachable!()
|
||||
};
|
||||
draft.append(&path, value).unwrap();
|
||||
let item = format!("{list}/0");
|
||||
choose_history_constructor(&mut draft, &item, "Integer");
|
||||
draft.input(&format!("{item}/Integer"), "-".into());
|
||||
choose_history_constructor(&mut draft, &item, "String");
|
||||
draft.input(&format!("{item}/String"), "retained textual branch".into());
|
||||
choose_history_constructor(&mut draft, &item, "Integer");
|
||||
assert_eq!(draft.inputs[&format!("{item}/Integer")].raw, "-");
|
||||
draft.input(&format!("{item}/Integer"), "21".into());
|
||||
choose_history_constructor(&mut draft, &list, "Absent (none)");
|
||||
assert_eq!(draft.value.pointer(&list), Some(&Value::Null));
|
||||
let mut draft = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
choose_history_constructor(&mut draft, &list, "Set list");
|
||||
assert_eq!(draft.inputs[&format!("{item}/Integer")].raw, "21");
|
||||
choose_history_constructor(&mut draft, &item, "String");
|
||||
assert_eq!(
|
||||
draft.inputs[&format!("{item}/String")].raw,
|
||||
"retained textual branch"
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![
|
||||
FacetReview::Header(HeaderReview::Parent(Reviewed {
|
||||
oa: Observed::Value(Some(historical)),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Default(Reviewed {
|
||||
oa: Observed::Value(Some(vec![DataValue::String(
|
||||
"retained textual branch".into(),
|
||||
)])),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user