Merge branch 'main' into fix/78-79-update-visibility
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Three conflicts, two of them semantic rather than textual:
- bcc.py QSS: this branch added the noticeBanner rules using the old
module-level constants ({MUTED}, {ACCENT}); main had since moved the
stylesheet onto palette slots ({p.muted}). Took main's form and
translated the notice rules into it -- picking either side wholesale
would have either dropped the banner styling or reintroduced globals
that test_stylesheet_builder_has_no_hardcoded_colours now forbids.
- bcc.py methods: both sides appended to MainWindow (update-notice
handlers vs theme handlers). Additive, kept both.
- tests/test_core.py: the usual EOF append. Kept both blocks.
_build_menu_bar auto-merged cleanly (View menu above, Help menu below);
verified both are present with their menu roles intact.
Verified: 272 test functions = 265 (main) + 7 (this branch), no
duplicates; 421 passed, ruff clean.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Pytest port of the original test_core.py script (same 23 behaviours, now
|
||||
proper test functions with tmp_path/monkeypatch fixtures)."""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -2372,6 +2373,261 @@ def test_config_has_unfilled_placeholders_checks_env_too():
|
||||
assert c.config_has_unfilled_placeholders(cfg) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #72 -- a server value that isn't a JSON object must not take the load down
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("bad", ["not-a-dict", 123, ["a", "b"], None, True, 1.5])
|
||||
def test_extract_servers_survives_non_dict_server_value(bad):
|
||||
entries = c.extract_servers({"mcpServers": {"foo": bad}})
|
||||
assert len(entries) == 1
|
||||
assert entries[0].name == "foo"
|
||||
assert entries[0].data == {}
|
||||
assert entries[0].malformed is True
|
||||
assert entries[0].raw == bad
|
||||
|
||||
|
||||
def test_extract_servers_marks_only_the_bad_entry():
|
||||
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
|
||||
by_name = {e.name: e for e in c.extract_servers(cfg)}
|
||||
assert by_name["good"].malformed is False
|
||||
assert by_name["good"].data == {"command": "npx"}
|
||||
assert by_name["bad"].malformed is True
|
||||
|
||||
|
||||
def test_extract_servers_handles_malformed_disabled_entry():
|
||||
entries = c.extract_servers({c.DISABLED_KEY: {"parked": ["nope"]}})
|
||||
assert entries[0].enabled is False
|
||||
assert entries[0].malformed is True
|
||||
|
||||
|
||||
def test_malformed_entry_round_trips_through_save_unchanged():
|
||||
"""The cardinal rule: never silently delete what the user had on disk."""
|
||||
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
|
||||
servers = c.extract_servers(cfg)
|
||||
out = c.apply_servers(dict(cfg), servers)
|
||||
assert out["mcpServers"]["bad"] == "oops"
|
||||
assert out["mcpServers"]["good"] == {"command": "npx"}
|
||||
|
||||
|
||||
def test_editing_a_malformed_entry_retires_the_raw_value():
|
||||
entry = c.extract_servers({"mcpServers": {"bad": "oops"}})[0]
|
||||
entry.set_data({"command": "npx"})
|
||||
assert entry.malformed is False
|
||||
assert entry.config_value() == {"command": "npx"}
|
||||
assert c.apply_servers({}, [entry])["mcpServers"]["bad"] == {"command": "npx"}
|
||||
|
||||
|
||||
def test_lint_reports_the_malformed_entry_by_name():
|
||||
servers = c.extract_servers({"mcpServers": {"bad": "oops"}})
|
||||
warnings = c.lint_servers(servers)
|
||||
assert len(warnings) == 1
|
||||
assert "'bad'" in warnings[0]
|
||||
assert "not an object" in warnings[0]
|
||||
assert "str" in warnings[0]
|
||||
|
||||
|
||||
def test_lint_still_reports_normal_warnings_alongside_malformed():
|
||||
cfg = {"mcpServers": {"bad": "oops", "sloppy": {"command": "npx", "args": "one two"}}}
|
||||
warnings = c.lint_servers(c.extract_servers(cfg))
|
||||
assert any("not an object" in w for w in warnings)
|
||||
assert any("'args' should be a list" in w for w in warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #73 -- the stale-file merge must not discard BCC-authored keys
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_carry_owned_keys_moves_sets_onto_the_reloaded_config():
|
||||
local = {"mcpServers": {}, c.SETS_KEY: {"work": ["a", "b"]}}
|
||||
fresh = {"mcpServers": {"external": {"command": "npx"}}}
|
||||
contested = c.carry_owned_keys(local, fresh)
|
||||
assert contested == []
|
||||
assert fresh[c.SETS_KEY] == {"work": ["a", "b"]}
|
||||
assert fresh["mcpServers"] == {"external": {"command": "npx"}}
|
||||
|
||||
|
||||
def test_carry_owned_keys_reports_a_genuine_conflict():
|
||||
local = {c.SETS_KEY: {"work": ["a"]}}
|
||||
fresh = {c.SETS_KEY: {"work": ["a", "b"]}}
|
||||
assert c.carry_owned_keys(local, fresh) == [c.SETS_KEY]
|
||||
assert fresh[c.SETS_KEY] == {"work": ["a"]} # local wins: BCC owns the key
|
||||
|
||||
|
||||
def test_carry_owned_keys_is_quiet_when_both_sides_agree():
|
||||
local = {c.SETS_KEY: {"work": ["a"]}}
|
||||
fresh = {c.SETS_KEY: {"work": ["a"]}}
|
||||
assert c.carry_owned_keys(local, fresh) == []
|
||||
|
||||
|
||||
def test_carry_owned_keys_leaves_disk_alone_when_absent_locally():
|
||||
"""Can't distinguish 'deleted my last set' from 'never had sets'; keep theirs."""
|
||||
fresh = {c.SETS_KEY: {"remote": ["a"]}}
|
||||
assert c.carry_owned_keys({}, fresh) == []
|
||||
assert fresh[c.SETS_KEY] == {"remote": ["a"]}
|
||||
|
||||
|
||||
def test_carry_owned_keys_deep_copies_so_later_edits_do_not_leak():
|
||||
local = {c.SETS_KEY: {"work": ["a"]}}
|
||||
fresh = {}
|
||||
c.carry_owned_keys(local, fresh)
|
||||
local[c.SETS_KEY]["work"].append("b")
|
||||
assert fresh[c.SETS_KEY] == {"work": ["a"]}
|
||||
|
||||
|
||||
def test_merge_flow_preserves_sets_and_external_servers(tmp_path):
|
||||
"""End-to-end shape of the Merge & save path that lost sets in #73."""
|
||||
path = tmp_path / "claude.json"
|
||||
path.write_text(json.dumps({"mcpServers": {"old": {"command": "old"}}}))
|
||||
|
||||
# BCC loads, user saves a named set and edits servers in memory.
|
||||
local = c.load_config(path)
|
||||
servers = c.extract_servers(local)
|
||||
c.save_server_set(local, "work", servers)
|
||||
|
||||
# Something else rewrites the file underneath us.
|
||||
path.write_text(json.dumps({"mcpServers": {"external": {"command": "new"}}, "other": 1}))
|
||||
|
||||
# Merge & save: reload disk, carry BCC keys, re-apply the user's servers.
|
||||
fresh = c.load_config(path)
|
||||
c.carry_owned_keys(local, fresh)
|
||||
c.apply_servers(fresh, servers)
|
||||
c.write_config(path, fresh)
|
||||
|
||||
saved = c.load_config(path)
|
||||
assert saved[c.SETS_KEY] == {"work": ["old"]} # the set survived
|
||||
assert saved["other"] == 1 # unrelated external key preserved
|
||||
assert "old" in saved["mcpServers"] # user's servers re-applied
|
||||
|
||||
|
||||
def test_null_server_value_is_malformed_not_mistaken_for_absent():
|
||||
"""`{"mcpServers": {"foo": null}}` is legal JSON and a real malformed case,
|
||||
so None must not double as the 'nothing here' sentinel."""
|
||||
entry = c.extract_servers({"mcpServers": {"foo": None}})[0]
|
||||
assert entry.malformed is True
|
||||
assert entry.raw is None
|
||||
assert c.apply_servers({}, [entry])["mcpServers"]["foo"] is None
|
||||
|
||||
|
||||
def test_a_normal_entry_is_not_malformed():
|
||||
entry = c.extract_servers({"mcpServers": {"foo": {"command": "npx"}}})[0]
|
||||
assert entry.malformed is False
|
||||
assert entry.raw is c.NO_RAW
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #75 -- theming
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"setting,system_dark,expected",
|
||||
[
|
||||
(c.THEME_DARK, False, "dark"),
|
||||
(c.THEME_DARK, True, "dark"),
|
||||
(c.THEME_LIGHT, False, "light"),
|
||||
(c.THEME_LIGHT, True, "light"),
|
||||
(c.THEME_SYSTEM, True, "dark"),
|
||||
(c.THEME_SYSTEM, False, "light"),
|
||||
],
|
||||
)
|
||||
def test_resolve_theme_covers_every_setting_and_appearance(setting, system_dark, expected):
|
||||
assert c.resolve_theme(setting, system_dark) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("junk", ["", "solarized", None, "DARK", 3])
|
||||
def test_resolve_theme_falls_back_to_following_the_system(junk):
|
||||
"""A hand-edited or future QSettings value should follow the desktop,
|
||||
not pin a fixed theme."""
|
||||
assert c.resolve_theme(junk, True) == "dark"
|
||||
assert c.resolve_theme(junk, False) == "light"
|
||||
|
||||
|
||||
def test_palette_for_known_names():
|
||||
assert c.palette_for("dark") is c.DARK_PALETTE
|
||||
assert c.palette_for("light") is c.LIGHT_PALETTE
|
||||
|
||||
|
||||
def test_palette_for_unknown_name_falls_back_to_dark():
|
||||
assert c.palette_for("chartreuse") is c.DARK_PALETTE
|
||||
|
||||
|
||||
def test_dark_palette_is_unchanged_from_the_shipped_look():
|
||||
"""v1.3.0 shipped these exact colours; adding a light theme must not
|
||||
quietly restyle the dark one."""
|
||||
p = c.DARK_PALETTE
|
||||
assert (p.accent, p.bg, p.panel, p.panel_2) == ("#f97316", "#1b1d23", "#23262e", "#2b2f39")
|
||||
assert (p.text, p.muted, p.border) == ("#e7e9ee", "#9aa0ad", "#3a3f4b")
|
||||
assert (p.good, p.bad, p.warn, p.remote) == ("#4ade80", "#f87171", "#fbbf24", "#60a5fa")
|
||||
assert (p.on_accent, p.disabled_bg, p.mono_bg) == ("#1a1205", "#202229", "#16181d")
|
||||
|
||||
|
||||
def test_both_palettes_define_every_slot():
|
||||
"""A missing slot should fail here rather than render a broken window."""
|
||||
for pal in (c.DARK_PALETTE, c.LIGHT_PALETTE):
|
||||
for f in dataclasses.fields(c.Palette):
|
||||
value = getattr(pal, f.name)
|
||||
assert value, f"{pal.name}.{f.name} is empty"
|
||||
if f.name != "name":
|
||||
assert re.fullmatch(r"#[0-9a-fA-F]{6}", value), f"{pal.name}.{f.name}={value!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pal_name", ["dark", "light"])
|
||||
@pytest.mark.parametrize("slot", ["text", "muted", "good", "bad", "warn", "remote", "accent"])
|
||||
def test_palette_meets_contrast_on_panel(pal_name, slot):
|
||||
"""Every colour drawn as text/glyph must clear WCAG AA (4.5:1) against the
|
||||
surface it sits on. The light palette's semantic colours are NOT the dark
|
||||
ones lightened -- #4ade80 sits near 1.7:1 on white -- so this guards
|
||||
against someone 'harmonising' them back toward the dark hues."""
|
||||
pal = c.palette_for(pal_name)
|
||||
assert c.contrast_ratio(getattr(pal, slot), pal.panel) >= 4.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pal_name", ["dark", "light"])
|
||||
def test_on_accent_is_legible_against_the_accent_fill(pal_name):
|
||||
"""Primary buttons and selected rows draw on_accent on top of accent."""
|
||||
pal = c.palette_for(pal_name)
|
||||
assert c.contrast_ratio(pal.on_accent, pal.accent) >= 4.5
|
||||
|
||||
|
||||
def test_contrast_ratio_endpoints():
|
||||
assert c.contrast_ratio("#000000", "#ffffff") == pytest.approx(21.0, abs=0.01)
|
||||
assert c.contrast_ratio("#123456", "#123456") == pytest.approx(1.0, abs=0.001)
|
||||
assert c.contrast_ratio("#ffffff", "#000000") == pytest.approx(21.0, abs=0.01)
|
||||
|
||||
|
||||
def test_relative_luminance_extremes():
|
||||
assert c.relative_luminance("#000000") == pytest.approx(0.0)
|
||||
assert c.relative_luminance("#ffffff") == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_stylesheet_builder_has_no_hardcoded_colours():
|
||||
"""Every colour in the QSS must come from the palette.
|
||||
|
||||
Three near-black literals used to be inlined here (#1a1205, #202229,
|
||||
#16181d). Harmless with one theme; with two, they silently render dark
|
||||
chrome on a light window. Reads the source rather than importing bcc,
|
||||
which needs PySide6.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
|
||||
start = src.index("def build_stylesheet")
|
||||
body = src[start : src.index("def apply_palette")]
|
||||
assert re.findall(r"#[0-9a-fA-F]{6}", body) == []
|
||||
|
||||
|
||||
def test_every_palette_slot_is_consumed():
|
||||
"""A slot added to Palette but never wired up is dead weight.
|
||||
|
||||
Checks for `p.<slot>` anywhere in bcc.py, which covers both the QSS and
|
||||
apply_palette's global bindings -- not every slot belongs in the
|
||||
stylesheet (`good` and `remote` feed the inline status dots via
|
||||
STATUS_COLORS/HEALTH_COLORS, never the QSS). This won't catch a slot bound
|
||||
to a global that nothing then uses; it does catch the common mistake of
|
||||
extending the dataclass and forgetting to plumb it through.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
|
||||
for f in dataclasses.fields(c.Palette):
|
||||
if f.name == "name":
|
||||
continue
|
||||
assert f"p.{f.name}" in src, f"palette slot {f.name!r} is never consumed"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #78/#79 -- update notice: when to show it, and what it says
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user