Integrate nullable stored-history constructor correction

This commit is contained in:
2026-09-10 11:25:59 -07:00
6 changed files with 241 additions and 2 deletions
+3
View File
@@ -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);
}