CodeRabbit found _QUALITY_CHOICES and _RANKING_COVERAGE_SECONDS read by
_normalise_quality and _check_ranking_coverage but never defined on a mixin.
Confirmed: both are declared by all eight scoreboards, so nothing fails today --
it would only have bitten the ninth plugin to adopt this, at runtime, mid-render.
Both are identical everywhere, so they get defaults here; each plugin's own copy
still shadows them.
Auditing for others showed those two were the only ones, but also that the
host-contract docstring was substantially incomplete: it listed 21 attributes
where the mixins actually read about 40, and omitted five hooks
(_is_favorite_game, _is_game_really_over, _is_ranked_game,
_passes_other_filters, _get_timezone). The section is now derived from that
audit rather than remembered.
test_sports_shared.py covers what is genuinely new, not the moved bodies:
- The contract itself. It parses the module for every ALL-CAPS `self.X` the
mixins read and asserts each is defined, so the next omission fails here
rather than in the field.
- _plugin_dir, the only new logic in the move. Including the case that made it
necessary: SportsCore is an ABC, so a subclass built with type(name, bases,
ns) -- which the plugins' own tests build -- reports __module__ as "abc". The
test asserts that precondition before asserting the walk steps past it.
- The three SportsLive bodies. Hockey and lacrosse disable live mode in their
harness fixtures, so the 176 renders never reach this path; testing the mixin
directly means coverage no longer depends on which plugin happens to have a
unit test.
Two of those tests pin things that would otherwise be silently undone.
SportsRecentSharedMixin does carry an __init__ -- SportsRecent.__init__ was one
of the 48 byte-identical bodies. Its bare super() binds to where it is defined,
now the mixin, so it only reaches the host because the mixin is listed first in
the bases. One test proves the chain runs; the next proves that reversing the
order silently skips the host constructor.
Nine scoreboards ship their own sports.py -- 41,326 lines. Comparing executable
ASTs across the eight that share a lineage, 48 method bodies are byte-identical
in every one: 1,007 lines carried eight times, so 8,056 lines that must be
edited eight times to fix once.
They are the parts with no sport in them: the selection and rotation engine
(_round_robin_favorites, _favorites_first, _compose_selection,
_check_ranking_coverage, _game_divisions, _normalise_quality), the font/colour/
date subsystem (_scale_headline_fonts, _scorebug_font, _resolve_font_size,
_format_game_date, _font_color), and the switch-mode upcoming card
(_draw_upcoming_center_switch). Nothing here knows what an inning is.
Mixins rather than free functions: every one of these reads host state, so
rewriting 48 bodies into free functions would be a rewrite rather than a move,
and it is the move that keeps the renders identical.
Three of the 48 are deliberately left in the plugins, because a byte-identical
body is not automatically safe to move:
- _get_timezone calls resolve_timezone, imported from a per-plugin module
(hockey_timezone, soccer_timezone, ...). All eight of those differ -- each
carries its own _WRITEBACK_FIXED_IN -- so hoisting the caller would silently
bind every scoreboard to one plugin's copy.
- _extract_game_details and _fetch_data are @abstractmethod stubs. They are the
sport contract; satisfying them from a mixin would let a plugin instantiate
without implementing its own sport.
_resolve_font_path went the other way: a module-level function, identical in all
eight, that _scale_headline_fonts needs -- so it is inlined here.
_schema_font_size needed a real change rather than a move. It located the
plugin's config_schema.json with __file__, which here is src/common/, so the
load failed silently, the cache stayed empty and every element fell back to an
unsnapped size -- measured at 81% anti-aliased edges on a panel that should be
pixel-crisp. It now recovers the plugin directory from the instance. Note that
type(self).__module__ alone is not enough: SportsCore is an ABC, so a subclass
built with type(name, bases, ns) -- which the plugins' own tests do -- reports
its module as "abc". _plugin_dir walks the MRO past those synthetic classes to
the first module sitting beside a config_schema.json.
Worth recording: the 176 harness renders did NOT catch that regression. The
plugin's own test_fonts_are_crisp.py did. Renders alone were not a sufficient
gate here.
Not merged with src/common/sports_card.py despite fourteen same-named twins.
Only five are provably equivalent by source comparison; the other nine differ in
ways inspection cannot settle, and a wrong guess silently changes what every
scoreboard draws. That merge needs differential testing and is its own change.