Skip to content

chore(saves): URL encoding, ISO helper, dead code cleanup#264

Merged
danielcopper merged 9 commits intomainfrom
chore/258-saves-cleanup
May 5, 2026
Merged

chore(saves): URL encoding, ISO helper, dead code cleanup#264
danielcopper merged 9 commits intomainfrom
chore/258-saves-cleanup

Conversation

@danielcopper
Copy link
Copy Markdown
Owner

@danielcopper danielcopper commented May 5, 2026

Summary

PR 1 of 2 for #258 (post-0.16 save sync polish).

  • URL encoding — slot names and device IDs are now percent-encoded on every RomM API call (list_saves, upload_save, get_save_summary, download_save_content, update_device). Previously interpolated raw — real bug for slot names containing & / = / space; defensive on UUIDs.
  • ISO time helper — new lib/iso_time (parse_iso, parse_iso_to_epoch) replaces four lexical max(updated_at) callsites with epoch-based comparison. Consolidates duplicate parsing across domain/sync_action, services/saves, services/playtime, domain/save_status. domain → lib allowed in .importlinter (lib stays independent of every other layer).
  • Dead code removed — the set_game_slot Decky callable (no frontend caller; service method stays, still used internally by switch_slot), the unresolved_conflicts switchSlot reason (backend never returned it), and the entirely dead domain/save_conflicts.py module (replaced by the matrix model in 0.16). Wiki references in Backend-Architecture.md / Development.md updated in a separate wiki commit.
  • Coverage — closed branch-coverage gaps in _format_time_ago (parse failure + naive timestamp paths).

I-1 from the parent issue was a docs-only fix and shipped earlier in the wiki. I-3 (conflict_mode setting removal) is deferred to PR 2 because it cuts across model, persistence, settings UI, and launch interceptor logic.

Closes I-4, I-5, I-6, I-7 from #258.

Test plan

  • CI green: pytest, ruff, ruff format, basedpyright, lint-imports, pnpm build
  • Manual: launch a ROM, sync a save, switch slots — behaviour unchanged
  • Manual: SavesTab loads cleanly after unresolved_conflicts reason removal (no console errors)
  • Manual: a slot named with &/space round-trips through list/upload without server-side parse error

User-typed slot values (e.g. "Mom & Dad", "draft=1") and server-issued
device IDs were being interpolated raw into query strings, allowing
reserved URL characters to corrupt the request. Wrap each value with
urllib.parse.quote(safe="") in list_saves, upload_save, and
get_save_summary. device_id is normally a UUID; encode it defensively.
domain.sync_action, domain.save_status, and services.saves all had
near-duplicate ISO-8601 parsing — and three of them used lexical max() on
raw timestamp strings, which silently breaks once timezones differ. Move
parse_iso / parse_iso_to_epoch into lib.iso_time and switch all
"newest server save" picks to epoch-keyed comparisons. Unparseable
timestamps now sort to the bottom (0.0) instead of polluting the order.

Allow domain → lib in importlinter: lib now hosts layer-agnostic helpers
shared by domain and services.
The frontend never calls setGameSlot — only the export and the
matching @migration_blocked wrapper existed. Slot changes go through
switchSlot exclusively, which already calls SaveService.set_game_slot
internally (and that service method stays).
The backend switch_slot only ever returns sync_disabled, not_installed,
pending_uploads, or server_unreachable. The unresolved_conflicts branch
in SavesTab and the union member in SwitchSlotResponse have always been
dead.
domain.save_conflicts was orphaned by the 0.16 newest-wins rewrite
(#253). Nothing in py_modules, main.py, or tests imports it apart from
its own test file. The SaveConflict model class is unrelated and lives
in models.saves — still in use across services.saves.
Mirrors the encoding applied in 04c1013 to list_saves, upload_save, and
get_save_summary. download_save_content and update_device interpolated
device_id raw — same UUID source, encode defensively for symmetry.
Aligns upload_save with the rest of the file — every other quote() call
passes safe="". Existing test updated to assert the slash gets encoded
to %2F too.
Adds tests for the two uncovered branches in domain/save_status.py:
- Malformed timestamp -> _format_time_ago returns None -> 'Not synced' fallback
- Naive timestamp (no tzinfo) -> treated as UTC, no crash

The 'Just now' branch was already covered by test_synced_just_now.
Branch coverage on save_status.py now 100%.
Replaces datetime.fromisoformat with parse_iso for symmetry with the
rest of the codebase (introduced in df0cdec). Adds an explicit None
guard for unparseable input — semantics preserved (still falls into the
'Failed to calculate session duration' path), and adds a regression
test that didn't exist before.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 5, 2026

@danielcopper danielcopper merged commit 81fda8f into main May 5, 2026
9 checks passed
@danielcopper danielcopper deleted the chore/258-saves-cleanup branch May 5, 2026 19:17
danielcopper added a commit that referenced this pull request May 6, 2026
* chore(saves): remove conflict_mode setting

The 0.16 newest-wins matrix sync rewrite removed the only meaningful
consumer of conflict_mode (resolve_conflict_by_mode in domain/save_conflicts,
deleted in #264). The remaining frontend gate kept the launch-time conflict
check optional; that check should always run, so drop the setting end-to-end
and let the interceptor block launches whenever a save conflict is pending.

* chore(saves): remove clock_skew_tolerance_sec setting

The only consumer (resolve_conflict_by_mode in domain/save_conflicts) was
deleted with the 0.16 newest-wins matrix sync rewrite (#264). Drop the
setting from the dataclass, defaults, sanitiser, and frontend type.

* chore(saves): strip legacy settings keys on state load

load_state merges persisted settings via dict.update, so old state files
keep removed keys (conflict_mode, clock_skew_tolerance_sec) forever. Pop
them in _migrate_loaded_state so the next save_state writes a clean file.

* chore(saves): polish migration strip - guard, coverage, doc

- Drop the throwaway empty-dict fallback from the legacy settings
  strip in `_migrate_loaded_state`; missing/non-dict settings now
  cleanly fall through the `isinstance` guard.
- Pin the defensive guard with two tests: missing `settings` key
  and non-dict `settings` value.

* refactor(saves): reduce _migrate_loaded_state cognitive complexity

Extract the saves-block migration and the legacy-settings strip into
private helpers (_migrate_saves_entries, _strip_legacy_settings).
_migrate_loaded_state becomes a thin dispatcher. Cognitive complexity
22 -> below threshold (Sonar python:S3776).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant