mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-08 09:46:55 +00:00
unify id generation scripts
This commit is contained in:
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -20,7 +20,7 @@
|
||||
> Please describe the tests that you have conducted to verify the changes made in this PR.
|
||||
-->
|
||||
|
||||
- [ ] New filament/material profiles: I wrote **no** `filament_id` key by hand — I ran `python scripts/assign_filament_ids.py` and `--update-snapshot` and committed both diffs (see `doc/developer-reference/filament_id.md`)
|
||||
- [ ] New filament/material profiles: I wrote **no** `filament_id` key by hand — I ran `python scripts/orca_id_tool.py --generate` and `--update-snapshot` and committed both diffs (see [docs/HLSD/filament_id.md](docs/HLSD/filament_id.md))
|
||||
|
||||
<!--
|
||||
> A guide for users on how to download the artifacts from this PR.
|
||||
|
||||
@@ -9,7 +9,7 @@ OrcaFilamentLibrary (OFL), Qidi, or Snapmaker bundle. The granularity is the nam
|
||||
not the brand behind it: `AAA PLA Lite` and `AAA PLA Pro` are two filaments with two ids, not
|
||||
variants of one.
|
||||
|
||||
**How it is generated:** an id is computed, never invented. `scripts/assign_filament_ids.py`
|
||||
**How it is generated:** an id is computed, never invented. `scripts/orca_id_tool.py`
|
||||
mints it as a deterministic hash of the product's identity — the triple
|
||||
`(filament_vendor, filament_type, filament name)`, where the filament name is the preset name
|
||||
with its `@...` variant suffix stripped — producing an 8-character `OF*` code that is the
|
||||
@@ -34,7 +34,7 @@ This page is the rule for authoring `filament_id` in system profiles
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Never write a `filament_id` value by hand.** A new filament gets its id from
|
||||
> `python scripts/assign_filament_ids.py`; one already in the tree has one — inherit it.
|
||||
> `python scripts/orca_id_tool.py --generate`; one already in the tree has one — inherit it.
|
||||
|
||||
## The design, in two pieces
|
||||
|
||||
@@ -158,7 +158,8 @@ key needed). Tuning a generic material → **join the OrcaFilamentLibrary filame
|
||||
different product by rule 5, so it then needs its own id.
|
||||
5. **Ids follow the product identity.** The id is a pure function of the product triple
|
||||
`(filament_vendor, filament_type, filament name)`, so correcting any of them re-mints the id
|
||||
**by design** (via `--remint <Vendor>`; the exact sequence is in the FAQ). Nothing forwards
|
||||
**by design**, applied by `--generate` (preview with `--dry-run`, confine with `--vendor`) and
|
||||
gated by the `--update-snapshot` diff; the exact sequence is in the FAQ. Nothing forwards
|
||||
the old value, so anything outside the tree that stored it — a device tray, a calibration
|
||||
record, a saved project — falls back to matching by filament type until the user re-selects
|
||||
the filament. Re-mint deliberately, and only to fix a genuinely wrong identity.
|
||||
@@ -167,7 +168,7 @@ key needed). Tuning a generic material → **join the OrcaFilamentLibrary filame
|
||||
## Minting — nobody invents ids
|
||||
|
||||
New ids are deterministic, computed exactly like the `setting_id` precedent
|
||||
(`scripts/assign_vendor_setting_ids.py`):
|
||||
(the `setting_id` half of `scripts/orca_id_tool.py`):
|
||||
|
||||
```text
|
||||
FILAMENT_ID_NAMESPACE = uuid5(setting-id NAMESPACE, "filament_id")
|
||||
@@ -204,36 +205,49 @@ Workflow for a new filament:
|
||||
|
||||
```bash
|
||||
# 1. Author the filament with NO filament_id key anywhere.
|
||||
python scripts/assign_filament_ids.py # 2. mint + insert ids into the root preset(s)
|
||||
python scripts/assign_filament_ids.py --update-snapshot # 3. record the new claims in the snapshot
|
||||
python scripts/assign_filament_ids.py --check # 4. verify — the same checks CI runs
|
||||
# 5. Commit the profile edits together with scripts/filament_id_snapshot.json.
|
||||
python scripts/orca_id_tool.py --dry-run # 2. preview the ids — writes nothing
|
||||
python scripts/orca_id_tool.py --generate # 3. apply them to the profile file(s)
|
||||
python scripts/orca_id_tool.py --update-snapshot # 4. record the new claims in the snapshot
|
||||
python scripts/orca_id_tool.py --check # 5. validate the filament_id state
|
||||
python scripts/orca_extra_profile_check.py # 6. ...and everything else CI checks
|
||||
# 7. Commit the profile edits together with scripts/filament_id_snapshot.json, for review.
|
||||
```
|
||||
|
||||
The default run mints ids for id-less filaments and replaces any declaration that is not in
|
||||
`OF` format; it never rewrites a valid `OF` id, so it is idempotent and a no-op once every
|
||||
filament has one. It edits profile files byte-preservingly (indentation, BOM, and line endings
|
||||
intact) and re-parses them to fail loudly.
|
||||
`--mint "filament_vendor/filament_type/filament_name"` prints the id a **new** mint of that
|
||||
triple would get, without touching anything — note that for a triple whose id already exists
|
||||
it prints the next *free* salt iteration, not the live id (asking for
|
||||
`Polymaker/PLA/PolyLite PLA` today prints the salt-1 id, because `OF5CgdDq` is taken).
|
||||
`--generate` makes every filament's id equal the mint of its own
|
||||
`(filament_vendor, filament_type, filament name)` triple: it inserts one where an instantiated
|
||||
filament resolves none, and re-derives one that does not match. A preset that *inherits* a
|
||||
mismatching id is the one case left to the author — check 3b names it, and the fix is to inherit
|
||||
a preset of the same filament or to give the preset its own key. It converges on an id the same
|
||||
product already holds and salts only past ids *other* products hold, so an id already equal to a
|
||||
salt iteration of its own triple is left alone and deliberate salt splits survive. The same run
|
||||
assigns `generate_preset_setting_id(vendor, type, name)` to every instantiated filament, process
|
||||
and machine preset of every vendor except BBL, which keeps its authoritative `G*` ids, strips
|
||||
`setting_id` from base profiles, and fixes the misspelled `settings_id` key — dropped, or, for
|
||||
BBL, whose ids have no formula to fall back on, restored under the correct name. It is idempotent and
|
||||
byte-preserving (indentation, BOM, and line endings intact, every edited file re-parsed to fail
|
||||
loudly), and a no-op on a tree that already passes `scripts/orca_extra_profile_check.py` — the
|
||||
check CI runs over both id kinds, of which `--check` is the `filament_id` half.
|
||||
|
||||
Maintenance modes (`--remint` is also the step for identity fixes — see the FAQ; the rest are
|
||||
normally only used by id migrations):
|
||||
|
||||
- `--remint VENDOR` re-derives a vendor's declared ids from their triples — any vendor, BBL
|
||||
included, since no bundle is exempt from the mint rule. A declaration already equal to a
|
||||
salt iteration of its own triple is conformant and left alone, so deliberate salt splits
|
||||
survive; convergence onto an id another bundle already uses for the *same* triple is legal
|
||||
by design — that is the point.
|
||||
- `--drop-redundant-ids VENDOR` deletes declarations that merely re-declare an inherited
|
||||
OrcaFilamentLibrary id.
|
||||
- `--filament-id` limits the run to `filament_id`.
|
||||
- `--setting-id` limits the run to `setting_id`. The two exclude each other; pass neither to
|
||||
write both.
|
||||
- `--vendor VENDOR` confines the run to that bundle; repeatable. The ids are still derived
|
||||
tree-wide, so a narrowed run writes exactly what a full one would — and reports any duplicate
|
||||
it was not allowed to clear, since only a run covering both bundles can.
|
||||
- `--dry-run` reports what `--generate` would do and writes nothing; with no mode of its own it
|
||||
implies `--generate`, so `--dry-run --vendor <Vendor>` previews just that bundle.
|
||||
- `--profiles DIR` points the tooling at a different profile tree (default
|
||||
`resources/profiles`).
|
||||
`resources/profiles`). `--check` and `--update-snapshot` read and write the sanctioned state of
|
||||
the tree they are given, so pointing them elsewhere needs `--snapshot PATH` for that tree too —
|
||||
`scripts/filament_id_snapshot.json` describes `resources/profiles` and no other tree.
|
||||
|
||||
**Identity fixes need no separate mode.** `--generate` re-derives an id that no longer matches its
|
||||
triple exactly the way it fills in a missing one, so a rename or a `filament_vendor` /
|
||||
`filament_type` correction is just: fix the config, run `--generate` (confine it with `--vendor`,
|
||||
preview it with `--dry-run`), then `--update-snapshot` and review the diff.
|
||||
|
||||
If you skip the tooling, CI fails and prints the remedy: the expected id for your filament and
|
||||
the instruction to run `python scripts/assign_filament_ids.py`; once the id is minted, the
|
||||
the instruction to run `python scripts/orca_id_tool.py --generate`; once the id is minted, the
|
||||
snapshot checks likewise point at `--update-snapshot` and tell you to commit the resulting
|
||||
diff.
|
||||
|
||||
@@ -466,18 +480,20 @@ ambiguity check behind structure rule 3.
|
||||
`Generic PLA` base name, set `compatible_printers`; no id key needed.
|
||||
- **A branded filament that borrows a generic's settings?** Fine — inherit `Generic X @System`
|
||||
(or any real filament) for the settings and declare the id of your own filament; run
|
||||
`python scripts/assign_filament_ids.py` to mint it. Inheritance never changes the id.
|
||||
`python scripts/orca_id_tool.py --generate` to mint it. Inheritance never changes the id.
|
||||
- **I need to fix a filament's `filament_vendor` or `filament_type`.** Fix the config, run
|
||||
`--remint <Vendor>` then `--update-snapshot`, and commit the profile and snapshot diffs
|
||||
together. The id re-derives from the corrected identity, and nothing forwards the old
|
||||
value, so a tray or record still holding it falls back to matching by filament type.
|
||||
`--generate --vendor <Vendor>` (preview with `--dry-run`), then `--update-snapshot`, and commit
|
||||
the profile and snapshot diffs together. The id re-derives from the corrected identity, and
|
||||
nothing forwards the old value, so a tray or record still holding it falls back to matching by
|
||||
filament type.
|
||||
- **I need to rename a filament.** Rename the presets (adding `renamed_from`, which keeps the
|
||||
preset *name* resolving), then `--remint <Vendor>`, then `--update-snapshot`. The id follows
|
||||
the new filament name; as with any identity fix, the old id is not forwarded.
|
||||
preset *name* resolving), then `--generate --vendor <Vendor>` (preview with `--dry-run`), then
|
||||
`--update-snapshot`. The id follows the new filament name; as with any identity fix, the old id
|
||||
is not forwarded.
|
||||
- **Can I reuse a `QD_*` id for a Qidi profile?** No — nobody can. It is the device protocol's
|
||||
own id space: the box composes those values at runtime and no preset carries one. Author
|
||||
Qidi filaments like any other vendor's.
|
||||
- **CI says my filament needs an id.** Run `python scripts/assign_filament_ids.py`, then
|
||||
- **CI says my filament needs an id.** Run `python scripts/orca_id_tool.py --generate`, then
|
||||
`--update-snapshot`, and commit both diffs. Do not type an id by hand.
|
||||
|
||||
For general profile authoring, see the profile development guide on the
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
|
||||
|
||||
Policy (see AGENTS.md "Critical Constraints"):
|
||||
* A preset's setting_id is a pure function of its identity:
|
||||
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
|
||||
The same value is recomputed on the fly by the C++ app
|
||||
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
|
||||
(generate_preset_setting_id, below) is also imported by the validator
|
||||
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
|
||||
collide only if they share vendor + type + name, which the validator flags.
|
||||
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
|
||||
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
|
||||
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
|
||||
deterministic rule.
|
||||
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
|
||||
template profiles do not.
|
||||
|
||||
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
|
||||
per-material id, shared across a filament's nozzle variants and inherited from base
|
||||
templates, so it must not be made per-file unique.
|
||||
|
||||
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
|
||||
The script is idempotent: a second run over an unchanged tree produces no diff.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
|
||||
# Deterministic preset setting_id rule. Imported by the validator
|
||||
# (orca_extra_profile_check.py) and kept byte-identical to the C++
|
||||
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
|
||||
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
|
||||
# into both languages - never change it.
|
||||
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ID_LENGTH = 16
|
||||
|
||||
|
||||
def generate_preset_setting_id(vendor, type_name, name):
|
||||
"""Deterministic 16-char base62 setting_id for a preset.
|
||||
|
||||
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
|
||||
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
|
||||
"""
|
||||
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = []
|
||||
for _ in range(ID_LENGTH):
|
||||
digits.append(ALPHABET[n % 62])
|
||||
n //= 62
|
||||
return "".join(reversed(digits))
|
||||
|
||||
|
||||
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
|
||||
|
||||
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
|
||||
RESERVED_VENDORS = {"BBL"}
|
||||
|
||||
PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
|
||||
def iter_profile_files(vendor_dir):
|
||||
"""Yield (json path, type) under a vendor, in a deterministic order.
|
||||
|
||||
type is the subdir name ("filament"/"process"/"machine"), which matches
|
||||
Preset::get_type_string() on the C++ side.
|
||||
"""
|
||||
for sub in PROFILE_SUBDIRS:
|
||||
base = os.path.join(vendor_dir, sub)
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for root, dirs, files in os.walk(base):
|
||||
dirs.sort() # deterministic traversal across filesystems
|
||||
for name in sorted(files):
|
||||
if name.endswith(".json"):
|
||||
yield os.path.join(root, name), sub
|
||||
|
||||
|
||||
def read_profile(path):
|
||||
"""Return (setting_id, instantiation, name) as present (or None)."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = json.loads(f.read())
|
||||
except (ValueError, OSError):
|
||||
return None, None, None
|
||||
if not isinstance(data, dict):
|
||||
return None, None, None
|
||||
return data.get("setting_id"), data.get("instantiation"), data.get("name")
|
||||
|
||||
|
||||
def list_vendors():
|
||||
return sorted(
|
||||
d for d in os.listdir(PROFILES_DIR)
|
||||
if os.path.isdir(os.path.join(PROFILES_DIR, d))
|
||||
)
|
||||
|
||||
|
||||
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
|
||||
|
||||
|
||||
def remove_key_line(text, key):
|
||||
"""Remove a top-level `"key": "..."` member, preserving formatting.
|
||||
|
||||
Handles both the common case (member has a trailing comma) and the member
|
||||
being the LAST in its object (consume the preceding comma instead, so no
|
||||
dangling comma is left). Returns (new_text, count).
|
||||
"""
|
||||
# Member followed by a comma (not the last in the object).
|
||||
trailing = re.compile(
|
||||
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
|
||||
)
|
||||
new, n = trailing.subn("", text, count=1)
|
||||
if n:
|
||||
return new, n
|
||||
# Member is the last one: drop the preceding comma and the member itself.
|
||||
leading = re.compile(
|
||||
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
|
||||
)
|
||||
return leading.subn("", text, count=1)
|
||||
|
||||
|
||||
def _remove_key_in_tree(key, should_remove):
|
||||
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
|
||||
removed = 0
|
||||
for vendor in list_vendors():
|
||||
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
sid, inst, _name = read_profile(path)
|
||||
if not should_remove(sid, inst, text):
|
||||
continue
|
||||
new_text, n = remove_key_line(text, key)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
|
||||
json.loads(new_text) # fail loudly if removal broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(new_text.encode("utf-8"))
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def remove_misspelled_settings_id():
|
||||
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
|
||||
|
||||
The app never reads that key, so those presets effectively had no setting_id
|
||||
and get a correct one assigned by the normal pass; here we drop the junk key.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"settings_id", lambda sid, inst, text: '"settings_id"' in text
|
||||
)
|
||||
|
||||
|
||||
def strip_base_setting_ids():
|
||||
"""Remove setting_id from every base profile (instantiation != "true").
|
||||
|
||||
Convention: only instantiated, user-selectable presets carry a setting_id;
|
||||
base/template profiles do not. Applied across all vendors.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
|
||||
)
|
||||
|
||||
|
||||
def replace_id_value(text, key, new_value):
|
||||
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
|
||||
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
|
||||
new_text, n = pattern.subn(repl, text, count=1)
|
||||
return new_text, n
|
||||
|
||||
|
||||
def insert_setting_id(text, new_id):
|
||||
"""Insert a `"setting_id"` line into a preset that lacks one.
|
||||
|
||||
Placed just before `filament_id` (or, failing that, `instantiation`) so it
|
||||
matches the canonical key order, reusing that anchor line's indentation and
|
||||
line ending. Only setting_id is added; filament_id is left untouched.
|
||||
"""
|
||||
for key in ("filament_id", "instantiation"):
|
||||
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
|
||||
if m:
|
||||
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
|
||||
return text[:m.start()] + line + text[m.start():], 1
|
||||
return text, 0
|
||||
|
||||
|
||||
def rewrite_file(path, new_id, has_setting_id):
|
||||
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
|
||||
|
||||
filament_id is intentionally left untouched. Uses binary IO so the file's
|
||||
original line endings (LF or CRLF) and exact formatting are preserved
|
||||
byte-for-byte apart from the changed/added line. The result is re-parsed to
|
||||
guarantee it is still valid JSON.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
if has_setting_id:
|
||||
text, n = replace_id_value(text, "setting_id", new_id)
|
||||
else:
|
||||
text, n = insert_setting_id(text, new_id)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not set setting_id on {path}")
|
||||
json.loads(text) # fail loudly if the edit broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(text.encode("utf-8"))
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
# 0. Drop the misspelled "settings_id" key wherever it appears.
|
||||
typos = remove_misspelled_settings_id()
|
||||
|
||||
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
|
||||
stripped = strip_base_setting_ids()
|
||||
|
||||
# 2. Assign the deterministic setting_id to every instantiated preset of every
|
||||
# non-reserved vendor.
|
||||
changed = added = 0
|
||||
vendors_touched = []
|
||||
for vendor in list_vendors():
|
||||
if vendor in RESERVED_VENDORS:
|
||||
continue
|
||||
vendor_changed = 0
|
||||
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
sid, inst, name = read_profile(path)
|
||||
if inst != "true":
|
||||
continue
|
||||
if not name:
|
||||
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
|
||||
new_id = generate_preset_setting_id(vendor, type_name, name)
|
||||
if sid == new_id:
|
||||
continue # already correct - idempotent
|
||||
rewrite_file(path, new_id, has_setting_id=sid is not None)
|
||||
changed += 1
|
||||
vendor_changed += 1
|
||||
if sid is None:
|
||||
added += 1
|
||||
if vendor_changed:
|
||||
vendors_touched.append((vendor, vendor_changed))
|
||||
|
||||
print(f"Misspelled settings_id removed : {typos}")
|
||||
print(f"Base setting_ids stripped : {stripped}")
|
||||
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
|
||||
print(f"Vendors updated : {len(vendors_touched)}")
|
||||
for v, n in vendors_touched:
|
||||
print(f" {v} ({n} files)")
|
||||
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,8 +3,7 @@ import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from assign_vendor_setting_ids import generate_preset_setting_id
|
||||
from assign_filament_ids import check_filament_ids
|
||||
from orca_id_tool import generate_preset_setting_id, check_filament_ids
|
||||
|
||||
OBSOLETE_KEYS = {
|
||||
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
|
||||
@@ -488,10 +487,11 @@ PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
def check_setting_id_uniqueness(profiles_dir):
|
||||
"""
|
||||
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py):
|
||||
Validate setting_id across every vendor (see scripts/orca_id_tool.py):
|
||||
1. Every instantiated preset must HAVE a setting_id. (all vendors)
|
||||
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
|
||||
value means the JSON was edited without rerunning assign_vendor_setting_ids.py.
|
||||
value means the JSON was edited without rerunning
|
||||
"python scripts/orca_id_tool.py --generate --setting-id".
|
||||
(all vendors EXCEPT the formula-exempt ones, e.g. BBL)
|
||||
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
|
||||
4. setting_id must be globally unique - no two files may share one. (all vendors)
|
||||
@@ -523,7 +523,8 @@ def check_setting_id_uniqueness(profiles_dir):
|
||||
errors += 1
|
||||
print_error(
|
||||
f'profile {rel} uses the misspelled key "settings_id" '
|
||||
f'(should be "setting_id"); run assign_vendor_setting_ids.py'
|
||||
f'(should be "setting_id"); run '
|
||||
f'"python scripts/orca_id_tool.py --generate --setting-id"'
|
||||
)
|
||||
sid = data.get("setting_id")
|
||||
instantiated = data.get("instantiation") == "true"
|
||||
@@ -533,7 +534,8 @@ def check_setting_id_uniqueness(profiles_dir):
|
||||
errors += 1
|
||||
print_error(
|
||||
f'base profile {rel} (instantiation != "true") must not have a '
|
||||
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py'
|
||||
f'setting_id ("{sid}"); run '
|
||||
f'"python scripts/orca_id_tool.py --generate --setting-id"'
|
||||
)
|
||||
continue
|
||||
# Rule 1: every instantiated preset must have a setting_id.
|
||||
@@ -541,7 +543,7 @@ def check_setting_id_uniqueness(profiles_dir):
|
||||
errors += 1
|
||||
print_error(
|
||||
f"instantiated preset {rel} is missing a setting_id; "
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
f'run "python scripts/orca_id_tool.py --generate --setting-id"'
|
||||
)
|
||||
continue
|
||||
# Rule 2: the stored id must match the deterministic rule. BBL keeps its
|
||||
@@ -553,7 +555,7 @@ def check_setting_id_uniqueness(profiles_dir):
|
||||
print_error(
|
||||
f'setting_id "{sid}" in {rel} does not match the expected '
|
||||
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
f'run "python scripts/orca_id_tool.py --generate --setting-id"'
|
||||
)
|
||||
continue
|
||||
# Rule 4: collect for the global-uniqueness check below.
|
||||
@@ -631,7 +633,7 @@ def main():
|
||||
# Runs once over the whole tree regardless of the --vendor filter.
|
||||
errors_found += check_setting_id_uniqueness(profiles_dir)
|
||||
|
||||
# Global filament_id check (see scripts/assign_filament_ids.py): effective ids
|
||||
# Global filament_id check (see scripts/orca_id_tool.py): effective ids
|
||||
# are resolved loader-faithfully and validated against the sanctioned snapshot
|
||||
# (scripts/filament_id_snapshot.json). Runs once over the whole tree.
|
||||
errors_found += check_filament_ids(profiles_dir)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/assign_filament_ids.py (stdlib unittest, no external deps).
|
||||
"""Tests for scripts/orca_id_tool.py (stdlib unittest, no external deps).
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
@@ -17,7 +17,7 @@ import uuid
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import assign_filament_ids as afi # noqa: E402
|
||||
import orca_id_tool as afi # noqa: E402
|
||||
import update_bambu_filament_ids as ubfi # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
@@ -99,6 +99,17 @@ class SyntheticTree:
|
||||
if register:
|
||||
self.add_to_index(vendor, data["name"])
|
||||
|
||||
def set_sub_path(self, vendor, name, sub_path):
|
||||
"""Rewrite one index entry's sub_path (the file itself does not move)."""
|
||||
idx_path = os.path.join(self.profiles, vendor + ".json")
|
||||
with open(idx_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
for entry in index["filament_list"]:
|
||||
if entry["name"] == name:
|
||||
entry["sub_path"] = sub_path
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def remove_preset(self, vendor, name):
|
||||
os.remove(self.preset_path(vendor, name))
|
||||
idx_path = os.path.join(self.profiles, vendor + ".json")
|
||||
@@ -109,12 +120,25 @@ class SyntheticTree:
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def bytes_map(self):
|
||||
"""{relative path -> file bytes} over every .json in the tree."""
|
||||
raw = {}
|
||||
for root, dirs, files in os.walk(self.profiles):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
with open(path, "rb") as f:
|
||||
raw[os.path.relpath(path, self.profiles)] = f.read()
|
||||
return raw
|
||||
|
||||
# -- pipeline wrappers ---------------------------------------------------
|
||||
|
||||
def update_snapshot(self):
|
||||
def update_snapshot(self, dry_run=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.update_snapshot(self.profiles, self.snapshot)
|
||||
rc = afi.update_snapshot(self.profiles, self.snapshot, dry_run)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def check(self, map_path=None):
|
||||
@@ -124,23 +148,32 @@ class SyntheticTree:
|
||||
errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs)
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def assign(self):
|
||||
# assign() and remint() are the same one pass over the tree — every filament
|
||||
# ends up with the id its own triple mints, whether that means inserting a
|
||||
# missing key or rewriting a non-conformant one. Both names are kept because
|
||||
# they read differently at the call sites.
|
||||
def assign(self, vendors=None, dry_run=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.assign_missing_ids(self.profiles, self.snapshot)
|
||||
changed, errors = afi.generate_filament_ids(
|
||||
self.profiles, self.snapshot, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
def remint(self, vendors):
|
||||
def remint(self, vendors, dry_run=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.remint_vendors(vendors, self.profiles)
|
||||
changed, errors = afi.generate_filament_ids(
|
||||
self.profiles, self.snapshot, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
def drop_redundant(self, vendor):
|
||||
def cli(self, *flags):
|
||||
"""Run main() against this tree, capturing stdout."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
dropped, errors = afi.drop_redundant_ids(vendor, self.profiles)
|
||||
return dropped, errors, buf.getvalue()
|
||||
rc = afi.main([*flags, "--profiles", self.profiles,
|
||||
"--snapshot", self.snapshot])
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
|
||||
"""Baseline tree: OFL base+generic, a vendor filament, a clean tuned generic.
|
||||
@@ -182,8 +215,8 @@ class OfCleanTreeCase(unittest.TestCase):
|
||||
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.
|
||||
remint (TestRemint, TestUpdateSnapshot) keep using SyntheticTreeCase
|
||||
instead.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.t = make_clean_tree(
|
||||
@@ -243,6 +276,81 @@ class TestMint(unittest.TestCase):
|
||||
self.assertEqual(afi.mint_filament_id(*triple, set()),
|
||||
afi.generate_filament_id(*triple))
|
||||
|
||||
def test_mint_never_returns_a_salt_the_identity_check_rejects(self):
|
||||
# mint_filament_id stops at MAX_CHECK_SALT, the last iteration check 3
|
||||
# accepts, so the tool can never write an id its own --check rejects.
|
||||
triple = ("Polymaker", "PLA", "PolyLite PLA")
|
||||
all_taken = afi.mint_iterations(triple)
|
||||
self.assertEqual(len(all_taken), afi.MAX_CHECK_SALT + 1)
|
||||
last = afi.mint_filament_id(*triple, all_taken - {afi.generate_filament_id(
|
||||
*triple, salt=afi.MAX_CHECK_SALT)})
|
||||
self.assertIn(last, afi.mint_iterations(triple))
|
||||
with self.assertRaises(RuntimeError):
|
||||
afi.mint_filament_id(*triple, all_taken)
|
||||
|
||||
def test_want_id_policy(self):
|
||||
# The whole id policy: a triple's id is its first mint iteration that no
|
||||
# OTHER product holds, in the tree or in the snapshot.
|
||||
triple = ("Polymaker", "PLA", "PolyLite PLA")
|
||||
other = ("Elegoo", "PETG", "Rapid PETG")
|
||||
salt0 = afi.generate_filament_id(*triple)
|
||||
salt1 = afi.generate_filament_id(*triple, salt=1)
|
||||
|
||||
def snapshot(**entries):
|
||||
return {"ids": {fid: {"filaments": [], "name": t[2],
|
||||
"filament_type": t[1], "filament_vendor": t[0]}
|
||||
for fid, t in entries.items()}}
|
||||
|
||||
# nobody holds it: salt 0
|
||||
self.assertEqual(afi.make_want_id({"triple_sets": {}})(triple), salt0)
|
||||
# the tree holds it under this very triple: reuse is convergence
|
||||
self.assertEqual(
|
||||
afi.make_want_id({"triple_sets": {salt0: {triple}}})(triple), salt0)
|
||||
# Another triple "holds" it, but salt0 is not a mint iteration of THAT
|
||||
# triple, so the holder is a wrong declaration this very run rewrites.
|
||||
# A transient squatter must not push a product off its own id.
|
||||
self.assertEqual(
|
||||
afi.make_want_id({"triple_sets": {salt0: {other}}})(triple), salt0)
|
||||
self.assertEqual(
|
||||
afi.make_want_id({"triple_sets": {salt0: {triple, other}}})(triple), salt0)
|
||||
# The snapshot is read the same way: only a conformant record holds.
|
||||
self.assertEqual(
|
||||
afi.make_want_id({"triple_sets": {}}, snapshot(**{salt0: other}))(triple),
|
||||
salt0)
|
||||
# ... and the snapshot's own triple reuses it
|
||||
self.assertEqual(
|
||||
afi.make_want_id({"triple_sets": {}}, snapshot(**{salt0: triple}))(triple),
|
||||
salt0)
|
||||
# Unused here, but salting must still be reachable: see
|
||||
# test_want_id_salts_past_a_genuine_collision.
|
||||
self.assertNotEqual(salt0, salt1)
|
||||
# memoized: one triple keeps one id for the whole run
|
||||
want_id = afi.make_want_id({"triple_sets": {}})
|
||||
self.assertEqual(want_id(triple), want_id(triple))
|
||||
# ... and a second triple never gets the id this run already handed out
|
||||
self.assertNotEqual(want_id(triple), want_id(other))
|
||||
|
||||
def test_want_id_salts_past_a_genuine_collision(self):
|
||||
# Salting exists for one case only: two products whose triples mint the
|
||||
# same id. That needs a base62 collision, so force one — salt 0 of every
|
||||
# triple collapses to a single value while the salted iterations stay
|
||||
# distinct. `other` then CONFORMANTLY holds the shared salt-0 id, and
|
||||
# `triple` must step past it instead of stealing it.
|
||||
triple = ("V", "PLA", "X")
|
||||
other = ("W", "ABS", "Y")
|
||||
real = afi.generate_filament_id
|
||||
|
||||
def colliding(vendor, ftype, name, salt=0):
|
||||
return "OFcolid" if salt == 0 else real(vendor, ftype, name, salt)
|
||||
|
||||
afi.generate_filament_id = colliding
|
||||
try:
|
||||
self.assertIn("OFcolid", afi.mint_iterations(other)) # holder conforms
|
||||
got = afi.make_want_id({"triple_sets": {"OFcolid": {other}}})(triple)
|
||||
finally:
|
||||
afi.generate_filament_id = real
|
||||
self.assertEqual(got, real(*triple, salt=1))
|
||||
|
||||
|
||||
class TestBaseName(unittest.TestCase):
|
||||
def test_filament_name_derivation(self):
|
||||
@@ -258,6 +366,27 @@ class TestBaseName(unittest.TestCase):
|
||||
self.assertEqual(afi.base_name(name), filament_name, msg=name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# vendor discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVendorDiscovery(unittest.TestCase):
|
||||
def test_a_bundle_is_a_subdir_with_a_matching_index(self):
|
||||
# Neither half alone makes a bundle: resources/profiles tracks a "user"
|
||||
# directory with no user.json, and an index without its directory is a
|
||||
# leftover. Both are skipped, tree-wide.
|
||||
t = SyntheticTree()
|
||||
self.addCleanup(t.cleanup)
|
||||
t.add_vendor("VendorA", [])
|
||||
os.makedirs(os.path.join(t.profiles, "user", "filament"))
|
||||
with open(os.path.join(t.profiles, "Orphan.json"), "w",
|
||||
encoding="utf-8") as f:
|
||||
json.dump({"name": "Orphan", "filament_list": []}, f)
|
||||
self.assertEqual(afi.list_vendor_names(t.profiles), ["VendorA"])
|
||||
self.assertEqual(sorted(afi.analyze_tree(t.profiles)["vendors"]),
|
||||
["VendorA"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolver (loader-faithful semantics)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -323,12 +452,6 @@ class TestResolver(unittest.TestCase):
|
||||
fid, src, _e = self.resolve("A", [self.rec("A")], [])
|
||||
self.assertEqual((fid, src), (None, "missing"))
|
||||
|
||||
def test_skip_own_resolves_inherited(self):
|
||||
fid, _src, _e = self.resolve(
|
||||
"A", [self.rec("A", "OWN", inherits="B"), self.rec("B", "PARENT")], [],
|
||||
skip_own=True)
|
||||
self.assertEqual(fid, "PARENT")
|
||||
|
||||
|
||||
class TestTripleResolution(unittest.TestCase):
|
||||
@staticmethod
|
||||
@@ -692,6 +815,16 @@ class TestCheck6(OfCleanTreeCase):
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("regenerate the map", out)
|
||||
|
||||
def test_non_of_map_key_is_an_error(self):
|
||||
# The map is keyed by OUR ids; a Bambu id in the key column means the
|
||||
# map was generated or hand-edited the wrong way round.
|
||||
map_path = self._write_map({
|
||||
"GFB00": {"bambu_id": "GFB00", "vendor": "V", "type": "PLA",
|
||||
"name": "Foo"}})
|
||||
errors, out = self.t.check(map_path)
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertIn('"GFB00" is not a minted "OF" id', 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"},
|
||||
@@ -786,6 +919,21 @@ class TestUpdateSnapshot(SyntheticTreeCase):
|
||||
self.assertEqual(list(snap["ids"]["AX01"]),
|
||||
["filaments", "name", "filament_type", "filament_vendor"])
|
||||
|
||||
def test_refuses_a_tree_it_could_not_read(self):
|
||||
# A bundle that does not parse contributes no ids, so sanctioning the
|
||||
# rest would record the loss as a deliberate removal.
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
before = f.read()
|
||||
with open(os.path.join(self.t.profiles, "VendorA",
|
||||
"filament", "APLA @base.json"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("{ not json")
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("unreadable filament profile", out)
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before)
|
||||
|
||||
def test_refuses_an_id_declared_under_two_triples(self):
|
||||
# VendorB re-declares APLA's id for a different product: one id, two
|
||||
# triples. No single entry can describe it, and check 3 rejects it anyway.
|
||||
@@ -818,16 +966,36 @@ class TestUpdateSnapshot(SyntheticTreeCase):
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before) # nothing written on refusal
|
||||
|
||||
def test_dry_run_reports_without_writing(self):
|
||||
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
|
||||
compatible_printers=["P2"]))
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
before = f.read()
|
||||
rc, out = self.t.update_snapshot(dry_run=True)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("would be rewritten", out)
|
||||
self.assertIn("claims added : 1", out)
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before)
|
||||
# the real run writes exactly what the dry run reported
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("snapshot written", out)
|
||||
snap = load_json_file(self.t.snapshot)
|
||||
self.assertEqual(snap["ids"]["AX01"]["filaments"],
|
||||
["VendorA/ANEW", "VendorA/APLA"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# default run: mint + insert
|
||||
# --generate: one rule for inserts and rewrites alike
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssign(OfCleanTreeCase):
|
||||
def test_noop_on_fully_idded_tree(self):
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
self.assertIn("nothing to do (0 files changed)", out)
|
||||
self.assertIn("filament_ids inserted : 0", out)
|
||||
self.assertIn("filament_ids re-minted : 0", out)
|
||||
|
||||
def test_mints_into_filament_root_and_rootless_member(self):
|
||||
self.t.write_preset("VendorA", preset("FNEW @base", instantiation=False,
|
||||
@@ -855,7 +1023,7 @@ class TestAssign(OfCleanTreeCase):
|
||||
# idempotent: second run is a no-op
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
self.assertIn("nothing to do", out)
|
||||
self.assertIn("filament_ids inserted : 0", out)
|
||||
|
||||
def test_refuses_filament_with_incomplete_triple(self):
|
||||
self.t.write_preset("VendorA", preset("ENEW @base", instantiation=False))
|
||||
@@ -867,6 +1035,21 @@ class TestAssign(OfCleanTreeCase):
|
||||
self.assertIn("resolves empty filament_vendor and filament_type", out)
|
||||
self.assertIn("mint key needs both", out)
|
||||
|
||||
def test_refuses_to_re_mint_a_declaration_with_an_incomplete_triple(self):
|
||||
# The rewrite half of the same guard: a declared id that is not its
|
||||
# triple's mint still cannot be re-derived without a filament_vendor.
|
||||
self.t.write_preset("VendorA", preset("QNEW @base", filament_id="OFZZZZZZ",
|
||||
instantiation=False,
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("QNEW @P1", inherits="QNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 1), out)
|
||||
self.assertIn("resolves empty filament_vendor", out)
|
||||
self.assertIn("mint key needs both", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_refuses_filament_with_divergent_root_fields(self):
|
||||
self.t.write_preset("VendorA", preset("HNEW @base1", instantiation=False,
|
||||
filament_vendor="HV",
|
||||
@@ -909,10 +1092,10 @@ class TestAssign(OfCleanTreeCase):
|
||||
errors, out = self.t.check()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_non_of_declaration_is_treated_as_missing(self):
|
||||
def test_non_of_declaration_is_re_minted(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
|
||||
# filament, not left alone.
|
||||
# an upstream catalog, like BBL's GF ids) is not the mint of its own
|
||||
# triple, so the same pass rewrites it in place.
|
||||
self.t.write_preset("VendorA", preset("Synced PLA @base", filament_id="GFZZ00",
|
||||
instantiation=False,
|
||||
filament_vendor="ZV", filament_type="PLA"))
|
||||
@@ -927,9 +1110,10 @@ class TestAssign(OfCleanTreeCase):
|
||||
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()
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
self.assertEqual(raw.count('"filament_id"'), 1)
|
||||
# idempotent: the id is OF-format now, so a second run is a no-op
|
||||
# idempotent: the id is its triple's mint now, so a second run is a no-op
|
||||
changed, errors, _out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
|
||||
@@ -959,6 +1143,148 @@ class TestAssign(OfCleanTreeCase):
|
||||
self.assertEqual(b1["filament_id"], want)
|
||||
self.assertEqual(b2["filament_id"], want)
|
||||
|
||||
def test_converges_on_an_existing_tree_id_for_the_same_triple(self):
|
||||
# BEHAVIOUR CHANGE: an id-less filament whose product is already shipped
|
||||
# (with its conforming id) in another bundle converges on that id
|
||||
# instead of salting past it. One product, one id, in every bundle.
|
||||
want = afi.generate_filament_id("CV", "PLA", "CPLA")
|
||||
self.t.add_vendor("VendorB", [
|
||||
preset("CPLA @base", filament_id=want, instantiation=False,
|
||||
filament_vendor="CV", filament_type="PLA"),
|
||||
preset("CPLA @PB", inherits="CPLA @base",
|
||||
compatible_printers=["PB 0.4 nozzle"]),
|
||||
])
|
||||
self.t.write_preset("VendorA", preset("CPLA @base", instantiation=False,
|
||||
filament_vendor="CV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("CPLA @P1", inherits="CPLA @base",
|
||||
compatible_printers=["P1"]))
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "CPLA @base"))
|
||||
self.assertEqual(root["filament_id"], want)
|
||||
|
||||
def test_a_squatted_id_is_still_minted_for_its_own_product(self):
|
||||
# VendorB's "Other" declares the id that belongs to VendorA's "DPLA" —
|
||||
# a copy-paste, not a real claim, since it is not the mint of Other's
|
||||
# own triple. That declaration is transient (--generate rewrites it), so
|
||||
# it must NOT push DPLA onto a salted id: DPLA gets its canonical one.
|
||||
want0 = afi.generate_filament_id("DV", "PLA", "DPLA")
|
||||
self.t.add_vendor("VendorB", [
|
||||
preset("Other @base", filament_id=want0, instantiation=False,
|
||||
filament_vendor="OV", filament_type="ABS"),
|
||||
])
|
||||
self.t.write_preset("VendorA", preset("DPLA @base", instantiation=False,
|
||||
filament_vendor="DV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("DPLA @P1", inherits="DPLA @base",
|
||||
compatible_printers=["P1"]))
|
||||
changed, errors, out = self.t.assign(["VendorA"])
|
||||
# VendorB is outside the write set, so the id DPLA just took is still
|
||||
# declared there: the run says so rather than leaving a silent duplicate.
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("declared by VendorB/filament/Other @base.json", out)
|
||||
# ...and keeps saying so until a run that covers VendorB clears it.
|
||||
self.assertEqual(self.t.assign(["VendorA"])[:2], (0, 1))
|
||||
root = load_json_file(self.t.preset_path("VendorA", "DPLA @base"))
|
||||
self.assertEqual(root["filament_id"], want0)
|
||||
# A full run re-mints Other to its own triple and the duplicate is gone.
|
||||
other = load_json_file(self.t.preset_path("VendorB", "Other @base"))
|
||||
self.assertEqual(other["filament_id"], want0)
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
other = load_json_file(self.t.preset_path("VendorB", "Other @base"))
|
||||
self.assertEqual(other["filament_id"],
|
||||
afi.generate_filament_id("OV", "ABS", "Other"))
|
||||
|
||||
def test_mismatching_of_declaration_is_re_derived(self):
|
||||
# BEHAVIOUR CHANGE: the default run used to leave an OF-format id alone
|
||||
# and needed a separate mode to fix it. One rule now: an id that is not
|
||||
# a mint iteration of its own triple is rewritten.
|
||||
self.t.write_preset("VendorA", preset("KNEW @base", filament_id="OFZZZZZZ",
|
||||
instantiation=False,
|
||||
filament_vendor="KV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("KNEW @P1", inherits="KNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
want = afi.generate_filament_id("KV", "PLA", "KNEW")
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertIn('"OFZZZZZZ" -> "%s"' % want, out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "KNEW @base"))
|
||||
self.assertEqual(root["filament_id"], want)
|
||||
changed, errors, _out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
|
||||
def test_vendor_filter_limits_rewrites_and_inserts_alike(self):
|
||||
# A mismatching declarer in VendorA and an id-less filament in VendorB;
|
||||
# only VendorA is written, and VendorB's bytes are untouched.
|
||||
self.t.write_preset("VendorA", preset("LNEW @base", filament_id="OFZZZZZZ",
|
||||
instantiation=False,
|
||||
filament_vendor="LV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("LNEW @P1", inherits="LNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
self.t.add_vendor("VendorB", [
|
||||
preset("MNEW @base", instantiation=False,
|
||||
filament_vendor="MV", filament_type="PLA"),
|
||||
preset("MNEW @PB", inherits="MNEW @base",
|
||||
compatible_printers=["PB 0.4 nozzle"]),
|
||||
])
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.assign(["VendorA"])
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "LNEW @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id("LV", "PLA", "LNEW"))
|
||||
after = self.t.bytes_map()
|
||||
for rel, raw in before.items():
|
||||
if rel.split(os.sep)[0].startswith("VendorB"):
|
||||
self.assertEqual(after[rel], raw, rel)
|
||||
# ... and the deferred half is exactly what a VendorB run then writes
|
||||
changed, errors, out = self.t.assign(["VendorB"])
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
b_root = load_json_file(self.t.preset_path("VendorB", "MNEW @base"))
|
||||
self.assertEqual(b_root["filament_id"],
|
||||
afi.generate_filament_id("MV", "PLA", "MNEW"))
|
||||
|
||||
def test_unknown_vendor_reports_and_writes_nothing(self):
|
||||
# A real insert is pending, so "wrote nothing" means the unknown vendor
|
||||
# aborted the run before any write — not that the tree was already done.
|
||||
self.t.write_preset("VendorA", preset("PNEW @base", instantiation=False,
|
||||
filament_vendor="PV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("PNEW @P1", inherits="PNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.assign(["Nope"])
|
||||
self.assertEqual((changed, errors), (0, 1))
|
||||
self.assertIn("unknown vendor", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
# ... and that pending insert is real: a known-vendor run makes it.
|
||||
changed, errors, out = self.t.assign(["VendorA"])
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(
|
||||
load_json_file(self.t.preset_path("VendorA", "PNEW @base"))["filament_id"],
|
||||
afi.generate_filament_id("PV", "PLA", "PNEW"))
|
||||
|
||||
def test_dry_run_reports_the_real_run_and_writes_nothing(self):
|
||||
self.t.write_preset("VendorA", preset("NNEW @base", instantiation=False,
|
||||
filament_vendor="NV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("NNEW @P1", inherits="NNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
before = self.t.bytes_map()
|
||||
dry_changed, errors, out = self.t.assign(dry_run=True)
|
||||
self.assertEqual((dry_changed, errors), (1, 0), out)
|
||||
self.assertIn("would insert", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (dry_changed, 0), out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "NNEW @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id("NV", "PLA", "NNEW"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# byte-preserving profile edits
|
||||
@@ -1056,7 +1382,7 @@ class TestInsertEditing(unittest.TestCase):
|
||||
afi.rewrite_filament_id(path, "OFold123", "OFxxx999") # stale old id
|
||||
|
||||
def test_delete_line_with_trailing_comma(self):
|
||||
text, n = afi.delete_filament_id_line(self.CRLF_WITH_ID, "OFold123")
|
||||
text, n = afi.delete_key_line(self.CRLF_WITH_ID, "filament_id", "OFold123")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertNotIn("filament_id", json.loads(text))
|
||||
self.assertEqual(text, self.CRLF_WITH_ID.replace(
|
||||
@@ -1065,15 +1391,40 @@ class TestInsertEditing(unittest.TestCase):
|
||||
def test_delete_last_property_line(self):
|
||||
lf_text = ('{\n "name": "K @base",\n "instantiation": "false",\n'
|
||||
' "filament_id": "OFold123"\n}\n')
|
||||
text, n = afi.delete_filament_id_line(lf_text, "OFold123")
|
||||
text, n = afi.delete_key_line(lf_text, "filament_id", "OFold123")
|
||||
self.assertEqual(n, 1)
|
||||
data = json.loads(text)
|
||||
self.assertNotIn("filament_id", data)
|
||||
self.assertEqual(data["instantiation"], "false")
|
||||
# the preceding comma goes with it, and nothing else moves
|
||||
self.assertEqual(text, '{\n "name": "K @base",\n'
|
||||
' "instantiation": "false"\n}\n')
|
||||
|
||||
def test_dry_run_edits_verify_but_write_nothing(self):
|
||||
t = SyntheticTree()
|
||||
self.addCleanup(t.cleanup)
|
||||
t.add_vendor("VendorA", [])
|
||||
fresh = t.preset_path("VendorA", "JNEW @base")
|
||||
with open(fresh, "wb") as f:
|
||||
f.write(self.CRLF_TEXT.encode("utf-8"))
|
||||
idded = t.preset_path("VendorA", "JOLD @base")
|
||||
with open(idded, "wb") as f:
|
||||
f.write(self.CRLF_WITH_ID.encode("utf-8"))
|
||||
|
||||
afi.write_filament_id(fresh, "OFabc123", dry_run=True)
|
||||
afi.rewrite_filament_id(idded, "OFold123", "OFnew456", dry_run=True)
|
||||
with open(fresh, "rb") as f:
|
||||
self.assertEqual(f.read(), self.CRLF_TEXT.encode("utf-8"))
|
||||
with open(idded, "rb") as f:
|
||||
self.assertEqual(f.read(), self.CRLF_WITH_ID.encode("utf-8"))
|
||||
# the edit is still applied and verified in memory: only the write is
|
||||
# skipped, so a stale old id fails just as loudly
|
||||
with self.assertRaises(RuntimeError):
|
||||
afi.rewrite_filament_id(idded, "OFstale1", "OFnew456", dry_run=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --remint
|
||||
# --generate: re-minting non-conformant declarations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRemint(SyntheticTreeCase):
|
||||
@@ -1138,17 +1489,23 @@ class TestRemint(SyntheticTreeCase):
|
||||
a = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(a["filament_id"], want)
|
||||
|
||||
def test_blocked_by_other_triple_occurrence(self):
|
||||
def test_not_blocked_by_a_non_conformant_occurrence(self):
|
||||
# VendorB carries APLA's id under a PETG triple of its own, so that
|
||||
# declaration is wrong and this run's job is to rewrite it. It must not
|
||||
# cost VendorA's APLA the id its triple actually mints.
|
||||
want0 = afi.generate_filament_id(*self.TRIPLE)
|
||||
self.t.add_vendor("VendorB", [
|
||||
preset("BPLA @base", filament_id=want0, instantiation=False,
|
||||
filament_vendor="BV", filament_type="PETG"),
|
||||
])
|
||||
changed, errors, _out = self.t.remint(["VendorA"])
|
||||
self.assertEqual((changed, errors), (1, 0))
|
||||
changed, errors, out = self.t.remint(["VendorA"])
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("declared by VendorB/filament/BPLA @base.json", out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id(*self.TRIPLE, salt=1))
|
||||
self.assertEqual(root["filament_id"], want0)
|
||||
# Widening the run to both bundles clears it.
|
||||
changed, errors, out = self.t.remint(["VendorA", "VendorB"])
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
|
||||
def test_bbl_is_reminted_like_any_vendor(self):
|
||||
self.t.add_vendor("BBL", [
|
||||
@@ -1164,59 +1521,195 @@ class TestRemint(SyntheticTreeCase):
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id("Bambu Lab", "ABS", "Bambu ABS"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --drop-redundant-ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDropRedundantIds(SyntheticTreeCase):
|
||||
def test_drops_only_ofl_riding_same_base_name_declarations(self):
|
||||
# Redundant: same base name, rides the OFL generic, re-declares its id.
|
||||
self.t.write_preset("VendorA", preset("Generic PLA @P2",
|
||||
inherits="Generic PLA @System",
|
||||
compatible_printers=["P2"],
|
||||
filament_id="OGFL99"))
|
||||
# Renamed rider: not dropped (a repoint worksheet decision, not a drop).
|
||||
self.t.write_preset("VendorA", preset("Tuned PLA @P3",
|
||||
inherits="Generic PLA @System",
|
||||
compatible_printers=["P3"],
|
||||
filament_id="OGFL99"))
|
||||
dropped, errors, out = self.t.drop_redundant("VendorA")
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.assertEqual(dropped, 1)
|
||||
self.assertIn('dropped filament_id "OGFL99"', out)
|
||||
self.assertIn('rides OFL "Generic PLA @System"', out)
|
||||
gone = load_json_file(self.t.preset_path("VendorA", "Generic PLA @P2"))
|
||||
self.assertNotIn("filament_id", gone)
|
||||
kept = load_json_file(self.t.preset_path("VendorA", "Tuned PLA @P3"))
|
||||
self.assertEqual(kept["filament_id"], "OGFL99")
|
||||
# vendor-rooted filaments are untouched
|
||||
def test_dry_run_leaves_every_file_untouched(self):
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.remint(["VendorA"], dry_run=True)
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertIn("would rewrite", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
# the real run then makes exactly that one change
|
||||
changed, errors, _out = self.t.remint(["VendorA"])
|
||||
self.assertEqual((changed, errors), (1, 0))
|
||||
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(root["filament_id"], "AX01")
|
||||
|
||||
def test_bbl_is_forbidden(self):
|
||||
dropped, errors, out = self.t.drop_redundant("BBL")
|
||||
self.assertEqual(dropped, 0)
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("forbidden", out)
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id(*self.TRIPLE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --mint CLI
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMintCli(unittest.TestCase):
|
||||
def test_prints_an_of_id(self):
|
||||
class TestCli(unittest.TestCase):
|
||||
"""main(argv) over a synthetic tree. The clean tree's baseline ids are
|
||||
deliberately non-conformant ("AX01"/"OGFL99"), so a --generate run always
|
||||
has both a filament_id rewrite and setting_id inserts to do."""
|
||||
|
||||
def setUp(self):
|
||||
self.t = make_clean_tree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
def test_bare_invocation_prints_help(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(["--mint", "Polymaker/PLA/PolyLite PLA"])
|
||||
rc = afi.main([])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertRegex(buf.getvalue().strip(), r"^OF[0-9A-Za-z]{6}$")
|
||||
self.assertIn("usage:", buf.getvalue())
|
||||
self.assertIn("--generate", buf.getvalue())
|
||||
# ... and so does any invocation naming no mode: help, and no work
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("usage:", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_requires_exactly_three_parts(self):
|
||||
with self.assertRaises(SystemExit), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
afi.main(["--mint", "Vendor/Filament"])
|
||||
def test_another_tree_needs_its_own_snapshot(self):
|
||||
# --profiles retargets the tree, but the sanctioned state of that tree
|
||||
# is not the repo snapshot: checking against it is meaningless and
|
||||
# re-recording into it would overwrite the tracked file.
|
||||
with open(afi.SNAPSHOT_PATH, "rb") as f:
|
||||
repo_snapshot = f.read()
|
||||
for mode in ("--check", "--update-snapshot"):
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
afi.main([mode, "--profiles", self.t.profiles])
|
||||
self.assertEqual(caught.exception.code, 2, mode)
|
||||
with open(afi.SNAPSHOT_PATH, "rb") as f:
|
||||
self.assertEqual(f.read(), repo_snapshot)
|
||||
# Named explicitly, both modes run against that tree.
|
||||
rc, out = self.t.cli("--update-snapshot")
|
||||
self.assertEqual(rc, 0, out)
|
||||
# --generate only reads the snapshot as a list of ids not to reuse, so
|
||||
# it keeps working without one.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(["--dry-run", "--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_filament_id_and_setting_id_together_are_rejected(self):
|
||||
# Each flag's help promises it skips the other kind, so the pair cannot
|
||||
# quietly mean "both".
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
afi.main(["--generate", "--filament-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(caught.exception.code, 2)
|
||||
|
||||
def test_a_run_with_errors_does_not_report_success(self):
|
||||
# An unreadable profile must not be buried under a green summary line.
|
||||
path = self.t.preset_path("VendorA", "APLA @base")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("{ not json")
|
||||
rc, out = self.t.cli("--generate")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("error(s)", out)
|
||||
self.assertNotIn("SUCCESS", out)
|
||||
|
||||
def test_dry_run_alone_previews_generate(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would ", out)
|
||||
self.assertIn("nothing written", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_the_reported_count_is_files_not_edits(self):
|
||||
# A file both passes touch is still one file. "SoloPLA @P1" needs a
|
||||
# filament_id rewrite AND a setting_id insert, so an edit-counting
|
||||
# summary would over-report the tree.
|
||||
self.t.write_preset("VendorA", preset(
|
||||
"SoloPLA @P1", filament_id="AX01", filament_vendor="AVendor",
|
||||
filament_type="PLA", compatible_printers=["P1"]))
|
||||
before = self.t.bytes_map()
|
||||
|
||||
rc, out = self.t.cli("--generate")
|
||||
|
||||
self.assertEqual(rc, 0, out)
|
||||
after = self.t.bytes_map()
|
||||
touched = {p for p in after if after[p] != before.get(p)}
|
||||
self.assertIn("VendorA/filament/SoloPLA @P1.json".replace("/", os.sep), touched)
|
||||
self.assertIn("rewrite VendorA/filament/SoloPLA @P1.json", out)
|
||||
self.assertIn("update VendorA/filament/SoloPLA @P1.json", out)
|
||||
summary = [l for l in out.splitlines() if "file(s) changed" in l]
|
||||
self.assertEqual(len(summary), 1, out)
|
||||
self.assertIn(f"{len(touched)} file(s) changed", summary[0])
|
||||
|
||||
def test_dryrun_is_the_same_flag(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--dryrun")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would ", out) # the same preview, not a silent no-op
|
||||
self.assertIn("nothing written", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_generate_vendor_writes_only_in_that_bundle(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--generate", "--vendor", "VendorA")
|
||||
self.assertEqual(rc, 0, out)
|
||||
after = self.t.bytes_map()
|
||||
changed = sorted(rel for rel in before if after[rel] != before[rel])
|
||||
self.assertTrue(changed, out)
|
||||
for rel in changed:
|
||||
self.assertTrue(rel.startswith("VendorA" + os.sep), rel)
|
||||
# The bundles it spared were not simply already conformant: the
|
||||
# un-narrowed run goes on to write in them too.
|
||||
rc, out = self.t.cli("--generate")
|
||||
self.assertEqual(rc, 0, out)
|
||||
final = self.t.bytes_map()
|
||||
self.assertTrue(any(final[rel] != after[rel] for rel in after
|
||||
if not rel.startswith("VendorA" + os.sep)), out)
|
||||
|
||||
def test_generate_unknown_vendor_returns_1(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--generate", "--vendor", "Nope")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("unknown vendor", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_setting_id_only_leaves_filament_ids_alone(self):
|
||||
rc, out = self.t.cli("--generate", "--setting-id")
|
||||
self.assertEqual(rc, 0, out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(root["filament_id"], "AX01") # not re-minted
|
||||
member = load_json_file(self.t.preset_path("VendorA", "APLA @P1"))
|
||||
self.assertNotIn("filament_id", member)
|
||||
self.assertEqual(member["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament",
|
||||
"APLA @P1"))
|
||||
|
||||
def test_filament_id_only_inserts_no_setting_id(self):
|
||||
rc, out = self.t.cli("--generate", "--filament-id")
|
||||
self.assertEqual(rc, 0, out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id("AVendor", "PLA", "APLA"))
|
||||
for name in ("APLA @base", "APLA @P1"):
|
||||
self.assertNotIn(
|
||||
"setting_id", load_json_file(self.t.preset_path("VendorA", name)))
|
||||
|
||||
def test_check_mode_returns_1_on_errors(self):
|
||||
# What CI keys off: --check exits nonzero when the tree does not match
|
||||
# the snapshot it is validated against.
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--check")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("error(s)", out)
|
||||
self.assertEqual(self.t.bytes_map(), before) # --check never writes
|
||||
|
||||
def test_removed_and_conflicting_flags_are_rejected(self):
|
||||
for argv in (["--remint", "VendorA"], # removed mode
|
||||
["--mint", "A/B/C"], # removed mode
|
||||
["--drop-redundant-ids", "VendorA"], # removed mode
|
||||
["--assign"], # removed mode
|
||||
["--generate", "--check"], # two modes
|
||||
["--vendor", "VendorA"], # narrowing without a mode
|
||||
["--filament-id"], # narrowing without a mode
|
||||
["--check", "--vendor", "VendorA"]): # narrowing on --check
|
||||
with self.subTest(argv=argv):
|
||||
with self.assertRaises(SystemExit) as cm, \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
afi.main([*argv, "--profiles", self.t.profiles])
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1231,6 +1724,13 @@ class TestRealTree(unittest.TestCase):
|
||||
errors = afi.check_filament_ids(REAL_PROFILES)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
|
||||
def test_check_cli_returns_0(self):
|
||||
# The exact CI invocation, return code included.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(["--check"])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_every_instantiated_filament_resolves_an_id(self):
|
||||
analysis = afi.analyze_tree(REAL_PROFILES)
|
||||
self.assertEqual(analysis["missing_effective"], [])
|
||||
@@ -1241,6 +1741,46 @@ class TestRealTree(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReviewFixes(OfCleanTreeCase):
|
||||
def test_one_file_reached_by_two_spellings_counts_once(self):
|
||||
# The filament pass reaches a file through its index sub_path, the
|
||||
# setting_id pass through os.walk. Those two spellings differ whenever
|
||||
# the sub_path is not already normalized - always, on Windows, where
|
||||
# every sub_path keeps the "/" the index stores. One file is one file.
|
||||
# One preset per write path: SoloPLA has no id (inserted), WrongPLA
|
||||
# declares one that is not its own mint (rewritten). Both are
|
||||
# instantiated, so the setting_id pass reaches them too.
|
||||
self.t.write_preset("VendorA", preset("SoloPLA @P1",
|
||||
compatible_printers=["P1 0.4 nozzle"],
|
||||
filament_vendor="SV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset(
|
||||
"WrongPLA @P1", filament_id=afi.generate_filament_id("WV", "PLA", "Nope"),
|
||||
compatible_printers=["P1 0.4 nozzle"],
|
||||
filament_vendor="WV", filament_type="PLA"))
|
||||
for name in ("SoloPLA @P1", "WrongPLA @P1"):
|
||||
self.t.set_sub_path("VendorA", name, f"filament/./{name}.json")
|
||||
touched = set()
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
afi.generate_filament_ids(self.t.profiles, self.t.snapshot,
|
||||
None, False, touched)
|
||||
afi.generate_setting_ids(self.t.profiles, None, False, touched)
|
||||
for name in ("SoloPLA @P1", "WrongPLA @P1"):
|
||||
self.assertEqual([t for t in touched if t.endswith(name + ".json")],
|
||||
[self.t.preset_path("VendorA", name)], sorted(touched))
|
||||
self.assertEqual(sorted(touched),
|
||||
sorted(os.path.normpath(t) for t in touched))
|
||||
|
||||
def test_a_broken_edit_names_the_file(self):
|
||||
# An edit that produces invalid JSON is refused, and the message has to
|
||||
# say which of ~12,000 files it was: a bare JSONDecodeError does not.
|
||||
path = self.t.preset_path("VendorA", "APLA @base")
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
afi._edit_profile(path, lambda text: (text.replace("{", "{,", 1), 1),
|
||||
what="test edit")
|
||||
self.assertIn(path, str(caught.exception))
|
||||
self.assertIn("test edit", str(caught.exception))
|
||||
|
||||
def test_check3_skips_of_id_inherited_from_other_vendor(self):
|
||||
# An OFL filament carries its own minted OF id and a vendor tunes it
|
||||
# correctly (same base name, non-empty printers). The new claim must
|
||||
|
||||
902
scripts/tests/test_setting_id.py
Normal file
902
scripts/tests/test_setting_id.py
Normal file
@@ -0,0 +1,902 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the setting_id half of scripts/orca_id_tool.py (stdlib unittest, no
|
||||
external deps).
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import orca_id_tool as afi # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers: synthetic profile trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def preset(name, instantiation=True, setting_id=None, settings_id=None,
|
||||
filament_id=None, type_name="filament", **extra):
|
||||
"""A preset in the canonical key order the shipped profiles use."""
|
||||
data = {"type": type_name, "name": name, "from": "system"}
|
||||
if setting_id is not None:
|
||||
data["setting_id"] = setting_id
|
||||
if settings_id is not None:
|
||||
data["settings_id"] = settings_id
|
||||
if filament_id is not None:
|
||||
data["filament_id"] = filament_id
|
||||
data["instantiation"] = "true" if instantiation else "false"
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
class SettingTree:
|
||||
"""A throwaway resources/profiles-shaped directory of setting_id-bearing bundles.
|
||||
|
||||
A bundle is a `<vendor>/` directory plus the sibling `<vendor>.json` index
|
||||
that makes list_vendor_names() see it; presets live under filament/,
|
||||
process/ and machine/, the three subdirs generate_setting_ids walks.
|
||||
|
||||
write() also registers filament presets in the index's filament_list, the
|
||||
way a shipped bundle does. generate_setting_ids never reads that list, but
|
||||
generate_filament_ids does: without it the filament half of the tool is a
|
||||
no-op on this tree, and the tests that assert --setting-id leaves
|
||||
filament_ids alone would hold for the wrong reason.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="setting_id_test_")
|
||||
self.profiles = os.path.join(self.dir, "profiles")
|
||||
os.makedirs(self.profiles)
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def index_path(self, vendor):
|
||||
return os.path.join(self.profiles, vendor + ".json")
|
||||
|
||||
def add_vendor(self, vendor):
|
||||
"""Create the bundle dir and its index; idempotent, keeps the list."""
|
||||
for sub in afi.PROFILE_SUBDIRS:
|
||||
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
|
||||
if not os.path.exists(self.index_path(vendor)):
|
||||
self._write_index(vendor, {"name": vendor, "version": "01.00.00.00",
|
||||
"filament_list": []})
|
||||
|
||||
def _write_index(self, vendor, index):
|
||||
with open(self.index_path(vendor), "w", encoding="utf-8",
|
||||
newline="\n") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def register(self, vendor, subdir, name):
|
||||
"""Add a filament preset to the bundle index's filament_list."""
|
||||
with open(self.index_path(vendor), encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
sub_path = os.path.join(subdir, name + ".json").replace(os.sep, "/")
|
||||
index["filament_list"].append({"name": name, "sub_path": sub_path})
|
||||
self._write_index(vendor, index)
|
||||
|
||||
def path(self, vendor, subdir, name):
|
||||
return os.path.join(self.profiles, vendor, subdir, name + ".json")
|
||||
|
||||
def write(self, vendor, subdir, data, name=None):
|
||||
"""Write a preset as indented LF JSON; returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
file_name = name if name is not None else data["name"]
|
||||
path = self.path(vendor, subdir, file_name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
if subdir.split(os.sep)[0] == "filament" and data.get("name"):
|
||||
self.register(vendor, subdir, file_name)
|
||||
return path
|
||||
|
||||
def write_raw(self, vendor, subdir, name, raw):
|
||||
"""Write exact bytes (BOM, CRLF, tabs, broken JSON); returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, subdir, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
return path
|
||||
|
||||
def read(self, vendor, subdir, name):
|
||||
with open(self.path(vendor, subdir, name), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def raw(self, vendor, subdir, name):
|
||||
with open(self.path(vendor, subdir, name), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def bytes_map(self):
|
||||
"""relative path -> bytes, for every file in the tree."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(self.profiles):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
path = os.path.join(root, name)
|
||||
with open(path, "rb") as f:
|
||||
out[os.path.relpath(path, self.profiles)] = f.read()
|
||||
return out
|
||||
|
||||
# -- pipeline wrappers ---------------------------------------------------
|
||||
|
||||
def run(self, vendors=None, dry_run=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_setting_ids(self.profiles, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
def run_filament_ids(self, vendors=None, dry_run=False):
|
||||
"""The OTHER half of --generate, against this tree's own (absent)
|
||||
snapshot, so the id policy never consults the repo's real one."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_filament_ids(
|
||||
self.profiles, os.path.join(self.dir, "filament_id_snapshot.json"),
|
||||
vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
|
||||
class SettingTreeCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = SettingTree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mint: the C++/Python byte-identity contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSettingIdMint(unittest.TestCase):
|
||||
# Copied verbatim from tests/libslic3r/test_preset_setting_id.cpp: the C++
|
||||
# generate_preset_setting_id() recomputes these ids on the fly, so the two
|
||||
# implementations must stay byte-identical.
|
||||
GOLDEN = [
|
||||
("Afinia", "filament", "Afinia ABS @Afinia H400", "TL34qSVkppBvMvgH"),
|
||||
("Afinia", "process", "0.20mm Standard @Afinia H400", "FzmtNsy7XQvpd7w0"),
|
||||
("Afinia", "machine", "Afinia H400 0.4 nozzle", "r4FZagW0S8uoaJPd"),
|
||||
("Anycubic", "filament", "Generic PLA @Anycubic Kobra 2", "YIWGGLQ8Oepd30Fv"),
|
||||
("Creality", "process", "0.16mm Optimal @Creality Ender-3 V3", "2Nrbq8PxssUPBLza"),
|
||||
("Elegoo", "machine", "Elegoo Neptune 4 0.4 nozzle", "69QdWuRQwAZk9rFu"),
|
||||
]
|
||||
|
||||
def test_golden_vectors(self):
|
||||
for vendor, type_name, name, expected in self.GOLDEN:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
afi.generate_preset_setting_id(vendor, type_name, name), expected)
|
||||
|
||||
def test_namespace_and_length_are_frozen(self):
|
||||
# Baked into the C++ side and into every shipped profile; never change it.
|
||||
self.assertEqual(afi.NAMESPACE,
|
||||
uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"))
|
||||
self.assertEqual(afi.SETTING_ID_LENGTH, 16)
|
||||
self.assertEqual(
|
||||
afi.ALPHABET,
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
self.assertEqual(len(afi.ALPHABET), 62)
|
||||
|
||||
def test_format_is_sixteen_alphabet_chars(self):
|
||||
for vendor, type_name, name in [("Creality", "filament", "CR PLA @Ender"),
|
||||
("Elegoo", "machine", "Elegoo Neptune 4"),
|
||||
("拓竹", "filament", "拓竹 PLA @P1")]:
|
||||
with self.subTest(name=name):
|
||||
sid = afi.generate_preset_setting_id(vendor, type_name, name)
|
||||
self.assertEqual(len(sid), afi.SETTING_ID_LENGTH)
|
||||
self.assertTrue(set(sid) <= set(afi.ALPHABET), sid)
|
||||
|
||||
def test_is_the_low_base62_digits_of_the_uuid5(self):
|
||||
# Independent re-implementation of the whole rule, key layout included.
|
||||
for vendor, type_name, name, _expected in self.GOLDEN:
|
||||
u = uuid.uuid5(afi.NAMESPACE, f"{vendor}/{type_name}/{name}")
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = ""
|
||||
for _ in range(afi.SETTING_ID_LENGTH):
|
||||
digits = afi.ALPHABET[n % 62] + digits
|
||||
n //= 62
|
||||
self.assertEqual(afi.generate_preset_setting_id(vendor, type_name, name),
|
||||
digits)
|
||||
|
||||
def test_deterministic(self):
|
||||
a = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
|
||||
self.assertEqual(a, afi.generate_preset_setting_id("VendorX", "filament", "My PLA"))
|
||||
|
||||
def test_every_identity_component_changes_the_id(self):
|
||||
base = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorY", "filament", "My PLA"))
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "process", "My PLA"))
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "filament", "My PETG"))
|
||||
|
||||
def test_key_is_a_flat_slash_join(self):
|
||||
# The mint key is "<vendor>/<type>/<name>" with no escaping, so a "/" in
|
||||
# a component shifts the split — harmless in practice (vendor is a
|
||||
# directory name and type is one of PROFILE_SUBDIRS), but it is what the
|
||||
# C++ side does too and the two must agree byte for byte.
|
||||
self.assertEqual(afi.generate_preset_setting_id("A/B", "filament", "C"),
|
||||
afi.generate_preset_setting_id("A", "B/filament", "C"))
|
||||
|
||||
|
||||
class TestBase62Tail(unittest.TestCase):
|
||||
def test_hand_computed_digits(self):
|
||||
self.assertEqual(afi._base62_tail(0, 4), "0000")
|
||||
self.assertEqual(afi._base62_tail(61, 1), "z") # last alphabet char
|
||||
self.assertEqual(afi._base62_tail(62, 2), "10") # 1*62 + 0
|
||||
self.assertEqual(afi._base62_tail(3843, 2), "zz") # 61*62 + 61
|
||||
self.assertEqual(afi._base62_tail(3907, 3), "111") # 62^2 + 62 + 1
|
||||
|
||||
def test_keeps_only_the_low_digits(self):
|
||||
self.assertEqual(afi._base62_tail(62, 1), "0") # high digit dropped
|
||||
self.assertEqual(afi._base62_tail(3907, 2), "11")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssignment(SettingTreeCase):
|
||||
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
|
||||
# orca_extra_profile_check.py tests `instantiation == "true"` strictly.
|
||||
# Anything looser here would hand an id to a preset the validator calls
|
||||
# a base profile, and the two would fight over it on every run.
|
||||
for name, value in [("Boolean", True), ("Capitalised", "True"),
|
||||
("Numeric", 1), ("Absent", None)]:
|
||||
with self.subTest(instantiation=value):
|
||||
data = {"type": "filament", "name": name, "from": "system"}
|
||||
if value is not None:
|
||||
data["instantiation"] = value
|
||||
self.t.write("VendorA", "filament", data)
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_a_bundle_without_an_index_is_still_assigned(self):
|
||||
# setting_id is a per-file property, and the validator walks every
|
||||
# directory. A bundle whose index has not landed yet must be fixable,
|
||||
# or the validator flags files this tool refuses to touch.
|
||||
path = os.path.join(self.t.profiles, "Noindex", "process", "Q.json")
|
||||
os.makedirs(os.path.dirname(path))
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(preset("Q", type_name="process"), f, indent=4)
|
||||
self.assertFalse(os.path.exists(self.t.index_path("Noindex")))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["setting_id"],
|
||||
afi.generate_preset_setting_id("Noindex", "process", "Q"))
|
||||
|
||||
def test_assigns_across_every_profile_subdir(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "machine",
|
||||
preset("P1 0.4 nozzle", type_name="machine"))
|
||||
# os.walk recursion: a preset in a nested directory is walked too.
|
||||
self.t.write("VendorA", os.path.join("filament", "nested"),
|
||||
preset("A PETG @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (4, 0), out)
|
||||
for subdir, type_name, name in [
|
||||
("filament", "filament", "A PLA @P1"),
|
||||
("process", "process", "0.20mm Standard @P1"),
|
||||
("machine", "machine", "P1 0.4 nozzle"),
|
||||
(os.path.join("filament", "nested"), "filament", "A PETG @P1")]:
|
||||
self.assertEqual(
|
||||
self.t.read("VendorA", subdir, name)["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", type_name, name),
|
||||
msg=name)
|
||||
|
||||
def test_type_comes_from_the_subdirectory_not_the_type_field(self):
|
||||
# The subdir name is the type name (Preset::get_type_string()); a stale
|
||||
# "type" field inside the file does not enter the id.
|
||||
self.t.write("VendorA", "process", preset("Odd @P1", type_name="filament"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorA", "process", "Odd @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "process", "Odd @P1"))
|
||||
|
||||
def test_idempotent(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "filament", preset("A PLA @base", instantiation=False,
|
||||
setting_id="LEFTOVER00000000"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (3, 0), out)
|
||||
|
||||
after_first = self.t.bytes_map()
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), after_first)
|
||||
|
||||
def test_stale_value_is_replaced_in_place(self):
|
||||
path = self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", setting_id="0000000000000000",
|
||||
filament_id="OFabc123"))
|
||||
before = self.t.raw("VendorA", "filament", "A PLA @P1")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
want)
|
||||
raw = self.t.raw("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(raw.count(b'"setting_id"'), 1) # replaced, not appended
|
||||
self.assertEqual(raw, before.replace(b'"0000000000000000"',
|
||||
b'"%s"' % want.encode()))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_missing_value_is_inserted_before_filament_id(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", filament_id="OFabc123"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
text = self.t.raw("VendorA", "filament", "A PLA @P1").decode("utf-8")
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
|
||||
self.assertIn(f'"setting_id": "{want}"', text)
|
||||
self.assertLess(text.index('"setting_id"'), text.index('"filament_id"'))
|
||||
|
||||
def test_base_profiles_are_stripped(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
setting_id="0000000000000000", filament_id="OFabc123"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @base")
|
||||
self.assertNotIn("setting_id", data)
|
||||
self.assertEqual(data["filament_id"], "OFabc123") # nothing else touched
|
||||
self.assertEqual(data["instantiation"], "false")
|
||||
|
||||
def test_base_profile_without_instantiation_key_is_stripped(self):
|
||||
# No "instantiation" key at all == not instantiated (str(None) != "true").
|
||||
data = {"type": "filament", "name": "A PLA @root", "from": "system",
|
||||
"setting_id": "0000000000000000"}
|
||||
self.t.write("VendorA", "filament", data)
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "A PLA @root"))
|
||||
|
||||
def test_misspelled_settings_id_is_dropped(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
settings_id="0000000000000000"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @base")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertNotIn("setting_id", data)
|
||||
|
||||
def test_typo_drop_and_assignment_are_one_file_change(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", settings_id="0000000000000000",
|
||||
filament_id="OFabc123"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out) # ONE counted file change
|
||||
data = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
|
||||
def test_reserved_vendor_keeps_instantiated_ids_and_loses_base_ones(self):
|
||||
# BBL owns the authoritative "G*" cloud id space: its instantiated
|
||||
# presets are never rewritten, its base declarations still are stripped.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu ABS @BBL A1", setting_id="GFSB00_07"))
|
||||
self.t.write("BBL", "filament", preset("Bambu ABS @P1 no id"))
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu ABS @base", instantiation=False,
|
||||
setting_id="GFSB00_00"))
|
||||
kept = self.t.raw("BBL", "filament", "Bambu ABS @BBL A1")
|
||||
kept_idless = self.t.raw("BBL", "filament", "Bambu ABS @P1 no id")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out) # only the base profile
|
||||
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @BBL A1"), kept)
|
||||
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @P1 no id"),
|
||||
kept_idless)
|
||||
self.assertNotIn("setting_id",
|
||||
self.t.read("BBL", "filament", "Bambu ABS @base"))
|
||||
|
||||
def test_reserved_vendors_misspelled_key_is_corrected_not_dropped(self):
|
||||
# A reserved vendor's id is authoritative, so there is no formula to
|
||||
# fall back on. Dropping the typo and stopping there would leave the
|
||||
# preset with no setting_id at all and no way for the tool to give it
|
||||
# one - a validator error nothing can clear. Fix the key, keep the value.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu PLA @P1", settings_id="GFSA00_01"))
|
||||
# A base profile still just loses the key; it may not carry an id.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu PLA @base", instantiation=False,
|
||||
settings_id="GFSA00_00"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (2, 0), out)
|
||||
fixed = self.t.read("BBL", "filament", "Bambu PLA @P1")
|
||||
self.assertNotIn("settings_id", fixed)
|
||||
self.assertEqual(fixed["setting_id"], "GFSA00_01")
|
||||
base = self.t.read("BBL", "filament", "Bambu PLA @base")
|
||||
self.assertNotIn("settings_id", base)
|
||||
self.assertNotIn("setting_id", base)
|
||||
# Idempotent: the corrected file is what the next run expects to see.
|
||||
self.assertEqual(self.t.run()[:2], (0, 0))
|
||||
|
||||
def test_a_managed_vendors_misspelled_key_is_still_replaced_by_the_mint(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", settings_id="whatever"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
|
||||
def test_reserved_vendors_constant(self):
|
||||
self.assertEqual(afi.RESERVED_VENDORS, {"BBL"})
|
||||
self.assertEqual(afi.PROFILE_SUBDIRS, ("filament", "process", "machine"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --vendor narrowing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVendorNarrowing(SettingTreeCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorB", "filament", preset("B PLA @P1"))
|
||||
|
||||
def test_restricts_writes_to_the_named_vendor(self):
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run(vendors=["VendorA"])
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
self.assertEqual(self.t.raw("VendorB", "filament", "B PLA @P1"),
|
||||
before[os.path.join("VendorB", "filament", "B PLA @P1.json")])
|
||||
# ... and the vendor left out is written by a later run, unchanged in kind.
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorB", "filament", "B PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorB", "filament", "B PLA @P1"))
|
||||
|
||||
def test_unknown_vendor_reports_and_writes_nothing(self):
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run(vendors=["Nope"])
|
||||
|
||||
self.assertEqual((changed, errors), (0, 1))
|
||||
self.assertIn("Nope", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_unknown_vendor_blocks_the_known_ones_too(self):
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, _out = self.t.run(vendors=["VendorA", "Nope"])
|
||||
self.assertEqual((changed, errors), (0, 1))
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dry-run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRun(SettingTreeCase):
|
||||
def test_writes_nothing_and_previews_the_real_run(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
setting_id="0000000000000000"))
|
||||
before = self.t.bytes_map()
|
||||
|
||||
dry_changed, dry_errors, out = self.t.run(dry_run=True)
|
||||
|
||||
self.assertEqual((dry_changed, dry_errors), (3, 0), out)
|
||||
self.assertIn("would", out)
|
||||
self.assertEqual(self.t.bytes_map(), before) # nothing written
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (dry_changed, dry_errors), out)
|
||||
self.assertNotEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# byte preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBytePreservation(SettingTreeCase):
|
||||
CRLF_TEXT = (
|
||||
'{\r\n'
|
||||
'\t"type": "filament",\r\n'
|
||||
'\t"name": "CRLF PLA @P1",\r\n'
|
||||
'\t"from": "system",\r\n'
|
||||
'\t"filament_id": "OFabc123",\r\n'
|
||||
'\t"instantiation": "true",\r\n'
|
||||
'\t"filament_type": [\r\n'
|
||||
'\t\t"PLA"\r\n'
|
||||
'\t]\r\n'
|
||||
'}\r\n'
|
||||
)
|
||||
|
||||
def test_crlf_and_tab_indentation_survive(self):
|
||||
self.t.write_raw("VendorA", "filament", "CRLF PLA @P1",
|
||||
self.CRLF_TEXT.encode("utf-8"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", "CRLF PLA @P1")
|
||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "CRLF PLA @P1")
|
||||
inserted = ('\t"setting_id": "%s",\r\n' % want).encode("utf-8")
|
||||
# Every original byte survives: dropping the inserted line restores the file.
|
||||
self.assertEqual(raw.replace(inserted, b"", 1),
|
||||
self.CRLF_TEXT.encode("utf-8"))
|
||||
|
||||
def test_bom_survives(self):
|
||||
raw_in = b"\xef\xbb\xbf" + json.dumps(
|
||||
preset("BOM PLA @P1", filament_id="OFabc123"),
|
||||
indent=4, ensure_ascii=False).encode("utf-8") + b"\n"
|
||||
self.t.write_raw("VendorA", "filament", "BOM PLA @P1", raw_in)
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", "BOM PLA @P1")
|
||||
self.assertTrue(raw.startswith(b"\xef\xbb\xbf"))
|
||||
self.assertEqual(raw.count(b"\xef\xbb\xbf"), 1)
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "BOM PLA @P1")
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "BOM PLA @P1")["setting_id"],
|
||||
want)
|
||||
self.assertEqual(
|
||||
raw.replace((' "setting_id": "%s",\n' % want).encode("utf-8"), b"", 1),
|
||||
raw_in)
|
||||
|
||||
def test_non_ascii_name_round_trips(self):
|
||||
name = "拓竹 PLA @P1 0.4 nozzle"
|
||||
raw_in = (json.dumps(preset(name, filament_id="OFabc123"), indent=4,
|
||||
ensure_ascii=False).encode("utf-8") + b"\n")
|
||||
self.t.write_raw("VendorA", "filament", name, raw_in)
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", name)
|
||||
self.assertIn(name.encode("utf-8"), raw) # not escaped to \uXXXX
|
||||
data = self.t.read("VendorA", "filament", name)
|
||||
self.assertEqual(data["name"], name)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", name))
|
||||
|
||||
def test_surrounding_formatting_is_untouched_on_a_strip(self):
|
||||
text = ('{\r\n'
|
||||
'\t"type": "filament",\r\n'
|
||||
'\t"name": "Odd @base",\r\n'
|
||||
'\t"setting_id": "0000000000000000",\r\n'
|
||||
'\t"instantiation": "false",\r\n'
|
||||
'\t"compatible_printers": []\r\n'
|
||||
'}\r\n')
|
||||
self.t.write_raw("VendorA", "filament", "Odd @base", text.encode("utf-8"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(
|
||||
self.t.raw("VendorA", "filament", "Odd @base").decode("utf-8"),
|
||||
text.replace('\t"setting_id": "0000000000000000",\r\n', "", 1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# byte-preserving key edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInsertAnchor(unittest.TestCase):
|
||||
WITH_BOTH = ('{\n "name": "K @P1",\n "filament_id": "OFabc123",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
WITH_INSTANTIATION = ('{\n "name": "K @P1",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
|
||||
def test_inserts_before_filament_id(self):
|
||||
text, n = afi.insert_setting_id(self.WITH_BOTH, "0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertEqual(text, self.WITH_BOTH.replace(
|
||||
' "filament_id"',
|
||||
' "setting_id": "0123456789abcdef",\n "filament_id"', 1))
|
||||
|
||||
def test_falls_back_to_instantiation(self):
|
||||
text, n = afi.insert_setting_id(self.WITH_INSTANTIATION, "0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertIn('"setting_id": "0123456789abcdef",\n "instantiation"', text)
|
||||
|
||||
def test_falls_back_to_name(self):
|
||||
# Last-resort anchor: every preset has a name, so the insert does not
|
||||
# depend on filament_id having been written first — a dry run, which
|
||||
# writes none, must reach the same verdict as the real run.
|
||||
text, n = afi.insert_setting_id('{\n "name": "K",\n "x": 1\n}\n',
|
||||
"0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertIn('"name": "K",\n "setting_id": "0123456789abcdef",', text)
|
||||
|
||||
def test_no_anchor_returns_zero(self):
|
||||
src = '{\n "type": "filament"\n}\n'
|
||||
text, n = afi.insert_setting_id(src, "0123456789abcdef")
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(text, src)
|
||||
|
||||
|
||||
class TestKeyLineHelpers(unittest.TestCase):
|
||||
def test_delete_trailing_comma_form(self):
|
||||
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
|
||||
' "instantiation": "false"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
|
||||
self.assertEqual(out, text.replace(
|
||||
' "setting_id": "OLD0000000000000",\n', "", 1))
|
||||
|
||||
def test_delete_last_property_form_consumes_the_preceding_comma(self):
|
||||
text = ('{\n "name": "K",\n "instantiation": "false",\n'
|
||||
' "setting_id": "OLD0000000000000"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
|
||||
self.assertEqual(out, '{\n "name": "K",\n "instantiation": "false"\n}\n')
|
||||
|
||||
def test_delete_requires_the_exact_old_value(self):
|
||||
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
|
||||
' "instantiation": "false"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id", old_value="OTHER")
|
||||
self.assertEqual((out, n), (text, 0))
|
||||
_out, n = afi.delete_key_line(text, "setting_id",
|
||||
old_value="OLD0000000000000")
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
def test_delete_missing_key_returns_zero(self):
|
||||
text = '{\n "name": "K"\n}\n'
|
||||
self.assertEqual(afi.delete_key_line(text, "setting_id"), (text, 0))
|
||||
|
||||
def test_delete_does_not_confuse_the_two_spellings(self):
|
||||
text = ('{\n "name": "K",\n "settings_id": "TYPO000000000000",\n'
|
||||
' "setting_id": "REAL000000000000",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "settings_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out)["setting_id"], "REAL000000000000")
|
||||
|
||||
def test_replace_refuses_a_stale_old_value(self):
|
||||
text = '{\n "setting_id": "OLD0000000000000"\n}\n'
|
||||
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
|
||||
old_value="NOTTHIS000000000")
|
||||
self.assertEqual((out, n), (text, 0))
|
||||
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
|
||||
old_value="OLD0000000000000")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out)["setting_id"], "NEW0000000000000")
|
||||
|
||||
def test_insert_key_line_prefers_before_over_after(self):
|
||||
text = ('{\n "name": "K",\n "filament_id": "OFabc123",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
out, n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("filament_id", "instantiation"),
|
||||
after=("name",))
|
||||
self.assertEqual(n, 1)
|
||||
self.assertLess(out.index('"setting_id"'), out.index('"filament_id"'))
|
||||
self.assertGreater(out.index('"setting_id"'), out.index('"name"'))
|
||||
# The `before` tuple's own order decides, not the order in the file.
|
||||
out, _n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("instantiation", "filament_id"))
|
||||
self.assertGreater(out.index('"setting_id"'), out.index('"filament_id"'))
|
||||
|
||||
def test_insert_key_line_falls_back_to_after(self):
|
||||
text = '{\n "name": "K",\n "from": "system"\n}\n'
|
||||
out, n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("filament_id",), after=("name",))
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(out, '{\n "name": "K",\n "setting_id": "V",\n'
|
||||
' "from": "system"\n}\n')
|
||||
|
||||
def test_insert_key_line_without_any_anchor(self):
|
||||
text = '{\n "from": "system"\n}\n'
|
||||
self.assertEqual(
|
||||
afi.insert_key_line(text, "setting_id", "V", before=("filament_id",),
|
||||
after=("name",)),
|
||||
(text, 0))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# error paths: reported and counted, never raised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorPaths(SettingTreeCase):
|
||||
def test_unparsable_profile_is_reported_and_the_run_continues(self):
|
||||
broken = b'{\n "name": "Broken @P1",\n oops\n}\n'
|
||||
self.t.write_raw("VendorA", "filament", "Broken @P1", broken)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Broken @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Broken @P1"), broken)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_non_object_top_level_is_reported(self):
|
||||
raw = b'[\n {"name": "K"}\n]\n'
|
||||
self.t.write_raw("VendorA", "filament", "List @P1", raw)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("List @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "List @P1"), raw)
|
||||
|
||||
def test_nameless_instantiated_preset_is_reported(self):
|
||||
nameless = {"type": "filament", "from": "system", "instantiation": "true"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless")
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
before = self.t.raw("VendorA", "filament", "Nameless")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Nameless", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Nameless"), before)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_a_nameless_preset_still_gets_its_misspelled_key_dropped(self):
|
||||
# The nameless-preset error must not abandon an edit already queued for
|
||||
# the same file: leaving "settings_id" behind would keep the validator
|
||||
# red with no way for this tool to clear it.
|
||||
nameless = {"type": "filament", "from": "system", "settings_id": "JUNK123",
|
||||
"instantiation": "true"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertNotIn("settings_id", self.t.read("VendorA", "filament", "Nameless"))
|
||||
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "Nameless"))
|
||||
self.assertIn('misspelled "settings_id" dropped : 1', out)
|
||||
|
||||
def test_an_unanchorable_file_is_reported_not_raised(self):
|
||||
# One oddly formatted profile must not abort the pass over all the
|
||||
# others, and it must fail the same way in a dry run as in a real one.
|
||||
raw = b'{ "type":"filament", "name":"Flat @P1", "instantiation":"true" }\n'
|
||||
self.t.write_raw("VendorA", "filament", "Flat @P1", raw)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
dry_changed, dry_errors, dry_out = self.t.run(dry_run=True)
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((dry_changed, dry_errors), (changed, errors), dry_out)
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Flat @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Flat @P1"), raw)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_nameless_base_preset_is_fine(self):
|
||||
# Only instantiated presets need an identity; a nameless base profile is
|
||||
# simply left alone.
|
||||
nameless = {"type": "filament", "from": "system", "instantiation": "false"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless base")
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
|
||||
class TestRealTree(unittest.TestCase):
|
||||
def test_shipped_tree_needs_no_setting_id_change(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_setting_ids(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual((changed, errors), (0, 0), buf.getvalue())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCli(SettingTreeCase):
|
||||
def main(self, argv):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(argv)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def test_setting_id_only_run_touches_no_filament_id(self):
|
||||
# "OFZZZZZZ" is not the mint of the preset's own triple, so the filament
|
||||
# half of --generate has real work waiting on this tree (proven at the
|
||||
# end): leaving the id alone is the narrowing's doing, not an idle tree.
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", filament_id="OFZZZZZZ",
|
||||
filament_vendor=["AV"], filament_type=["PLA"]))
|
||||
self.t.write("VendorA", "machine",
|
||||
preset("P1 0.4 nozzle", type_name="machine"))
|
||||
|
||||
rc, out = self.main(["--generate", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
|
||||
self.assertEqual(rc, 0, out)
|
||||
filament = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(filament["filament_id"], "OFZZZZZZ")
|
||||
self.assertEqual(filament["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
machine = self.t.read("VendorA", "machine", "P1 0.4 nozzle")
|
||||
self.assertNotIn("filament_id", machine)
|
||||
self.assertEqual(machine["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "machine", "P1 0.4 nozzle"))
|
||||
# ... and the skipped half does re-mint that id when it is allowed to run.
|
||||
changed, errors, out = self.t.run_filament_ids()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(
|
||||
self.t.read("VendorA", "filament", "A PLA @P1")["filament_id"],
|
||||
afi.generate_filament_id("AV", "PLA", "A PLA"))
|
||||
|
||||
def test_dry_run_setting_id_writes_nothing(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.main(["--generate", "--setting-id", "--dry-run",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("1 file(s) would change", out) # there WAS one to write
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
# the real run then writes exactly it
|
||||
rc, out = self.main(["--generate", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("1 file(s) changed", out)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament",
|
||||
"A PLA @P1"))
|
||||
|
||||
def test_setting_id_without_generate_is_a_usage_error(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
self.assertRaises(SystemExit) as cm:
|
||||
afi.main(["--setting-id", "--profiles", self.t.profiles])
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate resources/printers/bambu_filament_ids.json: the map from Orca's
|
||||
content-addressed filament_id ("OF" + 6 base62 chars, see assign_filament_ids.py)
|
||||
content-addressed filament_id ("OF" + 6 base62 chars, see orca_id_tool.py)
|
||||
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
|
||||
products Bambu ships.
|
||||
|
||||
@@ -54,7 +54,7 @@ import tempfile
|
||||
import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from assign_filament_ids import ( # noqa: E402
|
||||
from orca_id_tool import ( # noqa: E402
|
||||
BAMBU_MAP_PATH,
|
||||
OFL,
|
||||
OF_ID_RE,
|
||||
|
||||
@@ -545,7 +545,7 @@ std::string generate_preset_setting_id(const std::string& vendor, const std::str
|
||||
return "";
|
||||
|
||||
// Dedicated namespace for preset setting_ids, distinct from the cloud per-user
|
||||
// namespace (OrcaCloudServiceAgent). Keep in sync with scripts/assign_vendor_setting_ids.py;
|
||||
// namespace (OrcaCloudServiceAgent). Keep in sync with scripts/orca_id_tool.py;
|
||||
// never change this constant.
|
||||
static const boost::uuids::uuid vendor_namespace =
|
||||
boost::uuids::string_generator()("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f");
|
||||
|
||||
@@ -93,8 +93,8 @@ class PresetBundle;
|
||||
|
||||
// Deterministic preset setting_id: uuid5(vendor/type/name) -> 16 base62 chars.
|
||||
// Pure function of a system preset's identity, so the value can be assigned by
|
||||
// scripts/assign_vendor_setting_ids.py and recomputed here when a profile ships
|
||||
// without it. MUST stay byte-identical to scripts/assign_vendor_setting_ids.py.
|
||||
// scripts/orca_id_tool.py and recomputed here when a profile ships without it.
|
||||
// MUST stay byte-identical to scripts/orca_id_tool.py.
|
||||
// This is NOT the per-user cloud-sync setting_id
|
||||
// (OrcaCloudServiceAgent::generate_uuid_for_setting_id) - do not conflate them.
|
||||
std::string generate_preset_setting_id(const std::string& vendor,
|
||||
|
||||
@@ -5293,8 +5293,8 @@ std::string PresetBundle::load_vendor_preset(
|
||||
loaded.description = entry.description;
|
||||
loaded.setting_id = entry.setting_id;
|
||||
// Derive the preset setting_id on the fly when a profile ships without one,
|
||||
// matching scripts/assign_vendor_setting_ids.py. Only instantiated presets
|
||||
// carry an id; non-instantiated base profiles return earlier above. This never
|
||||
// matching scripts/orca_id_tool.py. Only instantiated presets carry an id;
|
||||
// non-instantiated base profiles return earlier above. This never
|
||||
// touches the per-user cloud-sync setting_id written into user .info files.
|
||||
if (loaded.setting_id.empty() && entry.instantiation == "true")
|
||||
loaded.setting_id = generate_preset_setting_id(
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
using namespace Slic3r;
|
||||
|
||||
// Golden vectors from the Python reference generate_preset_setting_id (defined in
|
||||
// scripts/assign_vendor_setting_ids.py). The C++ generate_preset_setting_id() MUST stay
|
||||
// byte-identical to it, otherwise app-side on-the-fly ids would diverge from the
|
||||
// scripts/orca_id_tool.py). The C++ generate_preset_setting_id() MUST stay byte-identical
|
||||
// to it, otherwise app-side on-the-fly ids would diverge from the
|
||||
// script-assigned ones in the profiles. Regenerate a vector with:
|
||||
// python3 -c "from assign_vendor_setting_ids import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))"
|
||||
// python3 -c "import sys; sys.path.insert(0, 'scripts'); from orca_id_tool import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))"
|
||||
TEST_CASE("preset setting_id matches the Python reference", "[Preset][setting_id]") {
|
||||
struct Vec { const char* vendor; const char* type; const char* name; const char* expected; };
|
||||
const Vec vectors[] = {
|
||||
|
||||
Reference in New Issue
Block a user