Files
KX-Bridge-Release/tests/test_spoolman_slot_map.py
Walter Almada B 2a13f1f0dd fix(spoolman): repair dead slot-map persistence + isolate it per printer
The AMS-slot -> Spoolman-spool persistence never worked: KobraXBridge
referenced `config_loader` in both the load (__init__) and save
(handle_kx_spoolman_set_active) paths, but the module alias is `env_loader`
(kobrax_moonraker_bridge.py:32). The resulting NameError was swallowed by a
bare `except`, so the map was neither loaded on startup nor written on change
- it only appeared to persist.

The map also lived in a single global `[spoolman] slot_spools` key, so on a
multi-printer bridge two AMS units clobbered each other's mapping (same class
of bug as #74/#75 for filament profiles).

- config_loader: add list_spool_map()/save_spool_map(printer_id) using a
  per-printer `[spoolman_<id>]` section with read-fallback to the legacy
  global key, mirroring _filament_section/list_filament_profiles. The global
  `[spoolman]` section keeps server/sync_rate.
- bridge: load via config_loader.list_spool_map(self._printer_id); persist via
  save_spool_map(..., self._printer_id); surface failures via log.warning
  instead of a silent except.
- _build_mmu_object: emit real gate_spool_id from the per-printer map (was
  hardcoded [-1]*num_gates) so Happy-Hare/OrcaSlicer can show the bound spool.
- config.ini.example: document the [spoolman] section.
- tests: tests/test_spoolman_slot_map.py (per-printer isolation, persistence
  round-trip, server/sync_rate preservation, parser robustness).

Verified on a 2-printer bridge: after restart KX1 loads its spools and KX2
loads its own, isolated; a real multicolor print deducted per slot (white spool
1.02g vs 0.98g slicer estimate) against the correct printer's spools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:42:40 -07:00

101 lines
4.1 KiB
Python

"""Per-printer Spoolman slot-map isolation + persistence (config_loader).
Regression test for two bugs in the Spoolman slot→spool persistence:
1. The bridge referenced ``config_loader`` while the module alias is
``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map
was never loaded nor saved (persistence looked implemented but was dead).
2. The map lived in a single global ``[spoolman] slot_spools`` key, so two
printers/two AMS units overwrote each other (same class as issue #74/#75).
Each printer now uses its own ``[spoolman_<id>]`` section, with a read-fallback
to the legacy global key for backward compatibility. The global ``[spoolman]``
section keeps ``server`` / ``sync_rate``.
"""
import sys
import pathlib
import configparser
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root
import config_loader # noqa: E402
BASE_INI = (
"[printer_1]\nname = K1\n\n"
"[printer_2]\nname = K2\n\n"
"[spoolman]\n"
"server = http://192.168.3.200:7912\n"
"sync_rate = 0\n"
"slot_spools = 0:1,1:2\n"
)
def _use_ini(monkeypatch, tmp_path, text=BASE_INI):
path = tmp_path / "config.ini"
path.write_text(text, encoding="utf-8")
monkeypatch.setattr(config_loader, "_find_config_file", lambda: path)
return path
def test_legacy_global_read(tmp_path, monkeypatch):
"""No printer_id -> original global [spoolman] slot_spools (back-compat)."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch):
"""Before any per-printer save, both printers see the global mapping."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
"""Core regression: mapping printer 1 must not change printer 2."""
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
# printer 2 has no own section yet -> still the global fallback
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
# legacy global key preserved untouched
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
config_loader.save_spool_map({0: 5, 1: 6}, "2")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
assert config_loader.list_spool_map("2") == {0: 5, 1: 6}
def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch):
"""Writing a per-printer map must not clobber [spoolman] server/sync_rate."""
path = _use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912"
assert cfg.get("spoolman", "sync_rate") == "0"
assert cfg.get("spoolman_1", "slot_spools") == "0:42"
def test_persistence_round_trips(tmp_path, monkeypatch):
"""Save then read back (simulates a bridge restart) — the map survives."""
_use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n")
config_loader.save_spool_map({0: 7, 2: 9}, "1")
assert config_loader.list_spool_map("1") == {0: 7, 2: 9}
def test_empty_map_clears_the_key(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
config_loader.save_spool_map({}, "1") # clear
# per-printer key gone -> falls back to the legacy global map
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path,
text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n")
assert config_loader.list_spool_map() == {0: 1, 4: 5}