diff --git a/scripts/assign_filament_ids.py b/scripts/assign_filament_ids.py index 46a138253c..cc3adeab4a 100755 --- a/scripts/assign_filament_ids.py +++ b/scripts/assign_filament_ids.py @@ -24,7 +24,10 @@ Policy (companion to assign_vendor_setting_ids.py; see docs/HLSD/filament_id.md) Identity changes (a family rename, a filament_vendor/filament_type fix) change the id BY DESIGN. * Reserved id spaces that are never minted into or altered: - - GF* Bambu AMS/RFID catalog (vendor BBL untouchable) + - GF* Bambu AMS/RFID catalog: frozen, no preset of any + vendor (including BBL) may declare one; the + generated resources/printers/bambu_filament_ids.json + carries the correspondence instead - QD_* Qidi device protocol: the box composes these ids at runtime, they are not preset ids, and no preset may declare one @@ -43,8 +46,9 @@ OFL it stays in OFL; a vendor chain that dead-ends id-less retries its direct parent in the OFL map. filament_vendor / filament_type resolve the same way. Run from anywhere: python3 scripts/assign_filament_ids.py - (default) mint + insert ids for id-less families; idempotent, never - rewrites a valid existing id; a no-op on a fully-idded tree + (default) mint + insert ids for id-less families, mint + replace + non-OF-format declarations; idempotent, never rewrites a + valid OF-format id; a no-op once every family has one --mint "Vendor/Type/Family" print the id that triple would mint; touches nothing --update-snapshot regenerate the snapshot from the tree @@ -75,6 +79,10 @@ FILAMENT_ID_LENGTH = 6 # base62 digits after the "OF" prefix -> 8 chars total SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) PROFILES_DIR = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "resources", "profiles")) SNAPSHOT_PATH = os.path.join(SCRIPTS_DIR, "filament_id_snapshot.json") +# Same path update_bambu_filament_ids.BAMBU_MAP_PATH computes; kept as a sibling +# constant (not imported) because that module imports FROM this one already. +BAMBU_MAP_PATH = os.path.normpath( + os.path.join(SCRIPTS_DIR, "..", "resources", "printers", "bambu_filament_ids.json")) OFL = "OrcaFilamentLibrary" @@ -89,6 +97,7 @@ BASE_NAME_RE = re.compile(r"\s?@.*$") MAX_CHECK_SALT = 8 UPDATE_HINT = 'run "python scripts/assign_filament_ids.py --update-snapshot" and commit the diff for maintainer review' +BAMBU_MAP_HINT = 'regenerate the map with "python scripts/update_bambu_filament_ids.py" and commit the diff for maintainer review' # Same output helpers/format as orca_extra_profile_check.py (not imported from @@ -304,12 +313,6 @@ def resolve_triple(name, filaments, ofl_filaments): base_name(name)) -def is_island_declaration(vendor, fid): - """Declarations frozen outside the OF mint domain: all of BBL (the QD_* - island was dissolved by plan v4 — Qidi declarations mint like any other).""" - return vendor == "BBL" - - def analyze_tree(profiles_dir): """Load every vendor bundle and derive the full filament_id state. @@ -351,8 +354,8 @@ def analyze_tree(profiles_dir): overrides = [] # (vendor, name, declared, inherited, file) missing_effective = [] # (vendor, name, file) instantiated presets resolving no id alias_candidates = [] # (vendor, rec, ofl_entry, own_key) presets riding an OFL family - triples = {} # fid -> set of triples of its non-island declarers - declarer_triples = [] # (vendor, rec, fid, triple) per non-island declarer + triples = {} # fid -> set of triples of its declarers + declarer_triples = [] # (vendor, rec, fid, triple) per declarer family_triples = {} # (vendor, family) -> {triple: [declarer names]} for vendor, filaments in vendors.items(): @@ -363,14 +366,13 @@ def analyze_tree(profiles_dir): occurring.add(fid) declared_ids.setdefault(vendor, set()).add(fid) ids.setdefault(fid, set()) - if not is_island_declaration(vendor, fid): - triple = resolve_triple(rec["name"], filaments, ofl_filaments) - rec["triple"] = triple - declarer_triples.append((vendor, rec, fid, triple)) - triples.setdefault(fid, set()).add(triple) - family_triples.setdefault( - (vendor, base_name(rec["name"])), {}).setdefault( - triple, []).append(rec["name"]) + triple = resolve_triple(rec["name"], filaments, ofl_filaments) + rec["triple"] = triple + declarer_triples.append((vendor, rec, fid, triple)) + triples.setdefault(fid, set()).add(triple) + family_triples.setdefault( + (vendor, base_name(rec["name"])), {}).setdefault( + triple, []).append(rec["name"]) if rec.get("inherits"): inherited, _src, skip_entry = resolve_filament_id( rec["name"], filaments, ofl_filaments, skip_own=True) @@ -495,7 +497,7 @@ def write_snapshot(path, obj): def reserved_space_owner(fid): """(is_reserved, owner_vendor or None) for the frozen id spaces.""" if fid.startswith("GF"): - return True, "BBL" + return True, None # Bambu AMS/RFID catalog: frozen, no vendor (not even BBL) may declare it if fid.startswith("QD_"): return True, None # dissolved Qidi device-protocol space: NO vendor may declare it if USER_CUSTOM_ID_RE.match(fid) or fid == "null": @@ -507,6 +509,8 @@ def reserved_space_desc(fid, owner): """Human description of a reserved space for error messages.""" if owner: return f"owned by {owner}" + if fid.startswith("GF"): + return "Bambu AMS/RFID catalog; frozen, no preset may declare it" if fid.startswith("QD_"): return "Qidi device protocol; composed by the device, never a preset id" return "reserved for user-custom presets" @@ -516,30 +520,35 @@ def reserved_space_desc(fid, owner): # Checks (imported and called tree-wide by orca_extra_profile_check.py) # --------------------------------------------------------------------------- -def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): +def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH, + map_path=BAMBU_MAP_PATH): """Validate filament_id state across every vendor. Returns the error count. - 1. Format: every id occurring in the tree (declared or effective) must be in - the snapshot, or match ^OF[0-9A-Za-z]{6}$, or belong to vendor BBL. + 1. Format: every id occurring in the tree (declared or effective) must + match ^OF[0-9A-Za-z]{6}$. No exceptions: not the snapshot, not BBL. 2. Snapshot equality, both directions: the tree-derived id->families multimap AND the id->triples map of the declarers must equal the snapshot exactly (the snapshot diff is the maintainer gate). - 3. Mint conformance: a non-island OF-format declaration must equal the mint - of the declarer's triple or a salted iteration, unless that exact - (id, triple) pair is grandfathered in the snapshot. + 3. Mint conformance: an OF-format declaration must equal the mint of the + declarer's triple or a salted iteration, unless that exact (id, triple) + pair is grandfathered in the snapshot. 4. Alias hygiene: a vendor preset riding an OFL family must keep the OFL base name, claim printers via non-empty compatible_printers, and declare no filament_id key of its own. - 5. Reserved namespaces (GF* for BBL; QD_*/P-hex/"null" for nobody) must not - be claimed by other vendors, except claims grandfathered in the snapshot. + 5. Reserved namespaces (GF*/QD_*/P-hex/"null", all ownerless) must not be + claimed by any vendor, except claims grandfathered in the snapshot. 6. Structure ratchet: (a) no NEW instantiated preset carries its own filament_id key; (b) no NEW declared-vs-inherited id drift; (c) every instantiated filament resolves an effective id (a hard load error in C++). - 7. Triple integrity: (a) every non-island declarer resolves non-empty - filament_vendor and filament_type (hard error, no grandfathering); - (b) declarers of one (bundle, family) resolve identical triples, unless - grandfathered in snapshot triple_exceptions; cross-bundle divergence on - the same family name is a warning only. + 7. Triple integrity: (a) every declarer resolves non-empty filament_vendor + and filament_type (hard error, no grandfathering); (b) declarers of one + (bundle, family) resolve identical triples, unless grandfathered in + snapshot triple_exceptions; cross-bundle divergence on the same family + name is a warning only. + 8. Bambu catalog map: resources/printers/bambu_filament_ids.json must parse, + carry source/bambustudio_commit/generated, key only OF-format ids, map + each Bambu id at most once, and for every row whose key the tree claims, + the tree's triple for that id must equal the row's (vendor, type, name). """ _utf8_console() errors = 0 @@ -558,14 +567,12 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): # -- 1. format ---------------------------------------------------------- for vendor in sorted(analysis["vendor_ids"]): for fid in sorted(analysis["vendor_ids"][vendor]): - if fid in snap_ids or OF_ID_RE.match(fid): - continue - if vendor == "BBL": + if OF_ID_RE.match(fid): continue print_error( - f'filament_id "{fid}" ({vendor}) is neither grandfathered in the ' - f'snapshot nor a minted "OF" id; new family ids must come from ' - f'"python scripts/assign_filament_ids.py" (see --mint)') + f'filament_id "{fid}" ({vendor}) is not a minted "OF" id; new ' + f'family ids must come from "python scripts/assign_filament_ids.py" ' + f'(see --mint)') errors += 1 # -- 2. snapshot equality (both directions) ----------------------------- @@ -719,6 +726,43 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): f"({detail}); bundles of one product converge on one id only once " f"their triples agree") + # -- 8. Bambu catalog map -------------------------------------------------- + try: + bambu_map = load_json(map_path) + except (OSError, ValueError) as e: + print_error(f"Bambu catalog map {map_path} does not parse ({e}); {BAMBU_MAP_HINT}") + errors += 1 + else: + for key in ("source", "bambustudio_commit", "generated"): + if not bambu_map.get(key): + print_error(f'Bambu catalog map {map_path} is missing "{key}"; {BAMBU_MAP_HINT}') + errors += 1 + rows = bambu_map.get("filaments", {}) + bambu_id_owners = {} + for fid, row in sorted(rows.items()): + if not OF_ID_RE.match(fid): + print_error(f'Bambu catalog map key "{fid}" is not a minted "OF" id; ' + f"{BAMBU_MAP_HINT}") + errors += 1 + bambu_id = row.get("bambu_id") + if bambu_id in bambu_id_owners: + print_error( + f'Bambu catalog map: Bambu id "{bambu_id}" is mapped by both ' + f'"{bambu_id_owners[bambu_id]}" and "{fid}"; {BAMBU_MAP_HINT}') + errors += 1 + else: + bambu_id_owners[bambu_id] = fid + claimed = tree_triples.get(fid) + if not claimed: + continue # a product BambuStudio ships that the tree does not (yet) + row_triple = [row.get("vendor", ""), row.get("type", ""), row.get("name", "")] + if row_triple not in claimed: + print_error( + f'Bambu catalog map row "{fid}" claims triple "{"/".join(row_triple)}" ' + f'but the tree declares "{"; ".join("/".join(t) for t in claimed)}" for ' + f"that id; {BAMBU_MAP_HINT}") + errors += 1 + return errors @@ -897,13 +941,25 @@ def remove_filament_id(path, old_id): # --------------------------------------------------------------------------- def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): - """Mint + insert ids for id-less families. Never rewrites a valid existing id. + """Mint + insert ids for id-less families; mint + replace non-OF-format + declarations. Never rewrites a valid (OF-format) existing id. A family = (vendor, base name) group over instantiated filaments with no effective id. The minted id is a pure function of the family's triple — (filament_vendor, filament_type) resolved on the root(s), family name — and is inserted into the family's root(s): the presets its members inherit that carry no id, or the member itself when it has no vendor-side parent. + + A declaration whose value is not OF-format (e.g. a vendor bundle synced + from an upstream source that ships its own catalog ids, such as BBL's GF*) + is treated the same as a missing family: a fresh id is minted for the + declarer's triple and the value is replaced in place (declarers that share + one triple across several per-printer roots converge on the same id, same + as the multi-root families above). This is what makes a future BBL sync + self-healing: upstream files arrive with GF ids, this pass replaces them, + and the generated Bambu catalog map (keyed by the ids this mints) already + knows the resulting rows. + Returns (files_changed, errors). """ analysis = analyze_tree(profiles_dir) @@ -923,9 +979,17 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): continue families.setdefault((vendor, base_name(name)), []).append(rec) - if not families: - print_success("every instantiated filament already resolves a filament_id; " - "nothing to do (0 files changed)") + # Declarations whose value is not OF-format: treated as missing too (see + # docstring). Grouped by triple, not by (vendor, family), so declarers + # that legitimately share one triple across several files converge on one + # freshly minted id instead of each getting their own. + non_of_declarers = [ + (vendor, rec, fid, triple) for vendor, rec, fid, triple in analysis["declarer_triples"] + if not OF_ID_RE.match(fid)] + + if not families and not non_of_declarers: + print_success("every instantiated filament already resolves an OF-format " + "filament_id; nothing to do (0 files changed)") return 0, errors # Ids already spoken for: the whole tree (declared or effective) + snapshot. @@ -998,7 +1062,34 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): files_changed += 1 print_info(f'family "{vendor}/{family}": filament_id "{new_id}" -> {root["file"]}') + # Non-OF-format declarations: mint once per triple, rewrite every declarer + # that shares it (see docstring). + declarations_reminted = 0 + assigned = {} # triple -> id chosen this run + for vendor, rec, fid, triple in sorted(non_of_declarers, key=lambda x: (x[0], x[1]["file"])): + fvendor, ftype, family = triple + if not fvendor or not ftype: + missing = " and ".join( + k for k, v in (("filament_vendor", fvendor), + ("filament_type", ftype)) if not v) + print_error( + f'cannot re-mint "{rec["file"]}" (non-OF filament_id "{fid}"): resolves ' + f'empty {missing}; the mint key needs both (generic materials use ' + f'filament_vendor "Generic")') + errors += 1 + continue + if triple not in assigned: + assigned[triple] = mint_filament_id(fvendor, ftype, family, taken) + taken.add(assigned[triple]) + new_id = assigned[triple] + rewrite_filament_id(rec["path"], fid, new_id) + files_changed += 1 + declarations_reminted += 1 + print_info(f'family "{vendor}/{family}": non-OF filament_id "{fid}" -> "{new_id}" ' + f'({rec["file"]})') + print_info(f"families minted : {families_minted}") + print_info(f"non-OF reminted : {declarations_reminted}") print_info(f"files changed : {files_changed}") if files_changed: print_warning(f"now {UPDATE_HINT}") @@ -1010,9 +1101,12 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): # --------------------------------------------------------------------------- def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): - """Re-derive every non-island declared id in the given vendors from its - triple; rewrite mismatching declarations in place (byte-preserving). Never - touches the snapshot — run --update-snapshot afterwards and review the diff. + """Re-derive every declared id in the given vendors from its triple; + rewrite mismatching declarations in place (byte-preserving). Never touches + the snapshot — run --update-snapshot afterwards and review the diff. + Accepts BBL like any other vendor: the GF* catalog is reserved and + ownerless, so BBL's own declarations mint OF ids the same as everyone + else's. A declaration already equal to ANY salt iteration of its own triple is mint-conformant (check 3) and left alone — deliberate salt splits (distinct @@ -1028,9 +1122,6 @@ def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): for msg in analysis["read_errors"]: print_error(msg) errors += 1 - if "BBL" in vendor_list: - print_error("--remint BBL is forbidden (the GF* catalog is frozen)") - return 0, errors + 1 unknown = sorted(set(vendor_list) - set(analysis["vendors"])) if unknown: for v in unknown: @@ -1067,8 +1158,6 @@ def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): key=lambda r: r["file"]) for rec in recs: fid = rec["filament_id"] - if is_island_declaration(vendor, fid): - continue scanned += 1 # Any salt iteration of the declarer's own triple is already # mint-conformant (check 3) — leave it. This keeps deliberate salt @@ -1117,7 +1206,7 @@ def drop_redundant_ids(vendor, profiles_dir=PROFILES_DIR): dropped = 0 for rec in sorted(vendor_map.values(), key=lambda r: r["file"]): fid = rec.get("filament_id") - if not fid or is_island_declaration(vendor, fid) or not rec.get("inherits"): + if not fid or not rec.get("inherits"): continue resolved, _src, entry = resolve_filament_id( rec["name"], vendor_map, ofl_map, skip_own=True) diff --git a/scripts/orca_extra_profile_check.py b/scripts/orca_extra_profile_check.py index 0bca29a985..62bd8c2916 100644 --- a/scripts/orca_extra_profile_check.py +++ b/scripts/orca_extra_profile_check.py @@ -290,19 +290,14 @@ def check_name_consistency(profiles_dir, vendor_name): return error_count, 0 -def check_filament_id(vendor, vendor_folder): +def check_filament_id(vendor_folder): """ - Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly + Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly. - NOTE: superseded by check_filament_ids (assign_filament_ids.py) for non-BBL/OFL - vendors, which validates format/uniqueness/structure tree-wide against the - grandfather snapshot. This length check stays scoped to BBL/OFL because other - vendors ship grandfathered >8-char ids (e.g. Prusa's 36-char name-ids), so it - cannot simply go tree-wide. + Runs tree-wide, every vendor alike: check_filament_ids (assign_filament_ids.py) + already requires every id in the tree to match the fixed-length "OF" format, + so this is a redundant belt-and-suspenders check, not a substitute for it. """ - if vendor not in ('BBL', 'OrcaFilamentLibrary'): - return 0 - error = 0 vendor_path = Path(vendor_folder) if not vendor_path.exists(): @@ -602,7 +597,7 @@ def main(): errors_found += check_vector_type_keys(profiles_dir, vendor_name) - errors_found += check_filament_id(vendor_name, vendor_path / "filament") + errors_found += check_filament_id(vendor_path / "filament") checked_vendor_count += 1 if args.vendor: diff --git a/scripts/tests/test_filament_id.py b/scripts/tests/test_filament_id.py index 2ff0e42a7c..3d668d4fc3 100644 --- a/scripts/tests/test_filament_id.py +++ b/scripts/tests/test_filament_id.py @@ -118,10 +118,11 @@ class SyntheticTree: allow_shared_catalog=allow_shared_catalog) return rc, buf.getvalue() - def check(self): + def check(self, map_path=None): buf = io.StringIO() + kwargs = {} if map_path is None else {"map_path": map_path} with contextlib.redirect_stdout(buf): - errors = afi.check_filament_ids(self.profiles, self.snapshot) + errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs) return errors, buf.getvalue() def assign(self): @@ -142,17 +143,24 @@ class SyntheticTree: dropped, errors = afi.drop_redundant_ids(vendor, self.profiles) return dropped, errors, buf.getvalue() -def make_clean_tree(): - """Baseline tree: OFL base+generic, a vendor family, a clean tuned generic.""" +def make_clean_tree(apla_id="AX01", generic_id="OGFL99"): + """Baseline tree: OFL base+generic, a vendor family, a clean tuned generic. + + apla_id/generic_id default to arbitrary non-OF placeholders (grandfathered + into the snapshot below) since most tests only need "already assigned, + don't touch". TestAssign passes real OF-format ids instead: assign_missing_ids + now treats a non-OF declaration as missing and remints it, so a non-OF + baseline would no longer be a no-op there. + """ t = SyntheticTree() t.add_vendor(OFL, [ - preset("fdm_pla", filament_id="OGFL99", instantiation=False, + preset("fdm_pla", filament_id=generic_id, instantiation=False, filament_vendor="Generic", filament_type="PLA"), preset("Generic PLA @System", inherits="fdm_pla", compatible_printers=[]), ]) t.add_vendor("VendorA", [ - preset("APLA @base", filament_id="AX01", instantiation=False, + preset("APLA @base", filament_id=apla_id, instantiation=False, filament_vendor="AVendor", filament_type="PLA"), preset("APLA @P1", inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), @@ -171,6 +179,21 @@ class SyntheticTreeCase(unittest.TestCase): self.addCleanup(self.t.cleanup) +class OfCleanTreeCase(unittest.TestCase): + """Like SyntheticTreeCase, but the baseline family/generic already carry + real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally, + with no snapshot exemption), so an otherwise-untouched tree still passes + check_filament_ids. Tests that specifically need a non-OF baseline to + remint or drop (TestRemint, TestDropRedundantIds, TestUpdateSnapshot) keep + using SyntheticTreeCase instead. + """ + def setUp(self): + self.t = make_clean_tree( + apla_id=afi.generate_filament_id("AVendor", "PLA", "APLA"), + generic_id=afi.generate_filament_id("Generic", "PLA", "fdm_pla")) + self.addCleanup(self.t.cleanup) + + # --------------------------------------------------------------------------- # mint # --------------------------------------------------------------------------- @@ -376,12 +399,13 @@ class TestTripleResolution(unittest.TestCase): # --------------------------------------------------------------------------- -# reserved namespaces / islands +# reserved namespaces # --------------------------------------------------------------------------- class TestReservedSpaces(unittest.TestCase): def test_owners(self): - self.assertEqual(afi.reserved_space_owner("GFL99"), (True, "BBL")) + # Bambu AMS/RFID catalog: reserved, but no vendor (not even BBL) may declare it + self.assertEqual(afi.reserved_space_owner("GFL99"), (True, None)) # Qidi device protocol: reserved, but no vendor may declare it self.assertEqual(afi.reserved_space_owner("QD_X4_PLA"), (True, None)) self.assertEqual(afi.reserved_space_owner("P1234abc"), (True, None)) @@ -390,20 +414,15 @@ class TestReservedSpaces(unittest.TestCase): self.assertEqual(afi.reserved_space_owner("OF5CgdDq"), (False, None)) self.assertEqual(afi.reserved_space_owner("P1234abcd"), (False, None)) # 8 hex chars: not the user space - def test_island_declarations(self): - self.assertTrue(afi.is_island_declaration("BBL", "GFL99")) - self.assertTrue(afi.is_island_declaration("BBL", "OF5CgdDq")) - # Qidi presets mint like any other vendor's: - self.assertFalse(afi.is_island_declaration("Qidi", "QD_X4_PLA")) - self.assertFalse(afi.is_island_declaration("Qidi", "OF5CgdDq")) - self.assertFalse(afi.is_island_declaration("VendorA", "GFL99")) + def test_gf_is_reserved_and_ownerless(self): + self.assertEqual(afi.reserved_space_owner("GFA00"), (True, None)) # --------------------------------------------------------------------------- # checks on synthetic trees # --------------------------------------------------------------------------- -class TestChecks(SyntheticTreeCase): +class TestChecks(OfCleanTreeCase): def test_clean_tree_is_silent(self): errors, out = self.t.check() self.assertEqual(errors, 0, out) @@ -418,7 +437,7 @@ class TestChecks(SyntheticTreeCase): compatible_printers=["P1"])) errors, out = self.t.check() self.assertGreater(errors, 0) - self.assertIn("neither grandfathered in the snapshot", out) + self.assertIn('is not a minted "OF" id', out) self.assertIn("BOGUS_9", out) def test_check2_new_claim_needs_snapshot_update(self): @@ -437,7 +456,8 @@ class TestChecks(SyntheticTreeCase): self.assertIn('"VendorA/APLA"', out) def test_check2_triple_change_needs_snapshot_update(self): - self.t.write_preset("VendorA", preset("APLA @base", filament_id="AX01", + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + self.t.write_preset("VendorA", preset("APLA @base", filament_id=apla_id, instantiation=False, filament_vendor="AVendor", filament_type="PETG"), @@ -542,7 +562,7 @@ class TestChecks(SyntheticTreeCase): self.assertEqual(errors, 0, out) def test_check5_reserved_namespace_claims(self): - for fid, marker in [("GFX99", "owned by BBL"), + for fid, marker in [("GFX99", "Bambu AMS/RFID catalog"), ("QD_X_PLA", "composed by the device"), ("P1a2b3c4", "user-custom"), ("null", "user-custom")]: @@ -560,7 +580,10 @@ class TestChecks(SyntheticTreeCase): self.assertIn(marker, out) def test_check6a_instantiated_preset_with_own_key(self): - self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX01", + # Same value the child would already resolve through inherits: this + # isolates check 6a (own key present) from check 6b (value drift). + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + self.t.write_preset("VendorA", preset("APLA @P1", filament_id=apla_id, inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), register=False) @@ -569,13 +592,16 @@ class TestChecks(SyntheticTreeCase): self.assertIn("declares its own filament_id key", out) def test_check6b_declared_vs_inherited_drift(self): - self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX02", + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + drifted = afi.generate_filament_id("AVendor", "PLA", "APLA", salt=1) + self.t.write_preset("VendorA", preset("APLA @P1", filament_id=drifted, inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), register=False) errors, out = self.t.check() self.assertGreater(errors, 0) - self.assertIn('declares filament_id "AX02" but its inherits chain resolves "AX01"', out) + self.assertIn(f'declares filament_id "{drifted}" but its inherits chain resolves ' + f'"{apla_id}"', out) def test_check6c_unresolvable_instantiated_filament(self): self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"])) @@ -591,7 +617,7 @@ class TestChecks(SyntheticTreeCase): self.assertIn("snapshot not found", out) -class TestCheck7(SyntheticTreeCase): +class TestCheck7(OfCleanTreeCase): def test_7a_empty_vendor_is_hard_error(self): fid = afi.generate_filament_id("", "PLA", "NVPLA") self.t.write_preset("VendorA", preset("NVPLA @base", filament_id=fid, @@ -650,6 +676,43 @@ class TestCheck7(SyntheticTreeCase): self.assertIn('"APLA"', out) +class TestCheck8(OfCleanTreeCase): + def _write_map(self, rows): + path = os.path.join(self.t.dir, "bambu_filament_ids.json") + ubfi.write_map(path, rows, "testcommit", "2026-09-04") + return path + + def test_row_triple_must_match_tree(self): + fid = afi.generate_filament_id("V", "PLA", "Foo") + self.t.write_preset("VendorA", preset("Foo @base", filament_id=fid, + instantiation=False, + filament_vendor="V", filament_type="PLA")) + self.t.write_preset("VendorA", preset("Foo @P1", inherits="Foo @base", + compatible_printers=["P1"])) + map_path = self._write_map( + {fid: {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Bar"}}) + errors, out = self.t.check(map_path) + self.assertGreater(errors, 0) + self.assertIn("regenerate the map", out) + + def test_duplicate_bambu_id_is_an_error(self): + map_path = self._write_map({ + "OFaaaaaa": {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Foo"}, + "OFbbbbbb": {"bambu_id": "GFZ00", "vendor": "V", "type": "PETG", "name": "Bar"}, + }) + errors, out = self.t.check(map_path) + self.assertGreater(errors, 0) + self.assertIn("GFZ00", out) + + def test_row_for_unshipped_product_is_fine(self): + map_path = self._write_map({ + "OFcccccc": {"bambu_id": "GFZ99", "vendor": "Nobody", "type": "PLA", + "name": "Ships Nothing"}, + }) + errors, out = self.t.check(map_path) + self.assertEqual(errors, 0, out) + + # --------------------------------------------------------------------------- # --update-snapshot # --------------------------------------------------------------------------- @@ -698,7 +761,7 @@ class TestUpdateSnapshot(SyntheticTreeCase): # default run: mint + insert # --------------------------------------------------------------------------- -class TestAssign(SyntheticTreeCase): +class TestAssign(OfCleanTreeCase): def test_noop_on_fully_idded_tree(self): changed, errors, out = self.t.assign() self.assertEqual((changed, errors), (0, 0)) @@ -771,6 +834,56 @@ class TestAssign(SyntheticTreeCase): self.assertGreater(errors, 0) self.assertIn("shared with famil", out) + def test_non_of_declaration_is_treated_as_missing(self): + # A declaration that isn't OF-format (e.g. a vendor bundle synced from + # an upstream catalog, like BBL's GF ids) is reminted like a missing + # family, not left alone. + self.t.write_preset("VendorA", preset("Synced PLA @base", filament_id="GFZZ00", + instantiation=False, + filament_vendor="ZV", filament_type="PLA")) + self.t.write_preset("VendorA", preset("Synced PLA @P1", + inherits="Synced PLA @base", + compatible_printers=["P1"])) + changed, errors, out = self.t.assign() + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 1) + path = self.t.preset_path("VendorA", "Synced PLA @base") + root = load_json_file(path) + want = afi.generate_filament_id("ZV", "PLA", "Synced PLA") + self.assertEqual(root["filament_id"], want) + # the value was replaced in place, not appended as a second key + raw = open(path, encoding="utf-8").read() + self.assertEqual(raw.count('"filament_id"'), 1) + # idempotent: the id is OF-format now, so a second run is a no-op + changed, errors, _out = self.t.assign() + self.assertEqual((changed, errors), (0, 0)) + + def test_non_of_multi_root_family_converges_on_one_id(self): + # Two per-printer roots of one product (same triple), both carrying + # the SAME non-OF id — the shape a synced vendor bundle ships (e.g. + # BambuStudio's own per-printer @base files). They must converge on + # one freshly minted id, not split into two. + self.t.write_preset("VendorA", preset("Synced ABS @P1base", filament_id="GFSYNC0", + instantiation=False, + filament_vendor="ZV", filament_type="ABS")) + self.t.write_preset("VendorA", preset("Synced ABS @P2base", filament_id="GFSYNC0", + instantiation=False, + filament_vendor="ZV", filament_type="ABS")) + self.t.write_preset("VendorA", preset("Synced ABS @P1", + inherits="Synced ABS @P1base", + compatible_printers=["P1"])) + self.t.write_preset("VendorA", preset("Synced ABS @P2", + inherits="Synced ABS @P2base", + compatible_printers=["P2"])) + changed, errors, out = self.t.assign() + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 2) + want = afi.generate_filament_id("ZV", "ABS", "Synced ABS") + b1 = load_json_file(self.t.preset_path("VendorA", "Synced ABS @P1base")) + b2 = load_json_file(self.t.preset_path("VendorA", "Synced ABS @P2base")) + self.assertEqual(b1["filament_id"], want) + self.assertEqual(b2["filament_id"], want) + # --------------------------------------------------------------------------- # byte-preserving profile edits @@ -962,11 +1075,19 @@ class TestRemint(SyntheticTreeCase): self.assertEqual(root["filament_id"], afi.generate_filament_id(*self.TRIPLE, salt=1)) - def test_bbl_is_forbidden(self): + def test_bbl_is_reminted_like_any_vendor(self): + self.t.add_vendor("BBL", [ + preset("Bambu ABS @base", filament_id="GFB00", instantiation=False, + filament_vendor="Bambu Lab", filament_type="ABS"), + preset("Bambu ABS @P1", inherits="Bambu ABS @base", + compatible_printers=["P1"]), + ]) changed, errors, out = self.t.remint(["BBL"]) - self.assertEqual(changed, 0) - self.assertGreater(errors, 0) - self.assertIn("forbidden", out) + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 1) + root = load_json_file(self.t.preset_path("BBL", "Bambu ABS @base")) + self.assertEqual(root["filament_id"], + afi.generate_filament_id("Bambu Lab", "ABS", "Bambu ABS")) # --------------------------------------------------------------------------- @@ -1044,7 +1165,7 @@ class TestRealTree(unittest.TestCase): # review-fix regressions # --------------------------------------------------------------------------- -class TestReviewFixes(SyntheticTreeCase): +class TestReviewFixes(OfCleanTreeCase): def test_check3_skips_of_id_inherited_from_other_vendor(self): # An OFL family carries its own minted OF id and a vendor tunes it # correctly (same base name, non-empty printers). The new claim must