Files
bambuddy/backend/tests/integration/test_failure_reason_vocabulary_migration.py
T
maziggy 5211fd4575 Store a failure reason in one vocabulary, not three (issue #2974)
failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".

In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.

The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.

The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.

Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.

The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
2026-08-28 08:28:27 +02:00

169 lines
7.2 KiB
Python

"""Migration tests for issue #2974 — one vocabulary for ``failure_reason``.
Three writers used to put three spellings of one cause into the column: the
backend wrote English display labels ("Layer shift"), older builds of the
archive editor wrote the *translated* label in whatever locale that user was
running, and two stale-archive paths wrote English prose sentences. The Failure
Analysis widget groups on the raw column, so one real cause occupied several
buckets.
Measured on a live install before this landed: ``print_log_entries`` held 91
rows reading ``"User cancelled"`` beside 1 reading ``"userCancelled"``. In an
English UI those render as the same words twice with different counts, which is
why nobody spotted it; in any other locale one of the two stays English.
"""
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import (
_LEGACY_FAILURE_REASON_LABELS,
_migrate_failure_reason_vocabulary,
)
from backend.app.models.archive import PrintArchive
from backend.app.models.print_log import PrintLogEntry
# The columns each model needs beyond ``failure_reason``. Rows stay minimal on
# purpose -- these tests exercise the UPDATE, not the schema.
_REQUIRED = {
"PrintArchive": {"filename": "x.3mf", "file_path": "/tmp/x.3mf", "file_size": 1},
"PrintLogEntry": {},
}
async def _seed(session: AsyncSession, model, values: list[str | None]) -> list[int]:
"""Insert one row per value through the ORM and return their ids, in order."""
required = _REQUIRED[model.__name__]
rows = [model(status="failed", failure_reason=v, **required) for v in values]
session.add_all(rows)
await session.commit()
ids = []
for row in rows:
await session.refresh(row)
ids.append(row.id)
return ids
async def _read(session: AsyncSession, model, ids: list[int]) -> list[str | None]:
"""Read ``failure_reason`` back for ``ids``, in the order given."""
session.expire_all()
result = await session.execute(select(model.id, model.failure_reason).where(model.id.in_(ids)))
got = {row[0]: row[1] for row in result.fetchall()}
return [got[i] for i in ids]
async def _run(session: AsyncSession) -> None:
"""Drive the migration over the session's own connection.
TEST_DATABASE_URL is in-memory SQLite on a shared pool, so opening a second
connection would not see the seeded rows -- and production calls this with
an ``AsyncConnection`` inside an open transaction anyway, which is exactly
what ``session.connection()`` hands over.
"""
await _migrate_failure_reason_vocabulary(await session.connection())
await session.commit()
# ---------------------------------------------------------------------------
# The map itself
# ---------------------------------------------------------------------------
def test_every_mapped_value_is_a_canonical_key() -> None:
"""A label may only ever fold onto a key the rest of the stack accepts."""
from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
offenders = sorted(set(_LEGACY_FAILURE_REASON_LABELS.values()) - _FAILURE_REASON_KEYS)
assert not offenders, f"map targets values nothing else recognises: {offenders}"
def test_the_map_is_unambiguous() -> None:
"""No label may resolve to two different keys.
This is what makes the conversion exact rather than a guess, and it is the
property that let the migration be written at all -- the reporter's open
question was what to do with a value matching no key.
"""
assert len(_LEGACY_FAILURE_REASON_LABELS) == len(set(_LEGACY_FAILURE_REASON_LABELS))
def test_the_map_covers_every_writer_that_ever_existed() -> None:
"""The three historical vocabularies, by example."""
m = _LEGACY_FAILURE_REASON_LABELS
# 1. Backend English display labels.
assert m["Layer shift"] == "layerShift"
assert m["Filament runout"] == "filamentRunout"
assert m["Clogged nozzle"] == "cloggedNozzle"
assert m["User cancelled"] == "userCancelled"
# 2. Legacy archive-editor writes of a *translated* label. Not English --
# that is the whole reason a locale-dependent reverse lookup could not
# fix this on read.
assert m["Schichtversatz"] == "layerShift"
assert m["Сдвиг слоёв"] == "layerShift"
# 3. The two stale-path prose sentences.
assert m["Stale - print likely cancelled or failed without status update"] == "noStatusUpdate"
assert m["Stale - reconciled after reconnect, end time unknown"] == "noStatusUpdate"
# ---------------------------------------------------------------------------
# The migration
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
async def test_labels_fold_onto_keys(db_session: AsyncSession, model) -> None:
ids = await _seed(db_session, model, ["Layer shift", "Schichtversatz", "layerShift"])
await _run(db_session)
assert await _read(db_session, model, ids) == ["layerShift", "layerShift", "layerShift"]
@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
async def test_the_live_split_collapses(db_session: AsyncSession, model) -> None:
"""The exact shape measured on the maintainer's instance."""
ids = await _seed(db_session, model, ["User cancelled"] * 3 + ["userCancelled"])
await _run(db_session)
assert set(await _read(db_session, model, ids)) == {"userCancelled"}
@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
async def test_both_stale_sentences_become_one_key(db_session: AsyncSession, model) -> None:
ids = await _seed(
db_session,
model,
[
"Stale - print likely cancelled or failed without status update",
"Stale - reconciled after reconnect, end time unknown",
],
)
await _run(db_session)
assert await _read(db_session, model, ids) == ["noStatusUpdate", "noStatusUpdate"]
@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
async def test_unrecognised_values_are_left_alone(db_session: AsyncSession, model) -> None:
"""Free text and NULL survive untouched.
Guessing at a value the map does not know would be worse than leaving one
honest string in its own bucket -- it still renders through the
``defaultValue`` fallback in the editor and the Statistics breakdown.
"""
ids = await _seed(db_session, model, ["Custom legacy reason", None, ""])
await _run(db_session)
assert await _read(db_session, model, ids) == ["Custom legacy reason", None, ""]
@pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
async def test_running_twice_changes_nothing(db_session: AsyncSession, model) -> None:
"""Self-terminating, which is why it carries no one-shot settings flag.
A user restoring an older database, or upgrading through this version
twice, must still get their legacy rows converted -- a flag would skip them
forever.
"""
ids = await _seed(db_session, model, ["Layer shift", "Custom legacy reason"])
await _run(db_session)
first = await _read(db_session, model, ids)
await _run(db_session)
assert await _read(db_session, model, ids) == first == ["layerShift", "Custom legacy reason"]