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
11 changes: 10 additions & 1 deletion src/argus_overview/ui/main_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment on lines +110 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The usedforsecurity keyword argument was introduced in Python 3.9. Using it directly will cause a TypeError on older Python versions (such as 3.8), which are still common in many environments. Since this is a UI utility, consider adding a fallback for compatibility.

Additionally, please note that ArrangementGrid.add_character (line 430) still uses the unstable hash() function and a local color palette. It should be refactored to use this character_accent_color helper to ensure that character colors are stable and consistent across all UI components.

Suggested change
digest = hashlib.md5(name.encode("utf-8"), usedforsecurity=False).digest()
index = digest[0] % len(CHARACTER_ACCENT_COLORS)
r, g, b = CHARACTER_ACCENT_COLORS[index]
try:
digest = hashlib.md5(name.encode("utf-8"), usedforsecurity=False).digest()
except TypeError:
# Fallback for Python < 3.9
digest = hashlib.md5(name.encode("utf-8")).digest()
index = digest[0] % len(CHARACTER_ACCENT_COLORS)
r, g, b = CHARACTER_ACCENT_COLORS[index]

return QColor(r, g, b)


Expand Down
54 changes: 54 additions & 0 deletions tests/test_main_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -10257,6 +10257,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."""
Expand Down
Loading