Merge pull request #12649 from TheMrZZ/fix/recipe-book-display-variants

🤖 Agent: transmute recipes show one recipe book entry per display, pinned by the new recipe_book_variants harness.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-08 23:21:45 +01:00
commit d5504cd328
6 files changed
+566 -10

No files matched your search

+9 -3
View File
@@ -1350,6 +1350,12 @@ pub struct Recipes {
}
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, Vec<usize>>>;
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
/// `TransmuteRecipe`'s `material_count` as the recipe parser reads it: a
/// number is exact, an object's missing ends are 1 and 8, both kept in 1..8.
pub(crate) fn transmute_material_bounds(material_count: Option<&Value>) -> (usize, usize) {
let (min, max) = bounds(material_count, (1, 1), (1, 8));
(min.clamp(1, 8) as usize, max.clamp(1, 8) as usize)
}
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
match v {
Some(Value::Number(n)) => {
@@ -1634,12 +1640,12 @@ impl Recipes {
}),
),
"minecraft:crafting_transmute" => special((|| {
let (min, max) = bounds(v.get("material_count"), (1, 1), (1, 8));
let (min, max) = transmute_material_bounds(v.get("material_count"));
Some(Special::Transmute {
input: ing("input")?,
material: ing("material")?,
min: min.clamp(1, 8) as usize,
max: max.clamp(1, 8) as usize,
min,
max,
add_count: v["add_material_count_to_result"].as_bool().unwrap_or(false),
result: result()?,
})
+2
View File
@@ -19157,6 +19157,8 @@ mod orb_data_tests;
#[cfg(test)]
mod orb_score_store_tests;
#[cfg(test)]
mod recipe_book_variants_tests;
#[cfg(test)]
mod stand_break_loot_tests;
#[cfg(test)]
mod stand_command_hurt_tests;
@@ -0,0 +1,227 @@
//! A recipe with several displays reaches a connected player's recipe book:
//! `ServerRecipeBook.addRecipes` sends a `ClientboundRecipeBookAddPacket.Entry`
//! for each display (`RecipeManager.listDisplaysForRecipe`), so
//! `minecraft:map_cloning`, a transmute recipe with a material count of 1 to 8,
//! sends eight (tools/test_recipe_book_variants_parity.py measures the same on
//! vanilla 26.3 with a real client).
use super::*;
use horizon_client::{
chunk::Chunk,
lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig},
motion::PlayerMotion,
};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
const GUEST: &str = "RecipeGuest";
/// A host on a stone floor with one connected, placed guest (conn 2).
fn game() -> (Game<'static>, mpsc::Receiver<horizon_client::lan::Out>) {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
for x in 0..2 {
game.world
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
let net = NetConfig {
announce: false,
..NetConfig::lan(0, "recipe-variants-test".into(), 2)
};
game.lan_host(
super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
net,
HubConfig {
default_game_mode: 1,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: Dim::Overworld.id().into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 2,
name: GUEST.into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[12.5, 100.0, 10.5],
1,
);
guest.placed = true;
guest.loaded = true;
// Its view, as the client sent it: straight down.
guest.pitch = 90.0;
game.lan.as_mut().unwrap().hub.guests.push(guest);
(game, rx)
}
/// Runs `command`: whether it succeeded and what the issuer was told.
fn run(game: &mut Game<'_>, command: &str) -> (bool, Vec<String>) {
game.chat_capture = Some(Vec::new());
let ok = game.run_command(command);
let said = game.chat_capture.take().unwrap_or_default();
(
ok,
said.iter().map(horizon_client::text::Text::plain).collect(),
)
}
/// The display ids of every recipe book Add the guest was sent.
fn added(rx: &mpsc::Receiver<horizon_client::lan::Out>) -> Vec<Vec<i32>> {
use horizon_protocol::packets::play::Clientbound as C;
let mut result = Vec::new();
for out in rx.try_iter() {
let bodies = match out {
horizon_client::lan::Out::Packet(body) => vec![body],
horizon_client::lan::Out::Packets(bodies) => bodies,
horizon_client::lan::Out::Disconnect(_) => panic!("unexpected disconnect"),
};
for body in bodies {
let mut reader = horizon_protocol::buf::Reader::new(&body);
let id = reader.varint().unwrap();
if let Ok(C::RecipeBookAdd(add)) = C::decode(id, reader.rest()) {
result.push(add.entries.iter().map(|e| e.id).collect());
}
}
}
result
}
fn step(game: &mut Game<'_>, ticks: usize) {
for _ in 0..ticks {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
}
fn grant(
game: &mut Game<'_>,
rx: &mpsc::Receiver<horizon_client::lan::Out>,
recipe: &str,
) -> Vec<Vec<i32>> {
let _ = added(rx);
let (ok, said) = run(game, &format!("recipe give {GUEST} {recipe}"));
assert!(ok, "{said:?}");
// The book's packets go out with a later tick's sync (measured: the next
// one but one); two ticks take them all.
step(game, 2);
added(rx)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_recipe_with_eight_displays_sends_eight_entries() {
let (mut game, rx) = game();
assert!(game.run_command("tick freeze"));
// The join's own book (a new player knows the crafting table) settles on
// these ticks; then the book is emptied.
step(&mut game, 3);
let _ = run(&mut game, &format!("recipe take {GUEST} *"));
step(&mut game, 2);
let _ = added(&rx);
let ordinary = grant(&mut game, &rx, "minecraft:crafting_table");
assert_eq!(
ordinary.iter().map(Vec::len).sum::<usize>(),
1,
"the ordinary recipe: {ordinary:?}"
);
let cloning = grant(&mut game, &rx, "minecraft:map_cloning");
let ids: Vec<i32> = cloning.concat();
assert_eq!(ids.len(), 8, "one entry per display: {cloning:?}");
let mut distinct = ids.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(distinct.len(), 8);
}
// Added after the baseline (the authorized adapter): a recipe with several
// displays still has no ghost (PlaceRecipe is unchanged), and an ordinary
// recipe's ghost is still sent.
fn ghosts(rx: &mpsc::Receiver<horizon_client::lan::Out>) -> usize {
use horizon_protocol::packets::play::Clientbound as C;
let mut n = 0;
for out in rx.try_iter() {
let bodies = match out {
horizon_client::lan::Out::Packet(body) => vec![body],
horizon_client::lan::Out::Packets(bodies) => bodies,
horizon_client::lan::Out::Disconnect(_) => panic!("unexpected disconnect"),
};
for body in bodies {
let mut reader = horizon_protocol::buf::Reader::new(&body);
let id = reader.varint().unwrap();
if let Ok(C::PlaceGhostRecipe(_)) = C::decode(id, reader.rest()) {
n += 1;
}
}
}
n
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_a_multi_display_recipe_still_has_no_ghost() {
let (mut game, rx) = game();
let _ = ghosts(&rx);
let hub = &mut game.lan.as_mut().unwrap().hub;
hub.send_ghost_recipe(2, 0, "minecraft:map_cloning");
assert_eq!(ghosts(&rx), 0, "no ghost for eight displays");
let hub = &mut game.lan.as_mut().unwrap().hub;
hub.send_ghost_recipe(2, 0, "minecraft:crafting_table");
assert_eq!(ghosts(&rx), 1, "the ordinary recipe's ghost");
}
+133 -7
View File
@@ -379,6 +379,11 @@ impl ServerAdvancements {
id: &str,
sync: &SyncData,
) -> Option<horizon_protocol::packets::recipes::RecipeDisplay> {
// A recipe with several displays has no ghost here (PlaceRecipe is
// not this change's): only a single-display recipe's entry is used.
if self.displays.display_ids(id).len() != 1 {
return None;
}
let bytes = self.book_entries(id, sync, 0).into_iter().next()?;
let mut r = horizon_protocol::buf::Reader::new(&bytes);
horizon_protocol::packets::recipes::RecipeBookEntry::read(&mut r)
@@ -390,10 +395,6 @@ impl ServerAdvancements {
/// highlighted, and with a notification unless the recipe turns it off.
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
let ids = self.displays.display_ids(id);
if ids.len() != 1 {
// Special recipes have no display; transmutations aren't unlocked on joining.
return Vec::new();
}
let Some(r) = self
.recipes
.get(id)
@@ -402,6 +403,15 @@ impl ServerAdvancements {
return Vec::new();
};
let r = &r;
if r["type"].as_str() == Some("minecraft:crafting_transmute") {
return self
.transmute_entries(r, ids, sync, flags)
.unwrap_or_default();
}
if ids.len() != 1 {
// Special recipes have no display.
return Vec::new();
}
let kind = r["type"].as_str().unwrap_or("");
let item = |n: &str| sync.id("minecraft:item", n);
let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0);
@@ -496,7 +506,22 @@ impl ServerAdvancements {
}
_ => return Vec::new(),
};
// The group, the recipe book category and the placement's ingredients.
self.entry_tail(&mut w, r, sync, category, &placed, flags);
vec![w.data]
}
/// An entry's end: the group, the recipe book category, the placement's
/// ingredients and the flags.
fn entry_tail(
&self,
w: &mut Writer,
r: &Value,
sync: &SyncData,
category: &str,
placed: &[&Value],
flags: i8,
) {
let item = |n: &str| sync.id("minecraft:item", n);
match r["group"].as_str().and_then(|g| self.groups.get(g)) {
Some(g) => w.varint(g + 1),
None => w.varint(0),
@@ -509,12 +534,113 @@ impl ServerAdvancements {
w.bool(true);
w.varint(placed.len() as i32);
for v in placed {
holder_set(&mut w, v, &item);
holder_set(w, v, &item);
}
// The notification only where the recipe shows one.
let notify = r["show_notification"].as_bool().unwrap_or(true);
w.i8(if notify { flags } else { flags & !1 });
vec![w.data]
}
/// `TransmuteRecipe.display`: one `ShapelessCraftingRecipeDisplay` per
/// material count from the recipe's minimum to its maximum, the input
/// then that many materials, its `resultDisplay`, the crafting table; the
/// placement (`placementInfo`) is the input and the most materials. All
/// or nothing: a form this cannot write, or a count that is not the
/// recipe's display ids, gives no entry at all.
fn transmute_entries(
&self,
r: &Value,
ids: std::ops::Range<i32>,
sync: &SyncData,
flags: i8,
) -> Option<Vec<Vec<u8>>> {
let (min, max) = crate::crafting::transmute_material_bounds(r.get("material_count"));
if (max + 1).checked_sub(min)? != ids.len() {
return None;
}
let item = |n: &str| sync.id("minecraft:item", n);
let slot = |n: &str| sync.id("minecraft:slot_display", n);
let (input, material) = (&r["input"], &r["material"]);
let holder = |v: &Value| matches!(v, Value::String(_));
if !holder(input) || !holder(material) {
return None;
}
// TransmuteResult: an item (or none: the input's own items), a count
// (1) and components, which this does not write.
let result = &r["result"];
if result
.get("components")
.is_some_and(|c| !c.as_object().is_some_and(|o| o.is_empty()))
{
return None;
}
let base = result["count"].as_i64().unwrap_or(1) as i32;
let add = r["add_material_count_to_result"].as_bool().unwrap_or(false);
let result_items: Vec<i32> = match result["id"].as_str() {
Some(id) => vec![item(id)?],
// Composite over the input's items, in the order clients get the tag.
None => match input.as_str()? {
tag if tag.starts_with('#') => {
let name = &tag[1..];
sync.tags
.registries
.iter()
.find(|t| t.registry == "minecraft:item")?
.tags
.iter()
.find(|t| t.name == name)?
.ids
.clone()
}
one => vec![item(one)?],
},
};
let (shapeless, tag, empty_stack) = (
sync.id("minecraft:recipe_display", "minecraft:crafting_shapeless")?,
slot("minecraft:tag")?,
slot("minecraft:item_stack")?,
);
let (composite, item_slot) = (slot("minecraft:composite")?, slot("minecraft:item")?);
let table = item("minecraft:crafting_table")?;
let category = crafting_category(r["category"].as_str().unwrap_or("")).name();
let placed: Vec<&Value> = std::iter::once(input)
.chain(std::iter::repeat_n(material, max))
.collect();
let mut entries = Vec::with_capacity(ids.len());
for (n, id) in (min..=max).zip(ids) {
let mut w = Writer::new();
w.varint(id);
w.varint(shapeless);
w.varint(n as i32 + 1);
for v in std::iter::once(input).chain(std::iter::repeat_n(material, n)) {
w.varint(tag);
holder_set(&mut w, v, &item);
}
// computeResultSize: the materials added to the count, or not.
let count = if add { n as i32 + base } else { base };
let stack = |w: &mut Writer, id: i32| {
w.varint(empty_stack);
w.varint(id);
w.varint(count);
w.varint(0);
w.varint(0);
};
match result["id"].as_str() {
Some(_) => stack(&mut w, result_items[0]),
None => {
w.varint(composite);
w.varint(result_items.len() as i32);
for &id in &result_items {
stack(&mut w, id);
}
}
}
w.varint(item_slot);
w.varint(table);
self.entry_tail(&mut w, r, sync, category, &placed, flags);
entries.push(w.data);
}
Some(entries)
}
}
@@ -710,9 +710,21 @@ public final class LocatorBarClient {
value.addProperty("flags", entry.flags());
value.add("display", net.minecraft.world.item.crafting.display.RecipeDisplay.CODEC.encodeStart(
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), entry.contents().display()).getOrThrow());
var contents = entry.contents();
if (contents.group().isPresent()) value.addProperty("group", contents.group().getAsInt());
value.addProperty("category", net.minecraft.core.registries.BuiltInRegistries.RECIPE_BOOK_CATEGORY
.getKey(contents.category()).toString());
contents.craftingRequirements().ifPresent(list -> value.add("requirements",
net.minecraft.world.item.crafting.Ingredient.CODEC.listOf().encodeStart(
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), list).getOrThrow()));
entries.add(value);
}
j.add("entries", entries);
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundRecipeBookRemovePacket c) {
j.addProperty("kind", "recipes-removed");
var ids = new com.google.gson.JsonArray();
for (var id : c.recipes()) ids.add(id.index());
j.add("ids", ids);
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundPlaceGhostRecipePacket c) {
j.addProperty("kind", "ghost");
j.addProperty("window", c.containerId());
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""A multi-display recipe reaches a real client's recipe book (server-recipe-book-display-variants).
`ServerRecipeBook.addRecipes` sends one `ClientboundRecipeBookAddPacket.Entry` per display a recipe has
(`RecipeManager.listDisplaysForRecipe`), and `removeRecipes` a `ClientboundRecipeBookRemovePacket` of all of
them. `minecraft:map_cloning` is a `crafting_transmute` recipe whose `material_count` runs from 1 to 8:
`TransmuteRecipe.display()` gives it eight shapeless displays.
A real network player (LocatorBarClient) joins a dedicated server (vanilla, then Horizon) with the
menu recorder on: every recipe book Add and Remove packet is kept with its raw payload and its fields as
vanilla's own codec decodes them (the client refuses a payload with bytes left over). With the recipe book
emptied, the player is given `minecraft:crafting_table` (an ordinary shaped recipe, the positive), then
`minecraft:map_cloning`, then both are taken back, map cloning first. After each step the player's
`recipeBook` is read through command storage.
Primary slice, declared before any fixed capture: `target-` keys are the map cloning grant's Add entries
and its Remove ids. `diag-` keys are recorded diagnostics (the join packets, absolute ids). Every other key is
a setup admission or a control.
"""
import argparse
import hashlib
import json
from pathlib import Path
import zipfile
import anvil_dump as nbt
import test_locator_bar_parity as net
from test_disconnect_crafting_parity import generate, wait_new
PLAYER = 'Bob'
STEPS = [
('take-all', 'recipe take Bob *'),
('give-ordinary', 'recipe give Bob minecraft:crafting_table'),
('give-transmute', 'recipe give Bob minecraft:map_cloning'),
('take-transmute', 'recipe take Bob minecraft:map_cloning'),
('take-ordinary', 'recipe take Bob minecraft:crafting_table'),
]
TARGET = {'give-transmute', 'take-transmute'}
def run(label, command, client_command, build, world):
directory = build / label
port = net.server_dir(directory, world)
server = net.Server(command, directory, label)
wire = build / f'{label}-wire.txt'
log = (build / f'{label}-client.log').open('w')
client = None
try:
server.wait_for(r'Done \(')
client = net.Client(client_command, port, wire, log)
client.tell('chat on')
client.tell('flat-chat on')
client.tell('menus on')
client.tell('step join')
client.tell(f'join {PLAYER}')
client.sync([PLAYER])
def cmd(step, text):
client.tell('step ' + step)
client.tell(f'send-cmd {PLAYER} {text}')
client.sync([PLAYER])
cmd('admit', 'gamemode survival Bob')
cmd('admit', 'data modify storage recipes:proof player set from entity Bob')
for step, text in STEPS:
cmd(step, text)
cmd(step + '-book', f'data modify storage recipes:proof {step.replace("-", "_")} set from entity Bob recipeBook')
client.tell('step save')
client.sync([PLAYER])
start = len(server.lines)
server.console('save-all flush')
wait_new(server, start, 'Saved the game')
client.end()
finally:
if client:
client.kill()
server.stop()
log.close()
storage = [p for p in (directory / 'world').rglob('*.dat')
if 'command_storage' in p.name and ('recipes' in p.parts or 'recipes' in p.name)]
assert len(storage) == 1, storage
proof = json.loads(json.dumps(nbt.read_dat(storage[0])))['data']['contents']['proof']
steps = {}
for line in wire.read_text().splitlines():
if not line.startswith(PLAYER + ' step='):
continue
prefix, rest = line.split(' step=', 1)
step, body = rest.split(' ', 1)
record = steps.setdefault(step, {'packets': [], 'chat': []})
if body.startswith('menu-wire '):
p = json.loads(body[len('menu-wire '):])
if p.get('kind') in ('recipes', 'recipes-removed'):
record['packets'].append(p)
elif body.startswith('chat-flat '):
record['chat'].append(json.loads(body[len('chat-flat '):]))
return proof, steps
def facts(proof, steps):
out = {}
player = proof['player']
for key in ('UUID', 'Health', 'playerGameType'):
out[f'admit/player/{key}'] = player.get(key)
out['admit/initial-book/recipes'] = sorted(player.get('recipeBook', {}).get('recipes', []))
out['diag-join/packets'] = steps.get('join', {}).get('packets')
for step, _ in STEPS:
record = steps.get(step, {'packets': [], 'chat': []})
# An action's packets: its own step and its book read after it (a
# server may send the book's packets after the command's reply).
later = steps.get(step + '-book', {'packets': []})['packets']
packets = record['packets'] + later
prefix = 'target-' if step in TARGET else ''
out[f'{step}/chat'] = record['chat']
adds = [p for p in packets if p['kind'] == 'recipes']
removes = [p for p in packets if p['kind'] == 'recipes-removed']
out[f'diag-{step}/after-reply'] = [p['kind'] for p in later]
out[f'{prefix}{step}/add/count'] = [len(p['entries']) for p in adds]
out[f'{prefix}{step}/add/replace'] = [p['replace'] for p in adds]
out[f'{prefix}{step}/add/entries'] = [
{k: v for k, v in e.items() if k != 'id'} for p in adds for e in p['entries']]
out[f'{prefix}{step}/add/ids'] = [e['id'] for p in adds for e in p['entries']]
out[f'{prefix}{step}/remove/ids'] = [i for p in removes for i in p['ids']]
out[f'diag-{step}/raw'] = [p['wire'] for p in packets]
book = proof.get(step.replace('-', '_'), {})
# ServerRecipeBook keeps sets: their saved order is not the book's.
out[f'{step}/book/recipes'] = sorted(book.get('recipes', []))
out[f'{step}/book/toBeDisplayed'] = sorted(book.get('toBeDisplayed', []))
# The ids the transmute grant added are the ids its removal takes back.
give = out['target-give-transmute/add/ids']
out['target-take-transmute/remove-matches-add'] = sorted(give) == sorted(out['target-take-transmute/remove/ids'])
return out
def recipe_bytes(jar, resources):
with zipfile.ZipFile(jar) as archive:
java = archive.read('data/minecraft/recipe/map_cloning.json')
native = (resources / 'data/minecraft/recipe/map_cloning.json').read_bytes()
return {'java': hashlib.sha256(java).hexdigest(), 'native': hashlib.sha256(native).hexdigest()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--side', choices=['java', 'native', 'both'], default='both')
parser.add_argument('--label')
parser.add_argument('--binary', type=Path)
parser.add_argument('--reference', type=Path, help='a Java facts file to compare with')
args = parser.parse_args()
net.PLAYERS = [PLAYER]
net.PROPERTIES['force-gamemode'] = 'false'
lock, build, java, native, client = net.sides('recipe-book-variants-parity',
args.binary.resolve() if args.binary else None)
import test_rust_parity as base
_, jars, _ = base.verify_artifacts()
admitted = recipe_bytes(jars['server'], net.lab.LOCAL / 'horizon-client/26.3')
(build / 'recipe-bytes.json').write_text(json.dumps(admitted, indent=2) + '\n')
assert admitted['java'] == admitted['native'], admitted
world = build / 'generate/world'
if not world.exists():
world = generate(java, build)
sides = ['java', 'native'] if args.side == 'both' else [args.side]
reference = json.loads(args.reference.read_text()) if args.reference else None
for side in sides:
label = args.label or side
proof, steps = run(label, java if side == 'java' else native, client, build, world)
actual = facts(proof, steps)
(build / f'{label}-facts.json').write_text(json.dumps(actual, indent=2, sort_keys=True) + '\n')
if reference is None:
reference = actual
print(label, len(actual), 'facts captured')
continue
assert actual.keys() == reference.keys(), 'canonical keys changed'
all_differences = [{'label': k, 'expected': reference[k], 'actual': actual[k]}
for k in reference if reference[k] != actual[k]]
differences = [d for d in all_differences if not d['label'].startswith('diag-')]
strict = [k for k in reference if not k.startswith('diag-')]
report = {'records': len(strict), 'passed': len(strict) - len(differences), 'differences': differences,
'diagnostic_differences': [d['label'] for d in all_differences if d['label'].startswith('diag-')]}
(build / f'{label}-report.json').write_text(json.dumps(report, indent=2) + '\n')
print(label, report['passed'], '/', report['records'])
if __name__ == '__main__':
main()