diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py index 7ce08422a..ee2e15074 100644 --- a/backend/app/services/github_restore.py +++ b/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: diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py index 7408b2329..31ffd89a4 100644 --- a/backend/tests/unit/test_github_restore.py +++ b/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") diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 29575f2e1..dcd3f2eaa 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4903,7 +4903,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', }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 249080d75..abb8ff40f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4948,7 +4948,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)', }, diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index fcefdac86..7cf4ae0b6 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4911,7 +4911,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: 'má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', }, diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index abc8eff83..f43732bef 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4892,7 +4892,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', }, diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 7f9bb36fb..c7cf31daf 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4891,7 +4891,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', }, diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index d87f91286..43f94218c 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4891,7 +4891,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', }, diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index e9c5dccc8..c48b165e3 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4660,7 +4660,7 @@ export default { settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}', settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными', settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии', - spoolsUsageCount: 'плюс записей расхода: {{count}}', + spoolsUsageCount: 'включая записей расхода: {{count}}', archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git', kprofilesPrinterCount: 'по {{count}} принтерам', }, diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b24f30125..89a1a317d 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4881,7 +4881,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', }, diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 8d7e0654b..48ee25c84 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -4946,7 +4946,7 @@ export default { settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}", settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими", settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії', - spoolsUsageCount: "плюс записів використання: {{count}}", + spoolsUsageCount: "включно із записами використання: {{count}}", archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git", kprofilesPrinterCount: "по {{count}} принтерах", }, diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index d7ae822b8..3fbb27ba8 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4891,7 +4891,7 @@ export default { settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键', settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭', settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复', - spoolsUsageCount: '另有 {{count}} 条使用记录', + spoolsUsageCount: '其中含 {{count}} 条使用记录', archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中', kprofilesPrinterCount: '涉及 {{count}} 台打印机', }, diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index ef440736e..e6dc050c3 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4891,7 +4891,7 @@ export default { settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵', settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉', settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原', - spoolsUsageCount: '另有 {{count}} 筆使用紀錄', + spoolsUsageCount: '其中含 {{count}} 筆使用紀錄', archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中', kprofilesPrinterCount: '涵蓋 {{count}} 台印表機', },