Merge views into Match, expand history, and polish the UI
- Match page now holds players/match info, turn stats with a turn picker, and the deck lists (My deck · Preview · Opponent); the Deck and Turn tabs are gone. Section heights are draggable and remembered. - History: sortable, resizable columns; deck and commander columns; right-click to show only or hide games with a value, with filter chips; stats tiles (win rate overall, last 10/100, last 7 days, play/draw, average length and turns) that follow the filters. Commanders are backfilled for games recorded before they were stored. - Deck panel shows copies per zone against the deck total (e.g. 2/5). - Preview can be cleared with ✕, a click on the image, or Esc. - Customize menu stays open while toggling; Arrange covers deck sections and turn stats together. - Fix squeezed nav buttons and the header pushing controls off-screen. - Fix known opponent cards being double-counted when also revealed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b3c8c8e63
commit
b3325e8ecc
15 files changed
+760
-183
No files matched your search
@@ -225,3 +225,25 @@ def test_commanders_are_not_emblems():
|
||||
assert g.commander_status(OPP) == [(9000, "stack")]
|
||||
assert g.commander_tax(OPP) == 2
|
||||
assert g.commander_tax(ME) == 0
|
||||
|
||||
|
||||
def test_revealed_card_already_tracked_is_not_double_counted():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
# Their card is bounced back to hand (tracked), and a reveal shows the same card.
|
||||
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
|
||||
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
|
||||
], objects=[card(210, 5005, BATTLEFIELD)])]))
|
||||
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
|
||||
zone(OPP_HAND, "Hand", [220], OPP), zone(BATTLEFIELD, "Battlefield", []),
|
||||
], objects=[card(400, 5005, OPP_HAND, otype="RevealedCard")], deleted=[210])]))
|
||||
assert t.game.opp_known_in("ZoneType_Hand") == {5005: 1}
|
||||
|
||||
|
||||
def test_known_cards_never_exceed_zone_size():
|
||||
t = MatchTracker()
|
||||
start_game(t)
|
||||
# Two reveals of different cards while their hand only has one card left.
|
||||
t.handle(GreMessages([gsm(2, zones=[zone(OPP_HAND, "Hand", [201], OPP)], objects=[
|
||||
card(400, 7007, OPP_HAND, otype="RevealedCard"), card(401, 7008, OPP_HAND, otype="RevealedCard")])]))
|
||||
assert sum(t.game.opp_known_in("ZoneType_Hand").values()) == 1
|
||||
@@ -65,3 +65,36 @@ def test_recorder_ignores_unfinished_match(tmp_path):
|
||||
t.handle(ev)
|
||||
rec.observe(ev, t)
|
||||
assert store.games() == []
|
||||
|
||||
|
||||
def test_old_database_is_upgraded_and_backfilled(tmp_path):
|
||||
import sqlite3
|
||||
path = tmp_path / "old.db"
|
||||
store = HistoryStore(path)
|
||||
import_log(FIXTURE, store, None)
|
||||
# Simulate a database from before commanders were stored.
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("UPDATE games SET my_commander = NULL, opp_commander = NULL")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store = HistoryStore(path)
|
||||
assert all(g.my_commander is None for g in store.games())
|
||||
assert store.backfill_commanders(None) == 2
|
||||
assert all(g.my_commander == "" and g.opp_commander == "" for g in store.games()) # not a Brawl match
|
||||
assert store.backfill_commanders(None) == 0
|
||||
|
||||
|
||||
def test_schema_without_commander_columns_gets_them(tmp_path):
|
||||
import sqlite3
|
||||
path = tmp_path / "v1.db"
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript("""CREATE TABLE games (match_id TEXT NOT NULL, game_number INTEGER NOT NULL,
|
||||
started_ms INTEGER, ended_ms INTEGER, event_id TEXT, opponent TEXT, deck_name TEXT, on_play TEXT,
|
||||
result TEXT, my_colors TEXT, opp_colors TEXT, turns INTEGER, best_of INTEGER,
|
||||
PRIMARY KEY (match_id, game_number));
|
||||
INSERT INTO games VALUES ('m1', 1, 1, 2, 'Ladder', 'Opp', 'Deck', 'me', 'Won', 'U', 'R', 5, 1);""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
games = HistoryStore(path).games()
|
||||
assert len(games) == 1 and games[0].deck_name == "Deck" and games[0].my_commander is None
|
||||
@@ -0,0 +1,58 @@
|
||||
from mtga_assistant.history import GameSummary
|
||||
from mtga_assistant.history_stats import WEEK_MS, Filter, apply_filters, compute_stats
|
||||
|
||||
NOW = 1_800_000_000_000
|
||||
DAY = 24 * 3600 * 1000
|
||||
|
||||
|
||||
def game(n: int, result: str, *, deck="Deck A", on_play="me", days_ago=0, turns=8, length_s=300,
|
||||
event="Ladder", best_of=1, game_number=1) -> GameSummary:
|
||||
start = NOW - days_ago * DAY - n * 1000
|
||||
return GameSummary(match_id=f"m{n}", game_number=game_number, started_ms=start, ended_ms=start + length_s * 1000,
|
||||
event_id=event, opponent=f"opp{n}", deck_name=deck, on_play=on_play, result=result,
|
||||
my_colors="UB", opp_colors="R", turns=turns, best_of=best_of)
|
||||
|
||||
|
||||
def test_filters_only_and_hide_combine():
|
||||
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="B"), game(3, "Won", deck="A", on_play="opp")]
|
||||
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A")])] == ["m1", "m3"]
|
||||
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A"), Filter("on_play", "opp")])] == ["m3"]
|
||||
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A", exclude=True)])] == ["m2"]
|
||||
|
||||
|
||||
def test_filter_labels_are_readable():
|
||||
assert Filter("on_play", "opp").label == "On the play = you on the draw"
|
||||
assert Filter("event", "Play_Brawl_Historic", exclude=True).label == "Event ≠ Play Brawl Historic"
|
||||
assert Filter("game", "Bo1").label == "Game = Bo1"
|
||||
|
||||
|
||||
def test_stats_windows_and_averages():
|
||||
# 12 recent games (newest first: n=0 is newest): W W L W W L W W L W | L L
|
||||
results = ["Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Lost", "Lost"]
|
||||
games = [game(n, r, days_ago=0 if n < 6 else 10, turns=6 + n % 3, length_s=240 + 60 * (n % 2),
|
||||
on_play="me" if n % 2 == 0 else "opp")
|
||||
for n, r in enumerate(results)]
|
||||
games.append(game(99, "", days_ago=1)) # no result: counted as a game, not in win rates
|
||||
s = compute_stats(games, NOW)
|
||||
assert s.games == 13
|
||||
assert (s.overall.wins, s.overall.losses) == (7, 5)
|
||||
assert (s.last_10.wins, s.last_10.losses) == (7, 3)
|
||||
assert s.last_10.rate == 0.7
|
||||
assert s.last_100.games == 12
|
||||
assert s.week_games == 7 and (s.week.wins, s.week.losses) == (4, 2)
|
||||
assert (s.on_play.wins, s.on_play.losses) == (3, 3)
|
||||
assert (s.on_draw.wins, s.on_draw.losses) == (4, 2)
|
||||
assert s.avg_turns is not None and 6 <= s.avg_turns <= 8
|
||||
assert s.avg_length_s is not None and 240 <= s.avg_length_s <= 300
|
||||
|
||||
|
||||
def test_stats_follow_filters():
|
||||
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="A"), game(3, "Lost", deck="B"), game(4, "Lost", deck="B")]
|
||||
assert compute_stats(apply_filters(games, [Filter("deck", "A")]), NOW).overall.rate == 0.5
|
||||
assert compute_stats(apply_filters(games, [Filter("deck", "B")]), NOW).overall.rate == 0.0
|
||||
|
||||
|
||||
def test_empty_history():
|
||||
s = compute_stats([], NOW)
|
||||
assert s.games == 0 and s.overall.rate is None and s.avg_length_s is None and s.week_games == 0
|
||||
assert WEEK_MS == 7 * DAY
|
||||
Reference in new issue
Block a user