Просмотр исходного кода

fix(backup): keep both restore tallies equal to the number the preview showed (#2656)

    Two ways the K-profile and spool categories broke the
    restored + skipped + failed == item_count invariant the settings count
    holds:

    * The spools preview counted only the spools and put the usage records
      in the detail, but _restore_spool_usage increments the same tally, so
      any backup with usage history reported a total larger than the number
      the user was shown. The preview now counts both and the detail breaks
      the total down instead of adding to it.
    * A K-profile entry that is not a dict was dropped silently on the
      connected path. _kprofile_profile_count includes it, so the offline,
      printer-missing and step-failed paths all account for it; only the one
      path that talks to a printer let it leave the tally. It now counts
      failed.
maziggy 3 недель назад
Родитель
Сommit
a0d1c3674b

+ 14 - 1
backend/app/services/github_restore.py

@@ -657,10 +657,16 @@ class GitHubRestoreService:
             spools = payload.get("spools") if isinstance(payload, dict) else None
             usage_payload = parsed.get(SPOOL_USAGE_PATH)
             usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+            # Usage records are counted here, not just described in the detail:
+            # _restore_spool_usage increments this category's tally, so counting
+            # only the spools broke restored + skipped + failed == item_count —
+            # the invariant the settings count is careful to hold. The detail
+            # breaks the total down rather than adding to it.
             count = len(spools) if isinstance(spools, list) else 0
             detail = None
             if isinstance(usage, list) and usage:
-                detail = _Detail("spoolsUsageCount", f"plus {len(usage)} usage records", {"count": len(usage)})
+                count += len(usage)
+                detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
             return count, detail
 
         if category == RestoreCategory.ARCHIVES:
@@ -1686,6 +1692,13 @@ class GitHubRestoreService:
                 claimed: set[int] = set()
                 for p in profiles:
                     if not isinstance(p, dict):
+                        # Counted, not dropped. _kprofile_profile_count includes
+                        # it, so the offline and printer-missing paths already
+                        # count the same entry skipped and the failure path
+                        # counts it outstanding — leaving the tally here was the
+                        # one place a profile could vanish from
+                        # restored + skipped + failed entirely.
+                        tally.failed += 1
                         continue
                     match = self._match_kprofile(p, current, claimed)
                     if match is None:

+ 73 - 0
backend/tests/unit/test_github_restore.py

@@ -411,6 +411,39 @@ class TestCompanionCredentials:
         assert tally.restored + tally.skipped + tally.failed == item_count
         assert (tally.restored, tally.skipped, tally.failed) == (1, 2, 1)
 
+    @pytest.mark.asyncio
+    async def test_the_spools_tally_holds_the_same_invariant(self, db_session):
+        """Spools broke it the other way: the tally counted more than the preview.
+
+        ``_restore_spool_usage`` increments this category's tally, but the
+        preview counted only the spools and mentioned the usage records in the
+        detail — so a backup with any usage history reported a total larger than
+        the number the user was shown.
+        """
+        spools = {
+            "spools": [
+                {"id": 1, "material": "PLA", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
+                {"id": 2, "material": "PETG", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
+            ]
+        }
+        usage = {
+            "usage_history": [
+                {"id": 9, "spool_id": 1, "grams_used": 12.5, "created_at": "2026-01-06 09:00:00"},
+                {"id": 10, "spool_id": 2, "grams_used": 4.0, "created_at": "2026-01-06 10:00:00"},
+                {"id": 11, "spool_id": 404, "grams_used": 1.0, "created_at": "2026-01-06 11:00:00"},
+            ]
+        }
+        item_count, _ = await _service()._count_items(
+            db_session, RestoreCategory.SPOOLS, {SPOOLS_PATH: spools, SPOOL_USAGE_PATH: usage}
+        )
+
+        tally = _CategoryTally()
+        await _service()._restore_spools(db_session, spools, usage, False, tally, {})
+        await db_session.commit()
+
+        assert item_count == 5, "two spools plus three usage records, all of which the tally counts"
+        assert tally.restored + tally.skipped + tally.failed == item_count
+
     @pytest.mark.asyncio
     async def test_preview_count_drops_by_one_when_the_local_credential_is_missing(self, db_session):
         parsed = {
@@ -1878,6 +1911,46 @@ class TestRestoreKprofiles:
         assert tally.failed == 0
         assert any("not connected" in note for note in _messages(tally))
 
+    @pytest.mark.asyncio
+    async def test_a_non_dict_profile_is_counted_failed_not_dropped(self, db_session, printer_factory):
+        """The online path was the one place an entry left the tally entirely.
+
+        ``_kprofile_profile_count`` counts it, so the offline and
+        printer-missing paths already count the same entry skipped and the
+        failure path counts it outstanding — only the connected path skipped it
+        silently, so restored + skipped + failed came up short of the number the
+        preview showed.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        path = next(iter(payload))
+        payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=self._client())
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert tally.failed == 1
+        assert tally.restored + tally.skipped + tally.failed == 2
+
+    @pytest.mark.asyncio
+    async def test_the_offline_path_counts_the_same_entry(self, db_session, printer_factory):
+        """Control for the above: the two paths have to agree on the total."""
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        path = next(iter(payload))
+        payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
+        client = MagicMock()
+        client.state.connected = False
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert tally.restored + tally.skipped + tally.failed == 2
+
     @pytest.mark.asyncio
     async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
         await printer_factory(serial_number="00M09A123456789")

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -5012,7 +5012,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
         settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
         settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
-        spoolsUsageCount: 'zzgl. {{count}} Verbrauchseinträge',
+        spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
         archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
         kprofilesPrinterCount: 'über {{count}} Drucker',
       },

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -5057,7 +5057,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
         settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
         settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
-        spoolsUsageCount: 'plus {{count}} usage record(s)',
+        spoolsUsageCount: 'including {{count}} usage record(s)',
         archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
         kprofilesPrinterCount: 'across {{count}} printer(s)',
       },

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -5019,7 +5019,7 @@ export default {
         settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
         settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
         settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
-        spoolsUsageCount: 's {{count}} registros de consumo',
+        spoolsUsageCount: 'incluidos {{count}} registros de consumo',
         archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
         kprofilesPrinterCount: 'en {{count}} impresoras',
       },

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -5001,7 +5001,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
         settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
         settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
-        spoolsUsageCount: 'plus {{count}} enregistrements de consommation',
+        spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
         archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
         kprofilesPrinterCount: 'sur {{count}} imprimantes',
       },

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -5000,7 +5000,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
         settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
         settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
-        spoolsUsageCount: 'più {{count}} record di consumo',
+        spoolsUsageCount: 'inclusi {{count}} record di consumo',
         archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
         kprofilesPrinterCount: 'su {{count}} stampanti',
       },

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -5000,7 +5000,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
         settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
         settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
-        spoolsUsageCount: 'mais {{count}} registros de consumo',
+        spoolsUsageCount: 'incluindo {{count}} registros de consumo',
         archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
         kprofilesPrinterCount: 'em {{count}} impressoras',
       },

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -4767,7 +4767,7 @@ export default {
         settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
         settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
         settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
-        spoolsUsageCount: 'плюс записей расхода: {{count}}',
+        spoolsUsageCount: 'включая записей расхода: {{count}}',
         archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
         kprofilesPrinterCount: 'по {{count}} принтерам',
       },

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -4989,7 +4989,7 @@ export default {
         settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
         settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
         settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
-        spoolsUsageCount: 'ayrıca {{count}} kullanım kaydı',
+        spoolsUsageCount: '{{count}} kullanım kaydı dahil',
         archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
         kprofilesPrinterCount: '{{count}} yazıcı genelinde',
       },

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -5054,7 +5054,7 @@ export default {
         settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
         settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
         settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
-        spoolsUsageCount: "плюс записів використання: {{count}}",
+        spoolsUsageCount: "включно із записами використання: {{count}}",
         archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
         kprofilesPrinterCount: "по {{count}} принтерах",
       },

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -5000,7 +5000,7 @@ export default {
         settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
         settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
         settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
-        spoolsUsageCount: '另有 {{count}} 条使用记录',
+        spoolsUsageCount: '其中含 {{count}} 条使用记录',
         archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
         kprofilesPrinterCount: '涉及 {{count}} 台打印机',
       },

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -5000,7 +5000,7 @@ export default {
         settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
         settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
         settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
-        spoolsUsageCount: '另有 {{count}} 筆使用紀錄',
+        spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
         archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
         kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
       },