mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 22:31:02 +00:00
Remove permissive singleton and short-array validation. Cover cooling array widths, preserve tuning with explicit variant entries, and bump the Snapmaker bundle version. Co-authored-by: Codex <codex@openai.com>
1334 lines
66 KiB
Python
1334 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
|
normalize, trim, update-index, fix-variant 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 re
|
|
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 TestObsoleteKeys(unittest.TestCase):
|
|
def test_obsolete_keys_match_the_loader_ignore_set(self):
|
|
path = os.path.join(REPO_ROOT, "src", "libslic3r", "PrintConfig.cpp")
|
|
with open(path, encoding="utf-8") as f:
|
|
source = f.read()
|
|
match = re.search(
|
|
r"void PrintConfigDef::handle_legacy\(.*?"
|
|
r"static\s+std::set<std::string>\s+ignore\s*=\s*\{(.*?)\};",
|
|
source, re.DOTALL)
|
|
self.assertIsNotNone(match, "Could not locate the loader's obsolete-key set")
|
|
keys = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
|
|
self.assertEqual(apt.OBSOLETE_KEYS, set(re.findall(r'"([^"\n]+)"', keys)))
|
|
|
|
|
|
class TestVariantScheme(unittest.TestCase):
|
|
def test_the_key_sets_are_read_from_the_engine(self):
|
|
scheme = apt._variant_scheme()
|
|
self.assertEqual(scheme["machine"][1]["retraction_length"], 1)
|
|
self.assertEqual(scheme["machine"][1]["machine_max_speed_x"], 2)
|
|
self.assertIn("outer_wall_speed", scheme["process"][1])
|
|
self.assertIn("nozzle_temperature", scheme["filament"][1])
|
|
# Commented out of its initializer, so not a member.
|
|
self.assertNotIn("filament_extruder_id", scheme["filament"][1])
|
|
for list_key, strides in scheme.values():
|
|
self.assertEqual(strides[list_key], 1)
|
|
|
|
def test_the_variant_names_are_read_from_the_engine(self):
|
|
extruder_types, volume_types, legacy, extruder_legacy = apt._variant_names()
|
|
self.assertEqual(extruder_types, {"Direct Drive", "Bowden"})
|
|
self.assertEqual(volume_types, {"Standard", "High Flow", "TPU High Flow", "E3D High Flow", "Extra High Flow"})
|
|
# Hybrid is an enum value no variant string may name.
|
|
self.assertNotIn("Hybrid", volume_types)
|
|
self.assertEqual(legacy, {"Normal": "Standard", "Big Traffic": "High Flow"})
|
|
self.assertEqual(extruder_legacy, {"DirectDrive": "Direct Drive"})
|
|
|
|
|
|
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,
|
|
"filament_load_time": ["15"], "filament_unload_time": "0"})
|
|
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.assertNotIn("filament_load_time", data)
|
|
self.assertNotIn("filament_unload_time", data)
|
|
self.assertEqual(data["filament_type"], ["PLA"])
|
|
self.assertEqual(data["filament_vendor"], ["AV"])
|
|
|
|
def test_obsolete_keys_are_removed_from_every_profile_type(self):
|
|
for sub in ("filament", "process", "machine"):
|
|
with self.subTest(profile_type=sub):
|
|
expected = {"type": sub, "name": "A"}
|
|
self.t.write("V", f"{sub}/A.json", {
|
|
**expected, "silent_mode": "", "adaptive_layer_height": "0",
|
|
"anisotropic_surfaces": "1", "filament_load_time": ["0"],
|
|
"filament_unload_time": "0"})
|
|
rc, out = self.run_command("normalize")
|
|
self.assertEqual(rc, 0, out)
|
|
self.assertEqual(self.t.read("V", f"{sub}/A.json"), expected)
|
|
before = self.t.bytes_map()
|
|
rc, out = self.run_command("normalize")
|
|
self.assertEqual(rc, 0, out)
|
|
self.assertIn("0 profile(s) normalized", out)
|
|
self.assertEqual(self.t.bytes_map(), before)
|
|
|
|
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"})
|
|
# These used to be misclassified as obsolete: one is active, the other
|
|
# is a legacy alias that still supplies the toolhead clearance on load.
|
|
self.t.write("V", "machine/M.json", {
|
|
"type": "machine", "name": "M", "extruder_type": ["Direct Drive"],
|
|
"extruder_clearance_max_radius": "68", "machine_load_filament_time": "15",
|
|
"machine_unload_filament_time": "10"})
|
|
self.t.write("V", "process/P.json", {
|
|
"type": "process", "name": "P", "travel_speed": "200",
|
|
"top_surface_fill_order": "outward"})
|
|
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", "bed_temperature": ["60"]})
|
|
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_include_targets_are_listed_before_their_users(self):
|
|
# The loader resolves include like inherits: in one pass over the list, so
|
|
# a template must be listed before every preset that includes it - even
|
|
# though a template has no parent of its own to order it by.
|
|
self.t.write("V", "machine/P.json", {"type": "machine", "name": "P",
|
|
"include": ["T start", "T end"]})
|
|
self.t.write("V", "machine/T start.json", {"type": "machine", "name": "T start"})
|
|
self.t.write("V", "machine/T end.json", {"type": "machine", "name": "T end"})
|
|
self.t.write("V", "filament/F.json", {"type": "filament", "name": "F",
|
|
"inherits": "B", "include": "S"})
|
|
self.t.write("V", "filament/B.json", {"type": "filament", "name": "B"})
|
|
self.t.write("V", "filament/S.json", {"type": "filament", "name": "S"})
|
|
rc, out = self.run_command("update-index")
|
|
self.assertEqual(rc, 0, out)
|
|
machines = [e["name"] for e in self.t.read_index("V")["machine_list"]]
|
|
self.assertEqual(machines, ["T end", "T start", "P"])
|
|
filaments = [e["name"] for e in self.t.read_index("V")["filament_list"]]
|
|
self.assertLess(filaments.index("B"), filaments.index("F"))
|
|
self.assertLess(filaments.index("S"), filaments.index("F"))
|
|
|
|
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
|
|
materials, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
|
|
errors += materials
|
|
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_library_filament_may_leave_compatible_printers_empty(self):
|
|
# The shared library is exempt from that rule and nothing else.
|
|
self.t.write(apt.OFL, "filament/A.json",
|
|
{"type": "filament", "name": "A", "instantiation": "true"})
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
errors = apt.check_filament_compatible_printers(self.t.profiles, apt.OFL)
|
|
self.assertEqual(errors, 0, buf.getvalue())
|
|
|
|
def test_the_library_is_checked_like_any_other_bundle(self):
|
|
# A file the library's own index does not reference must fail plain
|
|
# `check`, now that the per-vendor pass no longer skips it.
|
|
self.t.write(apt.OFL, "filament/Stray.json",
|
|
{"type": "filament", "name": "Stray"})
|
|
rc, out = self.run_command("check")
|
|
self.assertEqual(rc, 1, out)
|
|
self.assertIn(f"{apt.OFL}/filament/Stray.json: no {apt.OFL}.json list "
|
|
f"references it", 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_key_warnings_exclude_active_and_renamed_options(self):
|
|
self.bundle().write("V", "filament/B.json", {
|
|
"type": "filament", "name": "B", "silent_mode": "0",
|
|
"anisotropic_surfaces": "0", "extruder_type": ["Direct Drive"],
|
|
"extruder_clearance_max_radius": "68"})
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
warnings = apt.check_obsolete_keys(self.t.profiles, "V")
|
|
self.assertEqual(warnings, 2)
|
|
self.assertIn("Obsolete key", buf.getvalue())
|
|
|
|
def test_obsolete_key_warnings_run_without_a_flag(self):
|
|
self.t.write("V", "filament/A.json", {
|
|
"type": "filament", "name": "A", "silent_mode": "0"})
|
|
self.run_command("update-index")
|
|
rc, out = self.run_command("check")
|
|
self.assertEqual(rc, 1, out) # normalization also rejects the obsolete key
|
|
self.assertIn("Obsolete key: 'silent_mode' found in V/filament/A.json", out)
|
|
self.assertIn("Files with warnings : 1", out)
|
|
|
|
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 test_a_default_material_fails_check_without_a_flag(self):
|
|
# The reference check is part of the default run, not an opt-in: a
|
|
# dangling name has to fail plain `check`.
|
|
self.bundle()
|
|
self.t.write("V", "machine/M.json", {
|
|
"type": "machine", "name": "M 0.4 nozzle",
|
|
"default_filament_profile": ["A", "Nope"]})
|
|
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
|
rc, out = self.run_command("check")
|
|
self.assertEqual(rc, 1, out)
|
|
self.assertIn("Missing filament profile: 'Nope'", out)
|
|
|
|
def test_the_stray_user_directory_is_not_a_vendor(self):
|
|
# A local validator run leaves resources/profiles/user/ behind; an
|
|
# unscoped check must not count it as a bundle and warn about it.
|
|
self.bundle()
|
|
for sub in apt.PROFILE_SUBDIRS:
|
|
os.makedirs(os.path.join(self.t.profiles, apt.USER_DIR, "default", sub))
|
|
_rc, out = self.run_command("check")
|
|
self.assertIn("Checked vendors : 1", out)
|
|
self.assertNotIn("user", out)
|
|
|
|
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 machine_models(self):
|
|
"""The cross-vendor machine_model name check, whole tree by design."""
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
errors = apt.check_machine_model_name_uniqueness(self.t.profiles)
|
|
return errors, buf.getvalue()
|
|
|
|
def test_two_bundles_may_not_declare_one_machine_model_name(self):
|
|
# The name keys the global printer-type lookup: Preset::get_printer_type
|
|
# matches a preset's printer_model against every vendor's model names, so a
|
|
# copy of another vendor's model is ambiguous, not merely duplicated.
|
|
for vendor in ("V", "W"):
|
|
self.t.write(vendor, "machine/MyKlipper.json",
|
|
{"type": "machine_model", "name": "Generic Klipper Printer",
|
|
"model_id": "my_klipper_01"})
|
|
errors, out = self.machine_models()
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertIn('machine_model name "Generic Klipper Printer"', out)
|
|
self.assertIn("V/machine/MyKlipper.json", out)
|
|
self.assertIn("W/machine/MyKlipper.json", out)
|
|
|
|
def test_distinct_machine_model_names_are_left_alone(self):
|
|
for vendor in ("V", "W"):
|
|
self.t.write(vendor, "machine/model.json",
|
|
{"type": "machine_model", "name": f"{vendor} Model"})
|
|
errors, out = self.machine_models()
|
|
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"})
|
|
rc, out = self.run_command("check")
|
|
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 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):
|
|
# normalize and update-index own the shape of every bundle, the shared
|
|
# library included.
|
|
self.t.write(apt.OFL, "filament/A.json",
|
|
{"type": "filament", "name": "A", "version": "01.00.00.00"})
|
|
rc, out = self.run_command("check")
|
|
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")
|
|
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 TestFixVariant(TreeCase):
|
|
def test_cooling_arrays_require_one_value_per_declared_variant(self):
|
|
keys = ("fan_min_speed", "fan_max_speed", "additional_cooling_fan_speed")
|
|
variants = ["Direct Drive Standard", "Direct Drive High Flow", "Bowden Standard"]
|
|
for width in (1, 2, 3, 4):
|
|
with self.subTest(width=width):
|
|
self.preset("filament/F.json", instantiation="true",
|
|
filament_extruder_variant=variants,
|
|
**{key: ["20"] * width for key in keys})
|
|
apt.load_vendor_configs.cache_clear()
|
|
errors, out = self.width_errors()
|
|
self.assertEqual(errors, 0 if width == 3 else len(keys), out)
|
|
if width != 3:
|
|
for key in keys:
|
|
self.assertIn(f'"{key}" has {width} values', out)
|
|
|
|
def preset(self, rel, **data):
|
|
name = os.path.splitext(os.path.basename(rel))[0]
|
|
self.t.write("V", rel, {"type": rel.split("/")[0], "name": name, **data})
|
|
self.t.index("V", rel.split("/")[0], name, rel)
|
|
|
|
def processes(self, common):
|
|
"""outer_wall_speed written once in a list-less base, reached by list-less
|
|
presets through one base and by two-variant presets through another."""
|
|
dual = ["Direct Drive Standard", "Direct Drive High Flow"]
|
|
self.preset("process/common.json", instantiation="false", outer_wall_speed=common)
|
|
self.preset("process/single.json", inherits="common", instantiation="false")
|
|
self.preset("process/dual.json", inherits="common", instantiation="false",
|
|
print_extruder_variant=dual, print_extruder_id=["1", "1"])
|
|
for name, base in (("S1", "single"), ("S2", "single"), ("D1", "dual")):
|
|
self.preset(f"process/{name}.json", inherits=base, instantiation="true")
|
|
|
|
def width_errors(self, *strict):
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
errors, _warnings = apt.check_variant_arrays(self.t.profiles, "V", bool(strict))
|
|
return errors, buf.getvalue()
|
|
|
|
def test_a_machine_without_a_variant_list_has_one_variant_per_extruder(self):
|
|
# extruder_variant_list defaults to one "Direct Drive Standard" per extruder.
|
|
self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4", "0.4"],
|
|
retraction_length=["0.8", "1.2"], z_hop=["0.4"],
|
|
machine_max_speed_x=["500", "200"])
|
|
errors, out = self.width_errors()
|
|
self.assertEqual(errors, 2, out)
|
|
self.assertIn('M.json: "z_hop" has 1 values for variant length 2 (no '
|
|
'printer_extruder_variant, so one default variant per extruder) at stride 1, '
|
|
'which takes 2', out)
|
|
self.assertIn('M.json: "machine_max_speed_x" has 2 values for variant length 2', out)
|
|
self.assertNotIn("retraction_length", out)
|
|
|
|
def test_one_value_is_an_error_where_the_list_has_more_variants(self):
|
|
self.preset("process/P.json", instantiation="true", outer_wall_speed=["30"],
|
|
print_extruder_id=["1", "1"],
|
|
print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"])
|
|
errors, out = self.width_errors()
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertIn('P.json: "outer_wall_speed" has 1 values for variant length 2 at stride 1, '
|
|
'which takes 2', out)
|
|
|
|
def test_one_value_is_padded_to_every_variant(self):
|
|
self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4"] * 3,
|
|
retraction_length=["0.8"], machine_max_speed_x=["500", "200"])
|
|
rc, out = self.run_command("fix-variant")
|
|
self.assertEqual(rc, 0, out)
|
|
machine = self.t.read("V", "machine/M.json")
|
|
self.assertEqual(machine["retraction_length"], ["0.8"] * 3)
|
|
self.assertEqual(machine["machine_max_speed_x"], ["500", "200"] * 3)
|
|
self.assertEqual(self.width_errors()[0], 0)
|
|
|
|
def test_extruder_ids_are_left_to_check(self):
|
|
self.preset("process/P.json", instantiation="true", print_extruder_id=["1"],
|
|
print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"])
|
|
self.run_command("fix-variant")
|
|
self.assertEqual(self.t.read("V", "process/P.json")["print_extruder_id"], ["1"])
|
|
errors, out = self.width_errors()
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertIn("print_extruder_id has 1 entries for the 2 entries", out)
|
|
|
|
def test_a_base_is_judged_only_where_its_array_reaches_a_preset(self):
|
|
# A leaf overriding the base's one-value limit passes in both modes.
|
|
self.preset("machine/common.json", instantiation="false", machine_max_speed_x=["500"])
|
|
self.preset("machine/M.json", inherits="common", instantiation="true",
|
|
machine_max_speed_x=["500", "200"])
|
|
self.assertEqual(self.width_errors()[0], 0)
|
|
self.assertEqual(self.width_errors("strict")[0], 0)
|
|
|
|
def test_a_shared_array_is_fixed_in_the_base_whose_presets_agree(self):
|
|
self.processes(["30", "40"])
|
|
self.assertEqual(self.width_errors()[0], 0)
|
|
errors, out = self.width_errors("strict")
|
|
self.assertEqual(errors, 2, out)
|
|
self.assertIn('process/S1.json: "outer_wall_speed" has 2 values for variant length 1', out)
|
|
rc, out = self.run_command("fix-variant", "--strict")
|
|
self.assertEqual(rc, 0, out)
|
|
self.assertEqual(self.t.read("V", "process/single.json")["outer_wall_speed"], ["30"])
|
|
self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"],
|
|
["30", "40"])
|
|
self.assertNotIn("outer_wall_speed", self.t.read("V", "process/S1.json"))
|
|
self.assertEqual(self.width_errors("strict")[0], 0)
|
|
|
|
def test_an_inherited_array_is_checked_and_restated_only_when_strict(self):
|
|
self.processes(["30"])
|
|
self.assertEqual(self.width_errors()[0], 0)
|
|
errors, out = self.width_errors("strict")
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertIn('process/D1.json: "outer_wall_speed" has 1 values for variant length 2 at '
|
|
'stride 1, which takes 2 (it comes from V/process/common.json)', out)
|
|
before = self.t.bytes_map()
|
|
self.run_command("fix-variant")
|
|
self.assertEqual(self.t.bytes_map(), before)
|
|
rc, out = self.run_command("fix-variant", "--strict")
|
|
self.assertEqual(rc, 0, out)
|
|
self.assertEqual(self.t.read("V", "process/dual.json")["outer_wall_speed"], ["30", "30"])
|
|
self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"], ["30"])
|
|
self.assertEqual(self.width_errors("strict")[0], 0)
|
|
|
|
def test_a_strict_array_never_lands_in_a_file_of_another_width(self):
|
|
# common is list-less (width 1) and D2 inherits it directly, so D2 takes it.
|
|
self.processes(["30"])
|
|
self.preset("process/D2.json", inherits="common", instantiation="true",
|
|
print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"],
|
|
print_extruder_id=["1", "1"])
|
|
self.run_command("fix-variant", "--strict")
|
|
self.assertEqual(self.t.read("V", "process/D2.json")["outer_wall_speed"], ["30", "30"])
|
|
self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"], ["30"])
|
|
self.assertEqual(self.width_errors("strict")[0], 0)
|
|
|
|
def test_extra_values_are_dropped_where_they_are_written(self):
|
|
self.preset("process/base.json", instantiation="false", outer_wall_speed=["30", "0"])
|
|
self.preset("process/P1.json", inherits="base", instantiation="true",
|
|
inner_wall_speed=["40", "0"])
|
|
self.preset("process/P2.json", inherits="base", instantiation="true")
|
|
self.run_command("fix-variant")
|
|
self.assertEqual(self.t.read("V", "process/P1.json")["inner_wall_speed"], ["40"])
|
|
self.assertEqual(self.t.read("V", "process/base.json")["outer_wall_speed"], ["30", "0"])
|
|
self.run_command("fix-variant", "--strict")
|
|
self.assertEqual(self.t.read("V", "process/base.json")["outer_wall_speed"], ["30"])
|
|
self.assertNotIn("outer_wall_speed", self.t.read("V", "process/P1.json"))
|
|
|
|
def test_the_rest_of_the_file_keeps_its_bytes(self):
|
|
self.t.write_raw("V", "process/P.json", b'{\n "type": "process", "name": "P",\n'
|
|
b' "instantiation": "true",\n "outer_wall_speed": ["30", "0"],\n'
|
|
b' "inner_wall_speed": [\n "40",\n "0"\n ]\n}\n')
|
|
self.t.index("V", "process", "P", "process/P.json")
|
|
self.run_command("fix-variant")
|
|
self.assertEqual(self.t.raw("V", "process/P.json"),
|
|
b'{\n "type": "process", "name": "P",\n'
|
|
b' "instantiation": "true",\n "outer_wall_speed": ["30"],\n'
|
|
b' "inner_wall_speed": [\n "40"\n ]\n}\n')
|
|
|
|
def test_a_dry_run_writes_nothing(self):
|
|
self.processes(["30"])
|
|
before = self.t.bytes_map()
|
|
rc, out = self.run_command("fix-variant", "--strict", "--dry-run")
|
|
self.assertEqual(rc, 0, out)
|
|
self.assertIn("would resize V/process/dual.json: outer_wall_speed added -> 2", out)
|
|
self.assertEqual(self.t.bytes_map(), before)
|
|
|
|
def test_padding_repeats_the_last_value_or_pair(self):
|
|
self.assertEqual(apt._fit_width(["a", "b"], 4, 1), ["a", "b", "b", "b"])
|
|
self.assertEqual(apt._fit_width(["a", "b", "c"], 6, 2), ["a", "b"] * 3)
|
|
self.assertEqual(apt._fit_width(["a", "b", "c"], 2, 2), ["a", "b"])
|
|
self.assertEqual(apt._fit_width(["a"], 4, 2), ["a"] * 4)
|
|
|
|
|
|
class TestVariantNames(TreeCase):
|
|
def preset(self, rel, vendor="V", **data):
|
|
section = rel.split("/")[0]
|
|
name = os.path.splitext(os.path.basename(rel))[0]
|
|
self.t.write(vendor, rel, {"type": section, "name": name, **data})
|
|
self.t.index(vendor, section, name, rel)
|
|
|
|
def names(self, vendor="V"):
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
errors = apt.check_variant_names(self.t.profiles, vendor)
|
|
return errors, buf.getvalue()
|
|
|
|
def test_a_legal_variant_layout_passes(self):
|
|
self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4"],
|
|
extruder_type=["Direct Drive"],
|
|
extruder_variant_list=["Direct Drive Standard,Direct Drive High Flow"],
|
|
printer_extruder_id=["1", "1"],
|
|
printer_extruder_variant=["Direct Drive Standard",
|
|
"Direct Drive High Flow"],
|
|
nozzle_volume_type=["High Flow"],
|
|
default_nozzle_volume_type=["Standard"])
|
|
self.preset("process/P.json", instantiation="true", print_extruder_id=["1", "1"],
|
|
print_extruder_variant=["Direct Drive Standard",
|
|
"Direct Drive High Flow"])
|
|
self.preset("filament/F.json", instantiation="true",
|
|
filament_extruder_variant=["Direct Drive Standard", "Bowden High Flow"])
|
|
self.assertEqual(self.names()[0], 0, self.names()[1])
|
|
|
|
def test_a_variant_the_enum_cannot_build_is_an_error(self):
|
|
# An unknown volume type, in a list and in a menu, an extruder type that is not
|
|
# one, and the legacy extruder spelling that is rewritten in extruder_type alone.
|
|
cases = (("process/P1.json", "print_extruder_variant", "Direct Drive Fast",
|
|
'entry 1 holds "Direct Drive Fast"',
|
|
"not a nozzle volume type the enum has"),
|
|
("machine/M.json", "extruder_variant_list",
|
|
"Direct Drive Standard,Direct Drive Fast",
|
|
'extruder 1 holds "Direct Drive Fast"',
|
|
"not a nozzle volume type the enum has"),
|
|
("process/P2.json", "print_extruder_variant", "DD Standard",
|
|
'entry 1 holds "DD Standard"',
|
|
"does not start with an extruder type the enum has"),
|
|
("filament/F.json", "filament_extruder_variant", "DirectDrive Standard",
|
|
'entry 1 holds "DirectDrive Standard"',
|
|
"does not start with an extruder type the enum has"))
|
|
for rel, key, value, _held, _why in cases:
|
|
self.preset(rel, instantiation="false", **{key: [value]})
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, len(cases), out)
|
|
for rel, key, _value, held, why in cases:
|
|
self.assertIn(f"V/{rel}: {key} {held}", out)
|
|
self.assertIn(why, out)
|
|
|
|
def test_the_legacy_names_the_loader_rewrites_are_errors(self):
|
|
self.preset("machine/M.json", extruder_type=["DirectDrive"],
|
|
nozzle_volume_type=["Big Traffic"],
|
|
default_nozzle_volume_type=["Normal"],
|
|
printer_extruder_variant=["Direct Drive Big Traffic"])
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, 4, out)
|
|
self.assertIn('extruder_type spells the legacy name "DirectDrive"; the loader '
|
|
'still rewrites it to "Direct Drive"', out)
|
|
self.assertIn('V/machine/M.json: nozzle_volume_type spells the legacy name '
|
|
'"Big Traffic"', out)
|
|
self.assertIn('default_nozzle_volume_type spells the legacy name "Normal"', out)
|
|
self.assertIn('holds the legacy variant "Direct Drive Big Traffic"', out)
|
|
self.assertIn('a profile writes the enum name: "Direct Drive High Flow"', out)
|
|
|
|
def test_an_empty_entry_is_an_error(self):
|
|
self.preset("filament/F.json",
|
|
filament_extruder_variant=["Direct Drive Standard", ""])
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertIn("filament_extruder_variant entry 2 is empty", out)
|
|
|
|
def test_hybrid_is_runtime_only(self):
|
|
self.preset("machine/M.json", nozzle_volume_type=["Hybrid"],
|
|
default_nozzle_volume_type=["Hybrid"],
|
|
printer_extruder_variant=["Direct Drive Hybrid"])
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, 3, out)
|
|
self.assertIn("Hybrid names the sub-nozzles of one hybrid extruder at runtime", out)
|
|
self.assertIn('V/machine/M.json: nozzle_volume_type names "Hybrid"', out)
|
|
self.assertIn('default_nozzle_volume_type names "Hybrid"', out)
|
|
|
|
def test_bbl_is_held_to_the_enums_like_every_bundle(self):
|
|
# E3D High Flow passes because the engine's enum has it; TPU285 is a name no enum has.
|
|
self.preset("machine/M.json", vendor="BBL", extruder_type=["Direct Drive"],
|
|
extruder_variant_list=["Direct Drive Standard,Direct Drive E3D High Flow"],
|
|
printer_extruder_id=["1", "1"],
|
|
printer_extruder_variant=["Direct Drive Standard",
|
|
"Direct Drive E3D High Flow"])
|
|
self.preset("process/P.json", vendor="BBL", instantiation="true",
|
|
print_extruder_variant=["Direct Drive TPU285"])
|
|
errors, out = self.names("BBL")
|
|
self.assertEqual(errors, 1, out)
|
|
self.assertNotIn('holds "Direct Drive E3D High Flow"', out)
|
|
self.assertIn('"TPU285" is not a nozzle volume type the enum has', out)
|
|
|
|
def test_a_list_may_not_name_one_variant_twice(self):
|
|
self.preset("filament/F.json",
|
|
filament_extruder_variant=["Direct Drive Standard",
|
|
"Direct Drive High Flow",
|
|
"Direct Drive Standard"])
|
|
self.preset("machine/M.json",
|
|
extruder_variant_list=["Direct Drive Standard,Direct Drive Standard,"
|
|
"Direct Drive High Flow"])
|
|
self.preset("process/P.json", print_extruder_id=["1", "1", "1"],
|
|
print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow",
|
|
"Direct Drive Standard"])
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, 3, out)
|
|
self.assertIn('filament_extruder_variant lists "Direct Drive Standard" 2 times', out)
|
|
self.assertIn('extruder_variant_list extruder 1 lists "Direct Drive Standard" 2 '
|
|
"times", out)
|
|
self.assertIn('print_extruder_variant lists the pair (extruder 1, "Direct Drive '
|
|
'Standard") 2 times', out)
|
|
|
|
def test_one_variant_on_two_extruders_is_two_pairs_not_a_repeat(self):
|
|
self.preset("process/P.json", print_extruder_id=["1", "2"],
|
|
print_extruder_variant=["Direct Drive Standard",
|
|
"Direct Drive Standard"])
|
|
self.assertEqual(self.names()[0], 0, self.names()[1])
|
|
|
|
def test_an_unknown_enum_value_fails_the_bundle(self):
|
|
self.preset("machine/M.json", extruder_type=["Direct Drive", "Magnetic Drive"],
|
|
nozzle_volume_type=["Turbo"],
|
|
default_nozzle_volume_type=["Fast Flow"])
|
|
errors, out = self.names()
|
|
self.assertEqual(errors, 3, out)
|
|
self.assertIn('extruder_type "Magnetic Drive" is not one of (Bowden, Direct Drive)',
|
|
out)
|
|
self.assertIn('V/machine/M.json: nozzle_volume_type "Turbo" is not one of (E3D High '
|
|
"Flow, Extra High Flow, High Flow, Standard, TPU High Flow)", out)
|
|
self.assertIn('default_nozzle_volume_type "Fast Flow" is not one of (E3D High Flow, '
|
|
"Extra High Flow, High Flow, Standard, TPU High Flow)", out)
|
|
|
|
def test_a_file_no_list_references_is_not_judged(self):
|
|
# It never loads, so its variant names cannot reach anything;
|
|
# check_index_coverage reports the file itself.
|
|
self.t.write("V", "machine/M.json", {"type": "machine", "name": "M",
|
|
"printer_extruder_variant": ["Direct Drive Fast"]})
|
|
self.assertEqual(self.names()[0], 0, self.names()[1])
|
|
|
|
def test_check_runs_it(self):
|
|
self.preset("filament/F.json", instantiation="false",
|
|
filament_extruder_variant=["Direct Drive Normal"])
|
|
self.run_command("update-index") # so nothing but the name check can fail
|
|
rc, out = self.run_command("check")
|
|
self.assertEqual(rc, 1, out)
|
|
self.assertIn('holds the legacy variant "Direct Drive Normal"', out)
|
|
|
|
|
|
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"),
|
|
("fix-variant", "resized"),
|
|
("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 (["trim", "--force"],
|
|
["update-index", "--filament-id"],
|
|
["check", "--profile-type", "filament"],
|
|
["normalize", "--strict"]):
|
|
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()
|