Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/cdumm/engine/apply_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1940,10 +1940,39 @@ def _phase(name):
if d.is_dir() and d.name.isdigit() and len(d.name) == 4
and int(d.name) >= 36
)
# The stale-overlay probe above only fires when the disabled mods
# actually managed to BUILD an overlay. A json_source mod whose
# patches all got skipped (APPLY_SILENT_FAILURE: "no PAMT entry
# for ...") is still snapshotted as applied=1 when that apply
# finishes, yet it left nothing on disk. Turning it off then gave
# empty file_deltas/revert_files/has_enabled_json AND no overlay
# dir, so Apply bailed with "No mod changes to apply or revert" —
# error, no finished() signal, so the GUI never re-snapshotted and
# the card kept its "Apply to Deactivate" badge forever, with
# every retry hitting the same error (report 2026-09-04).
# The DB's own applied-vs-enabled bookkeeping is the authority on
# whether the user has a pending change: if any paz mod's applied
# flag disagrees with its enabled flag, run the normal flow (it
# reaches orphan-cleanup, the PAPGT rebuild and a clean commit)
# so the state gets reconciled and the badge clears.
try:
has_pending_state_change = self._db.connection.execute(
"SELECT 1 FROM mods WHERE mod_type = 'paz' "
"AND COALESCE(applied, 0) != COALESCE(enabled, 0) "
"LIMIT 1").fetchone() is not None
except Exception as e: # noqa: BLE001 - bookkeeping must never
# break apply (a pre-migration DB has no `applied` column)
logger.debug("applied/enabled sync check failed: %s", e)
has_pending_state_change = False
if (not file_deltas and not revert_files and not has_enabled_json
and not has_stale_overlay):
and not has_stale_overlay and not has_pending_state_change):
self.error_occurred.emit("No mod changes to apply or revert.")
return
if (not file_deltas and not revert_files and not has_enabled_json
and not has_stale_overlay):
logger.info(
"Nothing to write, but mod applied/enabled state is out of "
"sync — running apply to reconcile it")

# Entry-level deltas (from script mods) require updating the PAMT
# after PAZ composition. Track updates here for Phase 2.
Expand Down
46 changes: 46 additions & 0 deletions tests/test_apply_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,52 @@ def test_apply_disabling_every_json_mod_does_not_bail_with_stale_overlay(
db.close()


def test_apply_deactivating_a_silently_failed_json_mod_reconciles_state(
tmp_path: Path) -> None:
"""Report 2026-09-04 (glebk): deactivating every mod gave "Apply
Failed: No mod changes to apply or revert" and the card kept its
"Apply to Deactivate" badge on every retry.

The mod was a json_source mod whose patches all got skipped at the
previous apply (APPLY_SILENT_FAILURE, "no PAMT entry for ..."), so it
was snapshotted as applied=1 while leaving NO overlay dir on disk.
Turning it off then produced empty file_deltas/revert_files/
has_enabled_json and — unlike the 2026-08-26 case — no stale overlay
to catch the bail either, so Apply errored out. error_occurred means
no finished() signal, so the GUI never re-snapshotted applied state
and the pending badge could never clear.

applied != enabled is a pending user change: Apply must run and
finish so the state gets reconciled.
"""
game_dir, vanilla_dir, db = _setup_apply_test(tmp_path)

# Disabled json_source mod still marked applied=1 by the previous
# (silently failed) apply. No mod_deltas row, no overlay dir on disk.
db.connection.execute(
"INSERT INTO mods (name, mod_type, enabled, applied, json_source, "
"priority) VALUES ('JsonMod', 'paz', 0, 1, 'C:/fake/JsonMod.json', 1)"
)
db.connection.commit()
assert not any(d.name.isdigit() and int(d.name) >= 36
for d in game_dir.iterdir() if d.is_dir()), (
"precondition: no overlay dir on disk, so has_stale_overlay can't "
"be what saves this case")

worker = ApplyWorker(game_dir, vanilla_dir, db.db_path)
errors = []
finished = []
worker.error_occurred.connect(lambda e: errors.append(e))
worker.finished.connect(lambda: finished.append(True))
worker.run()

assert errors == [], f"Apply must not bail on a pending deactivation: {errors}"
assert len(finished) == 1, (
"finished() must fire so the GUI re-snapshots applied state and the "
"'Apply to Deactivate' badge clears")
db.close()


def test_revert_no_backups(tmp_path: Path) -> None:
game_dir = tmp_path / "game"
game_dir.mkdir()
Expand Down