mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-25 01:40:57 +00:00
Run every profile maintenance job from one tool (#15726)
* Run every profile maintenance job from one tool orca_id_tool.py becomes orca_profile_tool.py, and orca_extra_profile_check.py and orca_filament_lib.py fold into it as subcommands: check, generate-id, fix, trim, update-index and update-snapshot. The three scripts already overlapped -- the checker imported half of its rules from the id tool, which in turn kept a copy-pasted set of output helpers to avoid the resulting import cycle -- while disagreeing on how a vendor is enumerated, how a JSON file is read and what the exit code means. One file settles all three. check, normalize, trim and update-index reproduce their predecessors exactly; normalize and update-index were diffed byte-for-byte against the old scripts over a copy of the whole tree. Deliberate changes: the compatible-printers check no longer switches itself off when --check-materials is passed, an error exits 1 rather than -1, update-index honours --profile-type and reports a profile it cannot place instead of dropping it from the index, fix and update-index gained --dry-run, trim keeps an unindexed file some surviving profile still inherits from, and vendors are enumerated as directories with an index -- which is why blacklist.json, a data file that an unscoped index rebuild once wrote four empty list sections into, loses them here and will not collect them again. The dead rename_filament_system() helper is gone. The suite under scripts/tests now covers the maintenance commands too, and CI runs it; nothing there ran in CI before. No shipped profile data changes apart from those four keys. * update vendor index files with "python3 ./scripts/orca_profile_tool.py update-index" and "python3 ./scripts/orca_profile_tool.py normalize"
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/orca_id_tool.py (stdlib unittest, no external deps).
|
||||
"""Tests for scripts/orca_profile_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 orca_id_tool as afi # noqa: E402
|
||||
import orca_profile_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__), "..", ".."))
|
||||
@@ -164,12 +164,14 @@ class SyntheticTree:
|
||||
changed, errors = afi.generate_filament_ids(self.profiles, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
def cli(self, *flags):
|
||||
def cli(self, *argv):
|
||||
"""Run main() against this tree, capturing stdout."""
|
||||
flags = [*argv, "--profiles", self.profiles]
|
||||
if argv and argv[0] in ("check", "update-snapshot"):
|
||||
flags += ["--snapshot", self.snapshot]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main([*flags, "--profiles", self.profiles,
|
||||
"--snapshot", self.snapshot])
|
||||
rc = afi.main(flags)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
@@ -426,26 +428,6 @@ class TestTripleResolution(unittest.TestCase):
|
||||
("MyVendor", "PLA", "MyPLA"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reserved namespaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReservedSpaces(unittest.TestCase):
|
||||
def test_owners(self):
|
||||
# Bambu AMS/RFID catalog: reserved, but no vendor (not even BBL) may declare it
|
||||
self.assertEqual(afi.reserved_space_owner("GFL99"), (True, None))
|
||||
# Qidi device protocol: reserved, but no vendor may declare it
|
||||
self.assertEqual(afi.reserved_space_owner("QD_X4_PLA"), (True, None))
|
||||
self.assertEqual(afi.reserved_space_owner("P1234abc"), (True, None))
|
||||
self.assertEqual(afi.reserved_space_owner("pAbCdEf1"), (True, None)) # case-insensitive
|
||||
self.assertEqual(afi.reserved_space_owner("null"), (True, None))
|
||||
self.assertEqual(afi.reserved_space_owner("OF5CgdDq"), (False, None))
|
||||
self.assertEqual(afi.reserved_space_owner("P1234abcd"), (False, None)) # 8 hex chars: not the user space
|
||||
|
||||
def test_gf_is_reserved_and_ownerless(self):
|
||||
self.assertEqual(afi.reserved_space_owner("GFA00"), (True, None))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checks on synthetic trees
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -474,7 +456,7 @@ class TestChecks(OfCleanTreeCase):
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn('claim "VendorA/ANEW" is not sanctioned', out)
|
||||
self.assertIn("--update-snapshot", out)
|
||||
self.assertIn("update-snapshot", out)
|
||||
|
||||
def test_check2_vanished_claim_is_stability_error(self):
|
||||
self.t.remove_preset("VendorA", "APLA @P1")
|
||||
@@ -669,11 +651,12 @@ class TestChecks(OfCleanTreeCase):
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("does not match the mint of its triple", out)
|
||||
|
||||
def test_check4_reserved_namespace_claims(self):
|
||||
for fid, marker in [("GFX99", "Bambu AMS/RFID catalog"),
|
||||
("QD_X_PLA", "composed by the device"),
|
||||
("P1a2b3c4", "user-custom"),
|
||||
("null", "user-custom")]:
|
||||
def test_check1_an_id_another_system_composed_is_not_a_mint(self):
|
||||
# Nothing is reserved because nothing is exempt: an id some other system
|
||||
# composes for its own purposes - Bambu's catalog, a Qidi box, the dialog
|
||||
# that creates a user filament - is simply not the mint of a triple, and
|
||||
# check 1 rejects it for that and nothing else.
|
||||
for fid in ("GFX99", "QD_X_PLA", "P1a2b3c4", "null"):
|
||||
with self.subTest(fid=fid):
|
||||
name = f"R{fid} @base"
|
||||
self.t.write_preset("VendorA", preset(name, filament_id=fid,
|
||||
@@ -684,8 +667,8 @@ class TestChecks(OfCleanTreeCase):
|
||||
compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("reserved id space", out)
|
||||
self.assertIn(marker, out)
|
||||
self.assertIn(f'filament_id "{fid}"', out)
|
||||
self.assertIn('is not a minted "OF" id', out)
|
||||
|
||||
def test_check3c_unresolvable_instantiated_filament(self):
|
||||
self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"]))
|
||||
@@ -713,7 +696,7 @@ class TestCheck5(OfCleanTreeCase):
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("resolves empty filament_vendor", out)
|
||||
self.assertIn('filament_vendor "Generic"', out)
|
||||
# No grandfathering: sanctioning the tree does not silence check 5a.
|
||||
# No grandfathering: sanctioning the tree does not silence check 4a.
|
||||
rc, _out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
errors, out = self.t.check()
|
||||
@@ -736,7 +719,7 @@ class TestCheck5(OfCleanTreeCase):
|
||||
self.assertIn("divergent triples", out)
|
||||
self.assertIn("MV/PLA/MPLA", out)
|
||||
self.assertIn("MV/PETG/MPLA", out)
|
||||
# No grandfathering: sanctioning the tree does not silence check 5b.
|
||||
# No grandfathering: sanctioning the tree does not silence check 4b.
|
||||
rc, _out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
errors, out = self.t.check()
|
||||
@@ -922,20 +905,23 @@ class TestUpdateSnapshot(SyntheticTreeCase):
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before) # nothing written on refusal
|
||||
|
||||
def test_refuses_reserved_namespace_ids(self):
|
||||
def test_records_an_id_it_cannot_defend_and_lets_check_reject_it(self):
|
||||
# update-snapshot records state, it does not judge ids: a foreign id
|
||||
# lands in the diff a maintainer reviews, and fails check 1 straight
|
||||
# after. Sanctioning it does not grandfather it.
|
||||
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="GFX99",
|
||||
instantiation=False,
|
||||
filament_vendor="CV",
|
||||
filament_type="PLA"))
|
||||
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
before = f.read()
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("refusing to sanction", out)
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before) # nothing written on refusal
|
||||
self.assertEqual(rc, 0, out)
|
||||
with open(self.t.snapshot, encoding="utf-8") as f:
|
||||
self.assertIn("GFX99", json.load(f)["ids"])
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn('is not a minted "OF" id', out)
|
||||
|
||||
def test_dry_run_reports_without_writing(self):
|
||||
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
|
||||
@@ -1536,7 +1522,7 @@ class TestRemint(SyntheticTreeCase):
|
||||
|
||||
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
|
||||
deliberately non-conformant ("AX01"/"OGFL99"), so a generate-id run always
|
||||
has both a filament_id rewrite and setting_id inserts to do."""
|
||||
|
||||
def setUp(self):
|
||||
@@ -1544,17 +1530,17 @@ class TestCli(unittest.TestCase):
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
def test_bare_invocation_prints_help(self):
|
||||
# Naming no command is not an error: it is how you find out what the
|
||||
# commands are, and it must never be mistaken for a run that did work.
|
||||
before = self.t.bytes_map()
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main([])
|
||||
self.assertEqual(rc, 0)
|
||||
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)
|
||||
for command in ("check", "generate-id", "normalize", "trim", "update-index",
|
||||
"update-snapshot"):
|
||||
self.assertIn(command, buf.getvalue())
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_another_tree_needs_its_own_snapshot(self):
|
||||
@@ -1563,20 +1549,20 @@ class TestCli(unittest.TestCase):
|
||||
# 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"):
|
||||
for command 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)
|
||||
afi.main([command, "--profiles", self.t.profiles])
|
||||
self.assertEqual(caught.exception.code, 2, command)
|
||||
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")
|
||||
# Named explicitly, both commands run against that tree.
|
||||
rc, out = self.t.cli("update-snapshot")
|
||||
self.assertEqual(rc, 0, out)
|
||||
# --generate never reads the snapshot, so it keeps working without one.
|
||||
# generate-id never reads the snapshot, so it keeps working without one.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(["--dry-run", "--profiles", self.t.profiles])
|
||||
rc = afi.main(["generate-id", "--dry-run", "--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_filament_id_and_setting_id_together_are_rejected(self):
|
||||
@@ -1584,7 +1570,7 @@ class TestCli(unittest.TestCase):
|
||||
# quietly mean "both".
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
afi.main(["--generate", "--filament-id", "--setting-id",
|
||||
afi.main(["generate-id", "--filament-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(caught.exception.code, 2)
|
||||
|
||||
@@ -1593,14 +1579,14 @@ class TestCli(unittest.TestCase):
|
||||
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")
|
||||
rc, out = self.t.cli("generate-id")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("error(s)", out)
|
||||
self.assertNotIn("SUCCESS", out)
|
||||
|
||||
def test_dry_run_alone_previews_generate(self):
|
||||
def test_dry_run_previews_generate_id(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--dry-run")
|
||||
rc, out = self.t.cli("generate-id", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would ", out)
|
||||
self.assertIn("nothing written", out)
|
||||
@@ -1615,7 +1601,7 @@ class TestCli(unittest.TestCase):
|
||||
filament_type="PLA", compatible_printers=["P1"]))
|
||||
before = self.t.bytes_map()
|
||||
|
||||
rc, out = self.t.cli("--generate")
|
||||
rc, out = self.t.cli("generate-id")
|
||||
|
||||
self.assertEqual(rc, 0, out)
|
||||
after = self.t.bytes_map()
|
||||
@@ -1629,7 +1615,7 @@ class TestCli(unittest.TestCase):
|
||||
|
||||
def test_dryrun_is_the_same_flag(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--dryrun")
|
||||
rc, out = self.t.cli("generate-id", "--dryrun")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would ", out) # the same preview, not a silent no-op
|
||||
self.assertIn("nothing written", out)
|
||||
@@ -1637,7 +1623,7 @@ class TestCli(unittest.TestCase):
|
||||
|
||||
def test_generate_vendor_writes_only_in_that_bundle(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--generate", "--vendor", "VendorA")
|
||||
rc, out = self.t.cli("generate-id", "--vendor", "VendorA")
|
||||
self.assertEqual(rc, 0, out)
|
||||
after = self.t.bytes_map()
|
||||
changed = sorted(rel for rel in before if after[rel] != before[rel])
|
||||
@@ -1646,7 +1632,7 @@ class TestCli(unittest.TestCase):
|
||||
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")
|
||||
rc, out = self.t.cli("generate-id")
|
||||
self.assertEqual(rc, 0, out)
|
||||
final = self.t.bytes_map()
|
||||
self.assertTrue(any(final[rel] != after[rel] for rel in after
|
||||
@@ -1654,13 +1640,13 @@ class TestCli(unittest.TestCase):
|
||||
|
||||
def test_generate_unknown_vendor_returns_1(self):
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.t.cli("--generate", "--vendor", "Nope")
|
||||
rc, out = self.t.cli("generate-id", "--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")
|
||||
rc, out = self.t.cli("generate-id", "--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
|
||||
@@ -1671,7 +1657,7 @@ class TestCli(unittest.TestCase):
|
||||
"APLA @P1"))
|
||||
|
||||
def test_filament_id_only_inserts_no_setting_id(self):
|
||||
rc, out = self.t.cli("--generate", "--filament-id")
|
||||
rc, out = self.t.cli("generate-id", "--filament-id")
|
||||
self.assertEqual(rc, 0, out)
|
||||
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
@@ -1680,24 +1666,40 @@ class TestCli(unittest.TestCase):
|
||||
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.
|
||||
def test_check_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")
|
||||
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
|
||||
self.assertIn("Files with errors", out)
|
||||
self.assertEqual(self.t.bytes_map(), before) # check never writes
|
||||
|
||||
def test_check_vendor_narrows_the_per_vendor_pass(self):
|
||||
# check_profile.sh passes --vendor to this command, so it has to be
|
||||
# accepted -- and it must narrow only the per-vendor half.
|
||||
rc, out = self.t.cli("check", "--vendor", "VendorA")
|
||||
self.assertEqual(rc, 1, out) # the tree-wide checks still ran
|
||||
self.assertIn("Checked vendors : 1", out)
|
||||
|
||||
def test_an_empty_vendor_means_every_vendor(self):
|
||||
# check_profile.sh cannot expand an empty array under set -u, so it
|
||||
# passes --vendor "" to mean "all of them".
|
||||
_rc, scoped = self.t.cli("check", "--vendor", "")
|
||||
_rc, unscoped = self.t.cli("check")
|
||||
self.assertEqual(scoped, unscoped)
|
||||
|
||||
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
|
||||
for argv in (["--remint", "VendorA"], # removed mode
|
||||
["--generate"], # the pre-subcommand flag
|
||||
["--check"], # the pre-subcommand flag
|
||||
["--update-snapshot"], # the pre-subcommand flag
|
||||
["nonsense"], # not a command
|
||||
["generate-id", "--filament-id", "--setting-id"],
|
||||
["generate-id", "--materials"], # check's option
|
||||
["check", "--filament-id"], # generate-id's option
|
||||
["normalize", "--snapshot", "x"], # not a snapshot command
|
||||
["normalize", "--profile-type", "nozzle"]): # not a profile type
|
||||
with self.subTest(argv=argv):
|
||||
with self.assertRaises(SystemExit) as cm, \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
@@ -1722,7 +1724,7 @@ class TestRealTree(unittest.TestCase):
|
||||
# The exact CI invocation, return code included.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(["--check"])
|
||||
rc = afi.main(["check"])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_every_instantiated_filament_resolves_an_id(self):
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||
normalize, trim, update-index and check commands, and the subcommand dispatch that
|
||||
reaches them (stdlib unittest, no external deps).
|
||||
|
||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||
|
||||
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
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import orca_profile_tool as apt # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
|
||||
|
||||
|
||||
class Tree:
|
||||
"""A throwaway resources/profiles-shaped directory built one file at a time.
|
||||
|
||||
Nothing is written implicitly: index entries are added by index(), so a test
|
||||
can produce exactly the mismatch it is about (a file no list references, a
|
||||
list naming a file that is not there, a preset whose name disagrees with the
|
||||
index).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="profile_tool_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):
|
||||
for sub in apt.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"})
|
||||
return self
|
||||
|
||||
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)
|
||||
f.write("\n")
|
||||
|
||||
def read_index(self, vendor):
|
||||
with open(self.index_path(vendor), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def index(self, vendor, section, name, sub_path):
|
||||
index = self.read_index(vendor)
|
||||
index.setdefault(section + "_list", []).append(
|
||||
{"name": name, "sub_path": sub_path})
|
||||
self.write_index(vendor, index)
|
||||
|
||||
def path(self, vendor, rel):
|
||||
return os.path.join(self.profiles, vendor, rel.replace("/", os.sep))
|
||||
|
||||
def write(self, vendor, rel, data):
|
||||
"""Write a preset at <vendor>/<rel>; returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, rel)
|
||||
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")
|
||||
return path
|
||||
|
||||
def write_raw(self, vendor, rel, raw):
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
return path
|
||||
|
||||
def read(self, vendor, rel):
|
||||
with open(self.path(vendor, rel), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def raw(self, vendor, rel):
|
||||
with open(self.path(vendor, rel), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def bytes_map(self):
|
||||
"""Every file in the tree -> its bytes, for "nothing was written" asserts."""
|
||||
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
|
||||
|
||||
|
||||
class TreeCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = Tree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
def run_command(self, *argv):
|
||||
"""main() against this tree, capturing stdout."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = apt.main([*argv, "--profiles", self.t.profiles])
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalize(TreeCase):
|
||||
def test_a_missing_type_is_filled_in_from_the_directory(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
self.t.write("V", "process/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
self.assertEqual(self.t.read("V", "process/B.json")["type"], "process")
|
||||
|
||||
def test_the_machine_folder_splits_on_the_preset_name(self):
|
||||
# Orca keeps machine models in machine/ next to the nozzle variants that
|
||||
# are machines; only the name tells them apart.
|
||||
self.t.write("V", "machine/M.json", {"name": "V Printer"})
|
||||
self.t.write("V", "machine/N.json", {"name": "V Printer 0.4 nozzle"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "machine/M.json")["type"], "machine_model")
|
||||
self.assertEqual(self.t.read("V", "machine/N.json")["type"], "machine")
|
||||
|
||||
def test_dropped_keys_go_and_filament_vectors_are_arrayified(self):
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"type": "filament", "name": "A", "version": "1.2.3",
|
||||
"is_custom_defined": "1", "filament_type": "PLA",
|
||||
"filament_vendor": "AV", "travel_speed": 200})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
data = self.t.read("V", "filament/A.json")
|
||||
self.assertNotIn("version", data)
|
||||
self.assertNotIn("is_custom_defined", data)
|
||||
self.assertNotIn("travel_speed", data) # a process setting, not a filament one
|
||||
self.assertEqual(data["filament_type"], ["PLA"])
|
||||
self.assertEqual(data["filament_vendor"], ["AV"])
|
||||
|
||||
def test_the_larger_extruder_clearance_wins(self):
|
||||
# Keeping the smaller one would licence a toolhead collision.
|
||||
self.t.write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
|
||||
self.t.write("V", "machine/N.json", {
|
||||
"type": "machine", "name": "N 0.4 nozzle",
|
||||
"extruder_clearance_radius": "68", "extruder_clearance_max_radius": "45"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
kept = self.t.read("V", "machine/M.json")
|
||||
self.assertNotIn("extruder_clearance_radius", kept)
|
||||
self.assertEqual(kept["extruder_clearance_max_radius"], "68")
|
||||
kept = self.t.read("V", "machine/N.json")
|
||||
self.assertNotIn("extruder_clearance_max_radius", kept)
|
||||
self.assertEqual(kept["extruder_clearance_radius"], "68")
|
||||
|
||||
def test_a_rewritten_file_leads_with_its_identifying_keys(self):
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"filament_cost": [20], "name": "A", "instantiation": "true",
|
||||
"inherits": "base", "version": "1"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
keys = list(self.t.read("V", "filament/A.json"))
|
||||
self.assertEqual(keys[:4], ["type", "name", "inherits", "instantiation"])
|
||||
|
||||
def test_a_conforming_tree_is_left_byte_identical(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_force_rewrites_even_a_conforming_file(self):
|
||||
self.t.write_raw("V", "filament/A.json",
|
||||
b'{"name":"A","type":"filament"}')
|
||||
rc, out = self.run_command("normalize", "--force")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.raw("V", "filament/A.json"),
|
||||
b'{\n\t"type": "filament",\n\t"name": "A"\n}\n')
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_profile_type_confines_the_run(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
self.t.write("V", "process/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("normalize", "--profile-type", "filament")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
self.assertNotIn("type", self.t.read("V", "process/B.json"))
|
||||
|
||||
def test_an_unreadable_profile_is_reported_not_swallowed(self):
|
||||
self.t.write_raw("V", "filament/A.json", b"{ not json")
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("ERROR", out)
|
||||
self.assertEqual(self.t.raw("V", "filament/A.json"), b"{ not json")
|
||||
|
||||
def test_a_directory_without_an_index_is_not_a_bundle(self):
|
||||
# resources/profiles also holds non-bundle entries (blacklist.json, the
|
||||
# untracked user/ directory); only a directory WITH an index is a vendor.
|
||||
stray = os.path.join(self.t.profiles, "user", "filament")
|
||||
os.makedirs(stray)
|
||||
with open(os.path.join(stray, "A.json"), "wb") as f:
|
||||
f.write(b'{"name": "A"}')
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.t.profiles, "user.json")))
|
||||
with open(os.path.join(stray, "A.json"), "rb") as f:
|
||||
self.assertEqual(f.read(), b'{"name": "A"}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTrim(TreeCase):
|
||||
def bundle(self):
|
||||
self.t.write("V", "filament/Listed.json",
|
||||
{"type": "filament", "name": "Listed"})
|
||||
self.t.index("V", "filament", "Listed", "filament/Listed.json")
|
||||
return self.t
|
||||
|
||||
def test_an_unindexed_preset_is_removed(self):
|
||||
self.bundle().write("V", "filament/Orphan.json",
|
||||
{"type": "filament", "name": "Orphan"})
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Listed.json")))
|
||||
self.assertFalse(os.path.exists(self.t.path("V", "filament/Orphan.json")))
|
||||
|
||||
def test_a_dotted_sub_path_still_names_its_file(self):
|
||||
# Index entries are hand-written; "filament/./X.json" is the same file.
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/Dotted.json",
|
||||
{"type": "filament", "name": "Dotted"})
|
||||
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Dotted.json")))
|
||||
|
||||
def test_an_unparsable_file_is_kept_and_reported(self):
|
||||
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("WARNING", out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Broken.json")))
|
||||
|
||||
def test_a_data_file_is_not_a_preset(self):
|
||||
self.bundle().write("V", "filament/filaments_color_codes.json",
|
||||
{"data": [], "total": 0})
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(
|
||||
self.t.path("V", "filament/filaments_color_codes.json")))
|
||||
|
||||
def test_an_inherited_base_is_kept_and_reported(self):
|
||||
# Neither file loads -- the loader only reads indexed sub_paths -- but
|
||||
# deleting the parent destroys the only record of what the indexed child
|
||||
# was written against, so that is a maintainer's call, not trim's.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.write("V", "machine/mid.json",
|
||||
{"type": "machine", "name": "V mid", "inherits": "V base"})
|
||||
self.t.write("V", "machine/M.json",
|
||||
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V mid"})
|
||||
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
# ... and the chain is followed: mid rescues base in a second pass.
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/mid.json")))
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/base.json")))
|
||||
self.assertIn("inherited from", out)
|
||||
|
||||
def test_a_stale_copy_of_an_indexed_profile_is_removed(self):
|
||||
# "inherits" resolves by preset name, so the indexed base is the parent the
|
||||
# child actually gets; the unindexed twin is a leftover the loader never
|
||||
# reaches, and being named in an inherits does not earn it a reprieve.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/HSN/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
|
||||
self.t.write("V", "machine/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.write("V", "machine/M.json",
|
||||
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V base"})
|
||||
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/HSN/base.json")))
|
||||
self.assertFalse(os.path.exists(self.t.path("V", "machine/base.json")))
|
||||
self.assertNotIn("WARNING", out)
|
||||
self.assertIn('machine/HSN/base.json is the profile named "V base"', out)
|
||||
|
||||
def test_dry_run_deletes_nothing(self):
|
||||
self.bundle().write("V", "filament/Orphan.json",
|
||||
{"type": "filament", "name": "Orphan"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("trim", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be removed", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update-index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateIndex(TreeCase):
|
||||
def test_every_profile_on_disk_lands_in_its_own_section(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
self.t.write("V", "machine/M.json", {"type": "machine", "name": "M"})
|
||||
self.t.write("V", "machine/MM.json", {"type": "machine_model", "name": "MM"})
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
index = self.t.read_index("V")
|
||||
self.assertEqual(index["filament_list"],
|
||||
[{"name": "A", "sub_path": "filament/A.json"}])
|
||||
self.assertEqual(index["process_list"],
|
||||
[{"name": "B", "sub_path": "process/B.json"}])
|
||||
self.assertEqual(index["machine_list"],
|
||||
[{"name": "M", "sub_path": "machine/M.json"}])
|
||||
self.assertEqual(index["machine_model_list"],
|
||||
[{"name": "MM", "sub_path": "machine/MM.json"}])
|
||||
|
||||
def test_parents_are_listed_before_their_children(self):
|
||||
# The loader resolves inherits in one pass over the list.
|
||||
for name, parent in (("C", "B"), ("A", None), ("B", "A")):
|
||||
data = {"type": "filament", "name": name}
|
||||
if parent:
|
||||
data["inherits"] = parent
|
||||
self.t.write("V", f"filament/{name}.json", data)
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual([e["name"] for e in self.t.read_index("V")["filament_list"]],
|
||||
["A", "B", "C"])
|
||||
# inherits is ordering input only; it never lands in the index.
|
||||
for entry in self.t.read_index("V")["filament_list"]:
|
||||
self.assertEqual(sorted(entry), ["name", "sub_path"])
|
||||
|
||||
def test_a_profile_with_no_usable_type_is_reported_not_dropped(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "filament/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("cannot be indexed", out)
|
||||
self.assertIn("filament/B.json", out)
|
||||
|
||||
def test_two_profiles_claiming_one_name_leave_the_index_alone(self):
|
||||
# The bundle holds one profile per name, so a rebuild would pick a winner by
|
||||
# directory order and drop the other without a word.
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
|
||||
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
|
||||
self.t.index("V", "machine", "base", "machine/HSN/base.json")
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn('2 profiles are named "base"', out)
|
||||
self.assertIn("machine/base.json", out)
|
||||
self.assertIn("machine/HSN/base.json", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_profile_type_rebuilds_only_that_section(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
rc, out = self.run_command("update-index", "--profile-type", "filament")
|
||||
self.assertEqual(rc, 0, out)
|
||||
index = self.t.read_index("V")
|
||||
self.assertEqual([e["name"] for e in index["filament_list"]], ["A"])
|
||||
self.assertNotIn("process_list", index)
|
||||
|
||||
def test_an_up_to_date_index_is_left_byte_identical(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.run_command("update-index")
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be rebuilt", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_a_json_file_with_no_bundle_is_never_touched(self):
|
||||
# resources/profiles/blacklist.json is a .json with no directory beside
|
||||
# it. Enumerating vendors by stem once wrote four empty *_list keys into it.
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
stray = os.path.join(self.t.profiles, "blacklist.json")
|
||||
with open(stray, "wb") as f:
|
||||
f.write(b'{"filament": ["GFSA03"]}')
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
with open(stray, "rb") as f:
|
||||
self.assertEqual(f.read(), b'{"filament": ["GFSA03"]}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheck(TreeCase):
|
||||
def bundle(self):
|
||||
"""A bundle that passes every per-vendor check."""
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"type": "filament", "name": "A", "instantiation": "true",
|
||||
"filament_id": "OFaaaaaa", "filament_type": ["PLA"],
|
||||
"filament_vendor": ["AV"], "compatible_printers": ["M 0.4 nozzle"],
|
||||
"setting_id": apt.generate_preset_setting_id("V", "filament", "A")})
|
||||
self.t.index("V", "filament", "A", "filament/A.json")
|
||||
return self.t
|
||||
|
||||
def per_vendor_errors(self, *argv):
|
||||
"""Run the per-vendor checks alone, which is what --vendor narrows."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_filament_compatible_printers(self.t.profiles, "V")
|
||||
name_errors, _warn = apt.check_name_consistency(self.t.profiles, "V")
|
||||
errors += name_errors
|
||||
errors += apt.check_vector_type_keys(self.t.profiles, "V")
|
||||
errors += apt.check_filament_id_length(self.t.profiles, "V")
|
||||
conflict, _warn = apt.check_conflict_keys(self.t.profiles, "V")
|
||||
errors += conflict
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def test_a_clean_bundle_reports_nothing(self):
|
||||
self.bundle()
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_an_instantiated_filament_needs_compatible_printers(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "instantiation": "true"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("'compatible_printers' missing", out)
|
||||
|
||||
def test_a_duplicate_key_is_an_error(self):
|
||||
self.bundle().write_raw("V", "filament/B.json",
|
||||
b'{"type":"filament","name":"B","name":"B2"}')
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Duplicate key", out)
|
||||
|
||||
def test_the_index_and_the_file_must_agree_on_the_name(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/C.json", {"type": "filament", "name": "Other"})
|
||||
self.t.index("V", "filament", "C", "filament/C.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("name mismatch", out)
|
||||
|
||||
def test_an_index_entry_with_no_file_is_an_error(self):
|
||||
self.bundle()
|
||||
self.t.index("V", "filament", "Gone", "filament/Gone.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Missing sub profile", out)
|
||||
|
||||
def test_a_vector_option_may_not_be_a_scalar(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "filament_type": "PLA"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("must be an array", out)
|
||||
|
||||
def test_renamed_and_old_option_may_not_co_exist(self):
|
||||
self.bundle().write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Conflict keys", out)
|
||||
|
||||
def test_the_length_rule_only_binds_indexed_presets(self):
|
||||
# A file the index never loads cannot break AMS matching, and some
|
||||
# bundles ship such orphans from before the rule existed.
|
||||
self.bundle().write("V", "filament/Long.json", {
|
||||
"type": "filament", "name": "Long", "filament_id": "OFtoolongforams"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.t.index("V", "filament", "Long", "filament/Long.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Filament id too long", out)
|
||||
|
||||
def test_obsolete_keys_are_opt_in_warnings(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "silent_mode": True})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
warnings = apt.check_obsolete_keys(self.t.profiles, "V")
|
||||
self.assertEqual(warnings, 1)
|
||||
self.assertIn("Obsolete key", buf.getvalue())
|
||||
|
||||
def test_a_default_material_must_exist_somewhere(self):
|
||||
self.bundle().write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"default_materials": "A;Nope"})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
|
||||
self.assertEqual(errors, 1)
|
||||
self.assertIn("'Nope'", buf.getvalue())
|
||||
|
||||
def names(self, vendor="V"):
|
||||
"""The preset name check for one bundle, which is what --vendor narrows."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_preset_name_uniqueness(self.t.profiles, vendor)
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def test_one_bundle_may_not_hold_two_profiles_of_a_name(self):
|
||||
self.bundle().write("V", "filament/dup.json", {
|
||||
"type": "filament", "name": "A", "instantiation": "false"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertIn('V has 2 filament profiles named "A"', out)
|
||||
|
||||
def test_an_unindexed_twin_counts_as_a_duplicate(self):
|
||||
# The case this check was written for: a stale copy of a base profile in
|
||||
# machine/, which no per-vendor check walked, one index edit away from
|
||||
# silently deciding which of the two a whole bundle inherits from.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/HSN/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "V base"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertIn("machine/HSN/base.json", out)
|
||||
self.assertIn("machine/base.json", out)
|
||||
|
||||
def test_one_name_in_two_types_is_not_a_clash(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "process/same.json", {"type": "process", "name": "A"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_name_is_per_bundle_not_global(self):
|
||||
# fdm_machine_common exists in 60 shipped bundles; the name is scoped to the
|
||||
# bundle that resolves it, so sharing one across vendors is not a clash.
|
||||
for vendor in ("V", "W"):
|
||||
self.t.write(vendor, "machine/common.json",
|
||||
{"type": "machine", "name": "fdm_machine_common"})
|
||||
self.t.index(vendor, "machine", "fdm_machine_common", "machine/common.json")
|
||||
for vendor in ("V", "W"):
|
||||
errors, out = self.names(vendor)
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def coverage(self, vendor="V"):
|
||||
"""The index-coverage check for one bundle: (errors, gaps, output)."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, gaps = apt.check_index_coverage(self.t.profiles, vendor)
|
||||
return errors, gaps, buf.getvalue()
|
||||
|
||||
def test_a_file_no_list_references_is_an_error(self):
|
||||
self.bundle().write("V", "filament/Stray.json",
|
||||
{"type": "filament", "name": "Stray"})
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexed"], 1)
|
||||
self.assertIn("no V.json list references it", out)
|
||||
|
||||
def test_a_file_with_no_type_is_its_own_category(self):
|
||||
# update-index cannot place it, so "add it to the index" is not the remedy.
|
||||
self.bundle().write("V", "filament/Stray.json", {"name": "Stray"})
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexable"], 1)
|
||||
self.assertIn("declares no profile type", out)
|
||||
|
||||
def test_an_unparsable_unlisted_file_is_reported_too(self):
|
||||
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexable"], 1)
|
||||
|
||||
def test_a_dotted_sub_path_still_counts_as_listed(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/Dotted.json",
|
||||
{"type": "filament", "name": "Dotted"})
|
||||
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
|
||||
errors, _gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_data_file_is_not_expected_in_the_index(self):
|
||||
self.bundle().write("V", "filament/filaments_color_codes.json",
|
||||
{"data": [], "total": 0})
|
||||
errors, _gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_bundle_with_no_index_is_left_to_the_name_check(self):
|
||||
# Every file unlisted because there is no list at all is one problem, not
|
||||
# one per file; check_name_consistency reports the missing index.
|
||||
self.t.write("W", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
os.remove(self.t.index_path("W"))
|
||||
errors, _gaps, out = self.coverage("W")
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_the_remedy_is_printed_once_not_once_per_file(self):
|
||||
self.bundle()
|
||||
for n in range(5):
|
||||
self.t.write("V", f"filament/Stray{n}.json",
|
||||
{"type": "filament", "name": f"Stray{n}"})
|
||||
self.t.write("V", "filament/NoType.json", {"name": "NoType"})
|
||||
snapshot = os.path.join(self.t.dir, "snapshot.json")
|
||||
self.run_command("update-snapshot", "--snapshot", snapshot)
|
||||
rc, out = self.run_command("check", "--snapshot", snapshot)
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertEqual(out.count("update-index\" to add them"), 1, out)
|
||||
self.assertEqual(out.count("or delete them"), 1, out)
|
||||
self.assertIn("5 unreferenced file(s)", out)
|
||||
self.assertIn("1 unreferenced file(s)", out)
|
||||
|
||||
def test_setting_id_uniqueness_is_tree_wide(self):
|
||||
# Two presets sharing vendor/type/name mint one id, so the collision
|
||||
# only shows up in a pass that has seen the whole tree.
|
||||
shared = apt.generate_preset_setting_id("V", "filament", "A")
|
||||
for rel in ("filament/A.json", "filament/nested/A.json"):
|
||||
self.t.write("V", rel, {"type": "filament", "name": "A",
|
||||
"instantiation": "true", "setting_id": shared})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_setting_id_uniqueness(self.t.profiles)
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("globally unique", buf.getvalue())
|
||||
|
||||
def test_a_base_profile_must_not_carry_a_setting_id(self):
|
||||
self.t.write("V", "filament/base.json", {
|
||||
"type": "filament", "name": "base", "instantiation": "false",
|
||||
"setting_id": apt.generate_preset_setting_id("V", "filament", "base")})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_setting_id_uniqueness(self.t.profiles)
|
||||
self.assertEqual(errors, 1)
|
||||
self.assertIn("must not have a", buf.getvalue())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check: normalize and update-index would change nothing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalized(TreeCase):
|
||||
"""The pass that holds a bundle to the shape normalize and update-index write."""
|
||||
|
||||
def normalize(self):
|
||||
"""Put the tree in that shape, the way a contributor is told to."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
apt.main(["normalize", "--profiles", self.t.profiles])
|
||||
apt.main(["update-index", "--profiles", self.t.profiles])
|
||||
return buf.getvalue()
|
||||
|
||||
def normalized(self, vendor="V"):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, gaps = apt.check_normalized(self.t.profiles, vendor)
|
||||
return errors, gaps, buf.getvalue()
|
||||
|
||||
def snapshot(self):
|
||||
path = os.path.join(self.t.dir, "snapshot.json")
|
||||
self.run_command("update-snapshot", "--snapshot", path)
|
||||
return path
|
||||
|
||||
def test_a_bundle_the_two_commands_just_wrote_reports_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
self.normalize()
|
||||
errors, _gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_profile_fix_would_rewrite_is_an_error(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.normalize()
|
||||
# version belongs to the bundle, in <vendor>.json, never to a preset.
|
||||
data = self.t.read("V", "filament/A.json")
|
||||
data["version"] = "01.00.00.00"
|
||||
self.t.write("V", "filament/A.json", data)
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unnormalized"], 1, out)
|
||||
self.assertIn("V/filament/A.json: normalize would remove version", out)
|
||||
|
||||
def test_an_index_update_index_would_rebuild_is_an_error(self):
|
||||
for name, parent in (("B", "A"), ("A", None)):
|
||||
data = {"type": "filament", "name": name}
|
||||
if parent:
|
||||
data["inherits"] = parent
|
||||
self.t.write("V", f"filament/{name}.json", data)
|
||||
self.normalize()
|
||||
# Parents-first is what lets the loader resolve inherits in one pass; a
|
||||
# hand-edited list that puts the child first still names every file.
|
||||
index = self.t.read_index("V")
|
||||
index["filament_list"].reverse()
|
||||
self.t.write_index("V", index)
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["stale_index"], 1, out)
|
||||
self.assertIn("V.json: update-index would rebuild filament_list", out)
|
||||
|
||||
def test_an_unbuildable_index_is_left_to_the_checks_that_name_it(self):
|
||||
# update-index refuses to rebuild a bundle where two files claim one name,
|
||||
# so "would be rebuilt" on top of the duplicate-name error would be noise.
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
|
||||
self.normalize()
|
||||
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.assertEqual(gaps["stale_index"], 0, out)
|
||||
|
||||
def test_a_bundle_with_no_index_still_has_its_files_checked(self):
|
||||
self.t.write("V", "filament/A.json",
|
||||
{"type": "filament", "name": "A", "is_custom_defined": "0"})
|
||||
os.remove(self.t.index_path("V"))
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unnormalized"], 1, out)
|
||||
self.assertEqual(gaps["stale_index"], 0, out)
|
||||
|
||||
def test_the_shared_base_bundle_is_covered_too(self):
|
||||
# The per-vendor pass leaves OrcaFilamentLibrary out because its filaments are
|
||||
# generic by design. That says nothing about the shape of its files, and
|
||||
# normalize and update-index rewrite that bundle like any other.
|
||||
self.t.write(apt.OFL, "filament/A.json",
|
||||
{"type": "filament", "name": "A", "version": "01.00.00.00"})
|
||||
rc, out = self.run_command("check", "--snapshot", self.snapshot())
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn(f"{apt.OFL}/filament/A.json: normalize would remove version", out)
|
||||
|
||||
def test_each_remedy_is_printed_once_for_the_whole_run(self):
|
||||
for n in range(3):
|
||||
self.t.write("V", f"filament/A{n}.json",
|
||||
{"type": "filament", "name": f"A{n}",
|
||||
"version": "01.00.00.00"})
|
||||
self.t.write("W", "filament/B.json", {"type": "filament", "name": "B"})
|
||||
rc, out = self.run_command("check", "--snapshot", self.snapshot())
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("3 profile file(s) above are not what", out)
|
||||
self.assertEqual(out.count('normalize" writes: run it and commit'), 1, out)
|
||||
self.assertIn("2 vendor index(es) above are not what", out)
|
||||
self.assertEqual(out.count('update-index" writes: run it and commit'), 1, out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatch(TreeCase):
|
||||
def test_each_command_reaches_its_own_writer(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
for command, expected in (("normalize", "normalized"),
|
||||
("trim", "unreferenced"),
|
||||
("update-index", "vendor index")):
|
||||
with self.subTest(command=command):
|
||||
rc, out = self.run_command(command, "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn(expected, out)
|
||||
|
||||
def test_an_option_belongs_to_one_command_only(self):
|
||||
for argv in (["normalize", "--materials"],
|
||||
["trim", "--force"],
|
||||
["update-index", "--filament-id"],
|
||||
["check", "--profile-type", "filament"],
|
||||
["update-snapshot", "--vendor", "V"]):
|
||||
with self.subTest(argv=argv):
|
||||
with self.assertRaises(SystemExit) as cm, \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
apt.main([*argv, "--profiles", self.t.profiles])
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
def test_an_unknown_vendor_stops_the_run(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize", "--vendor", "Nope")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("unknown vendor", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_an_empty_vendor_means_every_vendor(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
rc, out = self.run_command("normalize", "--vendor", "")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
|
||||
class TestRealTree(unittest.TestCase):
|
||||
def test_check_passes(self):
|
||||
# The exact CI invocation, return code included.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = apt.main(["check"])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_the_shipped_tree_needs_no_fix(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = apt.normalize_profiles(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
self.assertEqual(changed, 0, buf.getvalue())
|
||||
|
||||
def test_the_shipped_indexes_need_no_rebuild(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = apt.update_profile_indexes(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
self.assertEqual(changed, 0, buf.getvalue())
|
||||
|
||||
def test_no_shipped_bundle_is_a_stray_json_file(self):
|
||||
# blacklist.json has no directory beside it, so it is not a vendor.
|
||||
self.assertNotIn("blacklist", apt.list_vendor_names(REAL_PROFILES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the setting_id half of scripts/orca_id_tool.py (stdlib unittest, no
|
||||
"""Tests for the setting_id half of scripts/orca_profile_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 orca_id_tool as afi # noqa: E402
|
||||
import orca_profile_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")
|
||||
@@ -246,7 +246,7 @@ class TestBase62Tail(unittest.TestCase):
|
||||
|
||||
class TestAssignment(SettingTreeCase):
|
||||
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
|
||||
# orca_extra_profile_check.py tests `instantiation == "true"` strictly.
|
||||
# check_setting_id_uniqueness 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"),
|
||||
@@ -852,7 +852,7 @@ class TestCli(SettingTreeCase):
|
||||
self.t.write("VendorA", "machine",
|
||||
preset("P1 0.4 nozzle", type_name="machine"))
|
||||
|
||||
rc, out = self.main(["--generate", "--setting-id",
|
||||
rc, out = self.main(["generate-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
|
||||
self.assertEqual(rc, 0, out)
|
||||
@@ -874,13 +874,13 @@ class TestCli(SettingTreeCase):
|
||||
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",
|
||||
rc, out = self.main(["generate-id", "--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",
|
||||
rc, out = self.main(["generate-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("1 file(s) changed", out)
|
||||
@@ -888,7 +888,7 @@ class TestCli(SettingTreeCase):
|
||||
afi.generate_preset_setting_id("VendorA", "filament",
|
||||
"A PLA @P1"))
|
||||
|
||||
def test_setting_id_without_generate_is_a_usage_error(self):
|
||||
def test_setting_id_without_a_command_is_a_usage_error(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
self.assertRaises(SystemExit) as cm:
|
||||
afi.main(["--setting-id", "--profiles", self.t.profiles])
|
||||
|
||||
Reference in New Issue
Block a user