mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 09:39:46 -05:00
A tag can now be linked to a filament type, not just to one spool of it. The
tag table already carried filament_id and target_type for exactly this, so
there is no migration.
API, all additive:
POST /filament/{id}/tag link; 409 if anything else holds the UID
DELETE /filament/{id}/tag/{uid} unlink
GET /filament?tag=<uid> exact lookup on the normalized UID
Filament.tags also inside every spool's embedded filament
TagScan.matched_filament_id always present, like matched_spool_id
TagConflictMessage.filament_id names a filament holder on a 409
One UID still identifies one thing across both kinds: a spool's tag cannot be
linked to a filament or the reverse, and neither ?tag= lookup answers with the
other kind. The scan debounce key now includes the kind, since spool 7 and
filament 7 are different answers.
SpoolTag and SpoolTagParameters are renamed Tag and TagLinkParameters while
they are unreleased, so the schema does not describe filament tags as spool
tags forever.
client_v2: the filament inspector gets the Tags section, the link dialog names
and moves a tag held by either kind, and scanning a filament's tag opens that
filament when auto-navigate is on.
Tag having two delete-orphan parents was checked on SQLAlchemy 2.0.51: new tags
flush, unlinking deletes the row, and deleting either parent cascades.
Proven on sqlite, postgres, mariadb and cockroachdb: 558 integration tests
each. Unit tests, svelte-check and the Playwright tag specs pass.
212 lines
9.5 KiB
Python
212 lines
9.5 KiB
Python
"""Tests for the scan relay's debounce cache and reader registry.
|
|
|
|
Both are pure in-memory state, so they are tested here without a socket or a session. Time is
|
|
injected rather than slept through, which is what keeps this file fast enough to be worth
|
|
running on every change.
|
|
"""
|
|
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from spoolman.scanrelay import MAX_READERS, READER_ID_PATTERN, ScanRelay, derive_reader_id
|
|
|
|
# Aware, matching the relay's own clock. Injected time and the relay's default must agree:
|
|
# register() writes last_seen and readers() prunes against it, so a naive value here would
|
|
# only pass because these tests inject every clock -- see the internal-consistency test below.
|
|
T0 = datetime(2026, 8, 13, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
@pytest.fixture
|
|
def relay() -> ScanRelay:
|
|
return ScanRelay(debounce_window=timedelta(seconds=3), reader_ttl=timedelta(hours=1))
|
|
|
|
|
|
def test_first_scan_broadcasts(relay: ScanRelay):
|
|
assert relay.should_broadcast("04A2", "desk", None, now=T0) is True
|
|
|
|
|
|
def test_repeat_within_the_window_is_suppressed(relay: ScanRelay):
|
|
"""A reader re-reads a tag that is sitting still; subscribers should see one event."""
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=1)) is False
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=2.9)) is False
|
|
|
|
|
|
def test_repeat_after_the_window_broadcasts_again(relay: ScanRelay):
|
|
"""Tapping the same tag again later is a new scan, not a duplicate."""
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=4)) is True
|
|
|
|
|
|
def test_debounce_is_per_uid_and_per_reader(relay: ScanRelay):
|
|
"""Two readers tapping at once, or one reader seeing two tags, are distinct scans."""
|
|
relay.should_broadcast("04A2", "desk", None, now=T0)
|
|
assert relay.should_broadcast("04A2", "printer", None, now=T0) is True
|
|
assert relay.should_broadcast("B1C2", "desk", None, now=T0) is True
|
|
|
|
|
|
def test_a_changed_match_is_not_a_repeat(relay: ScanRelay):
|
|
"""The correction after a tag is linked must go out, not wait for the window (#1115).
|
|
|
|
An agent taps an unlinked tag, links it in its own UI, then re-reports the scan so the
|
|
paired browser learns the tag is known. Keyed on `(uid, reader)` alone, that second scan
|
|
looked like a duplicate of the first and the browser kept showing "unknown tag".
|
|
"""
|
|
assert relay.should_broadcast("04A2", "desk", None, now=T0) is True
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=1)) is True
|
|
|
|
|
|
def test_a_repeat_of_an_unlinked_tag_is_still_suppressed(relay: ScanRelay):
|
|
"""The reason the debounce exists survives the fix: the key stays `(uid, reader, None)`."""
|
|
relay.should_broadcast("04A2", "desk", None, now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", None, now=T0 + timedelta(seconds=1)) is False
|
|
|
|
|
|
def test_repeats_of_the_corrected_match_are_suppressed(relay: ScanRelay):
|
|
"""A tag left sitting on the reader after being linked is still one event, not a stream."""
|
|
relay.should_broadcast("04A2", "desk", None, now=T0)
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=1))
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0 + timedelta(seconds=1.5)) is False
|
|
|
|
|
|
def test_losing_a_match_is_also_a_change(relay: ScanRelay):
|
|
"""Unlinking is the same event in reverse: the browser has to hear it lost its spool."""
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", None, now=T0 + timedelta(seconds=1)) is True
|
|
|
|
|
|
def test_a_tag_moved_to_another_spool_is_a_change(relay: ScanRelay):
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", ("spool", 8), now=T0 + timedelta(seconds=1)) is True
|
|
|
|
|
|
def test_a_spool_and_a_filament_with_the_same_id_are_different_matches(relay: ScanRelay):
|
|
"""Spool 7 and filament 7 are different answers, so a tag moving between them is a change."""
|
|
relay.should_broadcast("04A2", "desk", ("spool", 7), now=T0)
|
|
assert relay.should_broadcast("04A2", "desk", ("filament", 7), now=T0 + timedelta(seconds=1)) is True
|
|
|
|
|
|
def test_debounce_entries_are_pruned(relay: ScanRelay):
|
|
"""The cache does not grow without bound as tags come and go."""
|
|
for i in range(50):
|
|
relay.should_broadcast(f"UID{i:04X}", "desk", None, now=T0)
|
|
assert len(relay._last_broadcast) == 50 # noqa: SLF001
|
|
relay.should_broadcast("LATER", "desk", None, now=T0 + timedelta(seconds=30))
|
|
assert len(relay._last_broadcast) == 1 # noqa: SLF001
|
|
|
|
|
|
def test_readers_are_listed_most_recently_seen_first(relay: ScanRelay):
|
|
relay.register("desk", "Desk reader", now=T0)
|
|
relay.register("printer", "Voron spool holder", now=T0 + timedelta(minutes=1))
|
|
|
|
readers = relay.readers(now=T0 + timedelta(minutes=2))
|
|
assert [r.reader_id for r in readers] == ["printer", "desk"]
|
|
assert readers[0].name == "Voron spool holder"
|
|
assert readers[1].last_seen == T0
|
|
|
|
|
|
def test_registry_is_empty_before_anything_scans(relay: ScanRelay):
|
|
"""The registry is not persisted, so this is also the state after a restart."""
|
|
assert relay.readers(now=T0) == []
|
|
|
|
|
|
def test_reader_keeps_its_name_when_a_later_scan_omits_one(relay: ScanRelay):
|
|
"""A trimmed-down agent must not blank the label in someone's picker."""
|
|
relay.register("desk", "Desk reader", now=T0)
|
|
relay.register("desk", None, now=T0 + timedelta(minutes=1))
|
|
|
|
readers = relay.readers(now=T0 + timedelta(minutes=2))
|
|
assert readers[0].name == "Desk reader"
|
|
assert readers[0].last_seen == T0 + timedelta(minutes=1)
|
|
|
|
|
|
def test_a_new_name_replaces_the_old_one(relay: ScanRelay):
|
|
relay.register("desk", "Desk reader", now=T0)
|
|
relay.register("desk", "Bench reader", now=T0 + timedelta(minutes=1))
|
|
assert relay.readers(now=T0 + timedelta(minutes=2))[0].name == "Bench reader"
|
|
|
|
|
|
def test_reader_remembers_the_last_uid_it_reported(relay: ScanRelay):
|
|
"""What is on the reader right now, so a client can offer it instead of asking for a tap."""
|
|
relay.register("desk", "Desk reader", "04A2B3C4", now=T0)
|
|
assert relay.readers(now=T0)[0].last_uid == "04A2B3C4"
|
|
|
|
|
|
def test_a_new_scan_replaces_the_remembered_uid(relay: ScanRelay):
|
|
"""One UID, not a history: this answers "what is on the reader", not "what has been"."""
|
|
relay.register("desk", None, "04A2B3C4", now=T0)
|
|
relay.register("desk", None, "0455667788", now=T0 + timedelta(minutes=1))
|
|
assert relay.readers(now=T0 + timedelta(minutes=2))[0].last_uid == "0455667788"
|
|
|
|
|
|
def test_reader_keeps_its_uid_when_a_later_register_omits_one(relay: ScanRelay):
|
|
"""Same rule as the name, so registering for another reason cannot blank it."""
|
|
relay.register("desk", None, "04A2B3C4", now=T0)
|
|
relay.register("desk", "Desk reader", now=T0 + timedelta(minutes=1))
|
|
assert relay.readers(now=T0 + timedelta(minutes=2))[0].last_uid == "04A2B3C4"
|
|
|
|
|
|
def test_reader_that_has_not_scanned_has_no_uid(relay: ScanRelay):
|
|
"""Null rather than an empty string: "nothing seen yet" is not "seen nothing"."""
|
|
relay.register("desk", "Desk reader", now=T0)
|
|
assert relay.readers(now=T0)[0].last_uid is None
|
|
|
|
|
|
def test_readers_past_their_ttl_are_evicted_on_read(relay: ScanRelay):
|
|
"""Eviction happens as the response is built: no background task, nothing to shut down."""
|
|
relay.register("desk", None, now=T0)
|
|
assert len(relay.readers(now=T0 + timedelta(minutes=59))) == 1
|
|
assert relay.readers(now=T0 + timedelta(hours=2)) == []
|
|
|
|
|
|
def test_registry_is_capped(relay: ScanRelay):
|
|
"""An unauthenticated endpoint cannot be used to grow the registry without bound."""
|
|
for i in range(MAX_READERS + 20):
|
|
relay.register(f"reader-{i}", None, now=T0 + timedelta(seconds=i))
|
|
|
|
readers = relay.readers(now=T0 + timedelta(minutes=30))
|
|
assert len(readers) == MAX_READERS
|
|
# The least recently seen are the ones dropped.
|
|
assert readers[0].reader_id == f"reader-{MAX_READERS + 19}"
|
|
assert {r.reader_id for r in readers} == {f"reader-{i}" for i in range(20, MAX_READERS + 20)}
|
|
|
|
|
|
def test_the_relays_own_clock_is_internally_consistent():
|
|
"""Exercise the default `now` on every method, which the injected-time tests never do.
|
|
|
|
Each method reaching for its own clock is exactly where a naive/aware mismatch hides: the
|
|
stored last_seen and the pruning cutoff are compared, so one clock disagreeing with the
|
|
others makes GET /tag/reader raise rather than return.
|
|
"""
|
|
relay = ScanRelay()
|
|
relay.register("desk", "Desk reader")
|
|
assert relay.should_broadcast("04A2", "desk", None) is True
|
|
assert relay.should_broadcast("04A2", "desk", None) is False
|
|
|
|
listed = relay.readers()
|
|
assert [r.reader_id for r in listed] == ["desk"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("host", "expected"),
|
|
[
|
|
("192.168.1.50", "ip-192-168-1-50"),
|
|
("10.0.0.1", "ip-10-0-0-1"),
|
|
("fe80::1", "ip-fe80-1"),
|
|
("testclient", "ip-testclient"),
|
|
(None, "unknown"),
|
|
("", "unknown"),
|
|
("...", "unknown"),
|
|
],
|
|
)
|
|
def test_reader_id_is_derived_from_the_client_address(host: str | None, expected: str):
|
|
"""An unmodified agent that sends no reader_id still gets a stable id to pair against."""
|
|
assert derive_reader_id(host) == expected
|
|
|
|
|
|
def test_derived_reader_id_fits_the_pattern():
|
|
"""Whatever comes out must be usable as a websocket path segment."""
|
|
assert re.match(READER_ID_PATTERN, derive_reader_id("a" * 200))
|