From 6fe7884e3584ba0d7ed1c516ca35225a69787c1b Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sun, 26 Apr 2026 03:29:48 -0700 Subject: [PATCH] fix(ui): make character accent color stable across process restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit character_accent_color() used Python's built-in hash() to map names to palette indices, but PYTHONHASHSEED is randomized by default — the same character would render in a different color every app launch. The intra-process determinism test in test_status_dock.py never caught it because both calls happened in one process. Switch to MD5 (content-addressed). Same character → same color every launch, every machine, every Python build. The hash is not used for security; usedforsecurity=False quiets bandit/FIPS environments. New tests: - test_cross_process_determinism: runs the lookup in two subprocesses with different PYTHONHASHSEED values and asserts the result is identical. Would have caught the original bug. - test_uniform_distribution_over_palette: 200 distinct names hit at least 6 of 8 palette entries (sanity check the index function actually distributes). Suite: 2400 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 11 ++++++- tests/test_main_tab.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index c1f5c39..b1d8fdb 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -99,8 +99,17 @@ def character_accent_color(name: str) -> QColor: Used by both WindowPreviewWidget (frame border) and CharacterChip (avatar fill) so visual identity is consistent across surfaces. + + Uses MD5 (not Python's built-in hash()) for cross-process determinism: + PYTHONHASHSEED is randomized by default, so hash() varies between + app launches, which would make the same character render in a + different color every session. MD5 is content-addressed and stable. """ - r, g, b = CHARACTER_ACCENT_COLORS[abs(hash(name)) % len(CHARACTER_ACCENT_COLORS)] + import hashlib + + digest = hashlib.md5(name.encode("utf-8"), usedforsecurity=False).digest() + index = digest[0] % len(CHARACTER_ACCENT_COLORS) + r, g, b = CHARACTER_ACCENT_COLORS[index] return QColor(r, g, b) diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index fab376b..1c76d00 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -10227,6 +10227,60 @@ def test_palette_has_eight_entries(self): assert len(CHARACTER_ACCENT_COLORS) == 8 + def test_cross_process_determinism(self): + """Accent color must be stable across processes (MD5-based, not hash()).""" + import subprocess + import sys + + # Run the same lookup in a fresh Python process (different PYTHONHASHSEED) + # twice with explicit randomized hash seeds and verify the result is + # identical. If the implementation relied on hash(), these would differ. + script = ( + "from argus_overview.ui.main_tab import character_accent_color; " + "print(character_accent_color('TestPilot').rgb())" + ) + env_a = {"PYTHONHASHSEED": "1"} + env_b = {"PYTHONHASHSEED": "999999"} + + import os + + env_base = os.environ.copy() + env_base.pop("QT_QPA_PLATFORM", None) + env_base["QT_QPA_PLATFORM"] = "offscreen" + + run_a = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env={**env_base, **env_a}, + ) + run_b = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env={**env_base, **env_b}, + ) + + assert run_a.returncode == 0, run_a.stderr + assert run_b.returncode == 0, run_b.stderr + assert run_a.stdout.strip() == run_b.stdout.strip() + + def test_uniform_distribution_over_palette(self): + """A wide range of character names should hit every palette index.""" + from argus_overview.ui.main_tab import ( + CHARACTER_ACCENT_COLORS, + character_accent_color, + ) + + # Generate 200 distinct names; expect coverage of every palette entry + seen_indices = set() + target = {(r, g, b) for r, g, b in CHARACTER_ACCENT_COLORS} + for i in range(200): + color = character_accent_color(f"Pilot{i:04d}") + seen_indices.add((color.red(), color.green(), color.blue())) + # 200 names should hit at least 6 of 8 palette entries. + assert len(seen_indices & target) >= 6 + class TestCharacterAccentChipFrameMatch: """Same character → same color across the frame and the chip."""