test_failure_reason_vocabulary_migration.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. """Migration tests for issue #2974 — one vocabulary for ``failure_reason``.
  2. Three writers used to put three spellings of one cause into the column: the
  3. backend wrote English display labels ("Layer shift"), older builds of the
  4. archive editor wrote the *translated* label in whatever locale that user was
  5. running, and two stale-archive paths wrote English prose sentences. The Failure
  6. Analysis widget groups on the raw column, so one real cause occupied several
  7. buckets.
  8. Measured on a live install before this landed: ``print_log_entries`` held 91
  9. rows reading ``"User cancelled"`` beside 1 reading ``"userCancelled"``. In an
  10. English UI those render as the same words twice with different counts, which is
  11. why nobody spotted it; in any other locale one of the two stays English.
  12. """
  13. import pytest
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.core.database import (
  17. _LEGACY_FAILURE_REASON_LABELS,
  18. _migrate_failure_reason_vocabulary,
  19. )
  20. from backend.app.models.archive import PrintArchive
  21. from backend.app.models.print_log import PrintLogEntry
  22. # The columns each model needs beyond ``failure_reason``. Rows stay minimal on
  23. # purpose -- these tests exercise the UPDATE, not the schema.
  24. _REQUIRED = {
  25. # nosec B108 - a column value, not a path anything opens. The row exists
  26. # to be UPDATEd; nothing in these tests touches the filesystem.
  27. "PrintArchive": {"filename": "x.3mf", "file_path": "/tmp/x.3mf", "file_size": 1}, # nosec B108
  28. "PrintLogEntry": {},
  29. }
  30. async def _seed(session: AsyncSession, model, values: list[str | None]) -> list[int]:
  31. """Insert one row per value through the ORM and return their ids, in order."""
  32. required = _REQUIRED[model.__name__]
  33. rows = [model(status="failed", failure_reason=v, **required) for v in values]
  34. session.add_all(rows)
  35. await session.commit()
  36. ids = []
  37. for row in rows:
  38. await session.refresh(row)
  39. ids.append(row.id)
  40. return ids
  41. async def _read(session: AsyncSession, model, ids: list[int]) -> list[str | None]:
  42. """Read ``failure_reason`` back for ``ids``, in the order given."""
  43. session.expire_all()
  44. result = await session.execute(select(model.id, model.failure_reason).where(model.id.in_(ids)))
  45. got = {row[0]: row[1] for row in result.fetchall()}
  46. return [got[i] for i in ids]
  47. async def _run(session: AsyncSession) -> None:
  48. """Drive the migration over the session's own connection.
  49. TEST_DATABASE_URL is in-memory SQLite on a shared pool, so opening a second
  50. connection would not see the seeded rows -- and production calls this with
  51. an ``AsyncConnection`` inside an open transaction anyway, which is exactly
  52. what ``session.connection()`` hands over.
  53. """
  54. await _migrate_failure_reason_vocabulary(await session.connection())
  55. await session.commit()
  56. # ---------------------------------------------------------------------------
  57. # The map itself
  58. # ---------------------------------------------------------------------------
  59. def test_every_mapped_value_is_a_canonical_key() -> None:
  60. """A label may only ever fold onto a key the rest of the stack accepts."""
  61. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  62. offenders = sorted(set(_LEGACY_FAILURE_REASON_LABELS.values()) - _FAILURE_REASON_KEYS)
  63. assert not offenders, f"map targets values nothing else recognises: {offenders}"
  64. def test_the_map_is_unambiguous() -> None:
  65. """No label may resolve to two different keys.
  66. This is what makes the conversion exact rather than a guess, and it is the
  67. property that let the migration be written at all -- the reporter's open
  68. question was what to do with a value matching no key.
  69. """
  70. assert len(_LEGACY_FAILURE_REASON_LABELS) == len(set(_LEGACY_FAILURE_REASON_LABELS))
  71. def test_the_map_covers_every_writer_that_ever_existed() -> None:
  72. """The three historical vocabularies, by example."""
  73. m = _LEGACY_FAILURE_REASON_LABELS
  74. # 1. Backend English display labels.
  75. assert m["Layer shift"] == "layerShift"
  76. assert m["Filament runout"] == "filamentRunout"
  77. assert m["Clogged nozzle"] == "cloggedNozzle"
  78. assert m["User cancelled"] == "userCancelled"
  79. # 2. Legacy archive-editor writes of a *translated* label. Not English --
  80. # that is the whole reason a locale-dependent reverse lookup could not
  81. # fix this on read.
  82. assert m["Schichtversatz"] == "layerShift"
  83. assert m["Сдвиг слоёв"] == "layerShift"
  84. # 3. The two stale-path prose sentences.
  85. assert m["Stale - print likely cancelled or failed without status update"] == "noStatusUpdate"
  86. assert m["Stale - reconciled after reconnect, end time unknown"] == "noStatusUpdate"
  87. # ---------------------------------------------------------------------------
  88. # The migration
  89. # ---------------------------------------------------------------------------
  90. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  91. async def test_labels_fold_onto_keys(db_session: AsyncSession, model) -> None:
  92. ids = await _seed(db_session, model, ["Layer shift", "Schichtversatz", "layerShift"])
  93. await _run(db_session)
  94. assert await _read(db_session, model, ids) == ["layerShift", "layerShift", "layerShift"]
  95. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  96. async def test_the_live_split_collapses(db_session: AsyncSession, model) -> None:
  97. """The exact shape measured on the maintainer's instance."""
  98. ids = await _seed(db_session, model, ["User cancelled"] * 3 + ["userCancelled"])
  99. await _run(db_session)
  100. assert set(await _read(db_session, model, ids)) == {"userCancelled"}
  101. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  102. async def test_both_stale_sentences_become_one_key(db_session: AsyncSession, model) -> None:
  103. ids = await _seed(
  104. db_session,
  105. model,
  106. [
  107. "Stale - print likely cancelled or failed without status update",
  108. "Stale - reconciled after reconnect, end time unknown",
  109. ],
  110. )
  111. await _run(db_session)
  112. assert await _read(db_session, model, ids) == ["noStatusUpdate", "noStatusUpdate"]
  113. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  114. async def test_unrecognised_values_are_left_alone(db_session: AsyncSession, model) -> None:
  115. """Free text and NULL survive untouched.
  116. Guessing at a value the map does not know would be worse than leaving one
  117. honest string in its own bucket -- it still renders through the
  118. ``defaultValue`` fallback in the editor and the Statistics breakdown.
  119. """
  120. ids = await _seed(db_session, model, ["Custom legacy reason", None, ""])
  121. await _run(db_session)
  122. assert await _read(db_session, model, ids) == ["Custom legacy reason", None, ""]
  123. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  124. async def test_running_twice_changes_nothing(db_session: AsyncSession, model) -> None:
  125. """Self-terminating, which is why it carries no one-shot settings flag.
  126. A user restoring an older database, or upgrading through this version
  127. twice, must still get their legacy rows converted -- a flag would skip them
  128. forever.
  129. """
  130. ids = await _seed(db_session, model, ["Layer shift", "Custom legacy reason"])
  131. await _run(db_session)
  132. first = await _read(db_session, model, ids)
  133. await _run(db_session)
  134. assert await _read(db_session, model, ids) == first == ["layerShift", "Custom legacy reason"]