mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
|
||||
@@ -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: '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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -4767,7 +4767,7 @@ export default {
|
||||
settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
|
||||
settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
|
||||
settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
|
||||
spoolsUsageCount: 'плюс записей расхода: {{count}}',
|
||||
spoolsUsageCount: 'включая записей расхода: {{count}}',
|
||||
archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
|
||||
kprofilesPrinterCount: 'по {{count}} принтерам',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -5054,7 +5054,7 @@ export default {
|
||||
settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
|
||||
settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
|
||||
settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
|
||||
spoolsUsageCount: "плюс записів використання: {{count}}",
|
||||
spoolsUsageCount: "включно із записами використання: {{count}}",
|
||||
archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
|
||||
kprofilesPrinterCount: "по {{count}} принтерах",
|
||||
},
|
||||
|
||||
@@ -5000,7 +5000,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
|
||||
settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
|
||||
spoolsUsageCount: '另有 {{count}} 条使用记录',
|
||||
spoolsUsageCount: '其中含 {{count}} 条使用记录',
|
||||
archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
|
||||
kprofilesPrinterCount: '涉及 {{count}} 台打印机',
|
||||
},
|
||||
|
||||
@@ -5000,7 +5000,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
|
||||
settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
|
||||
spoolsUsageCount: '另有 {{count}} 筆使用紀錄',
|
||||
spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
|
||||
archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
|
||||
kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user