Files
agwandClaude Opus 5.5 5de261acd7 Tegn og Gæt: kæden slutter altid på et gæt
Ved et lige antal spillere får kæden et trin mere: i trin 1 tegner
ejeren sit eget ord, og rotationen starter et trin senere. Antal trin
hedder nu steps og bruges i step-eventet, snapshot og afsløringen;
self.n er antal spillere. Snapshot får step.own til trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:59:59 +02:00

417 lines
20 KiB
Python

import random
from django.test import SimpleTestCase
from fupogfakta.engine import GameError
from tegnoggaet import engine
from tegnoggaet.engine import Game, clean_drawing
from party.themes import load_themes
THEMES = [{"id": "dyr", "name": "Dyr", "words": ["kat", "hund", "ko", "gris"]},
{"id": "mad", "name": "Mad", "words": ["pizza", "is"]}]
STROKE = {"color": engine.PALETTE[0], "width": 0.01, "points": [[0.1, 0.1], [0.5, 0.5]]}
def setUpModule():
assert Game.show_rules is True # standarden i drift
Game.show_rules = False # reglerne testes for sig (RulesTests)
def tearDownModule():
Game.show_rules = True
class TegnTestCase(SimpleTestCase):
def setUp(self):
self.now = 1000.0
self.g = Game("TEST", THEMES, join_url="http://x/?code=TEST", rng=random.Random(4))
def join(self, n):
return [self.g.join(f"Spiller{i}") for i in range(n)]
def start(self, n, theme="dyr"):
ps = self.join(n)
self.g.start(None, self.now)
self.g.choose_theme(None, theme, self.now)
self.assertEqual(self.g.phase, "word")
return ps
def play_step(self, ps, value=None):
for p in ps:
if p.id not in self.g.players:
continue
if self.g.phase == "word":
self.g.submit_word(p.id, value or f"ord {p.name}", self.now)
elif self.g.phase == "draw":
self.g.submit_drawing(p.id, [STROKE], self.now)
else:
self.g.submit_guess(p.id, value or f"gæt {p.name}", self.now)
def play_all(self, ps):
while self.g.phase in ("word", "draw", "guess"):
self.play_step(ps)
self.assertEqual(self.g.phase, "reveal")
class RotationTests(TegnTestCase):
def test_every_chain_ends_on_a_guess(self):
"""Ulige N: N trin. Lige N: N+1 trin, og i trin 1 tegner ejeren sit eget ord."""
for n in (3, 4, 5, 6, 7, 8, 9, 10):
with self.subTest(n=n):
self.g = Game("TEST", THEMES, rng=random.Random(n))
ps = self.start(n)
steps = n if n % 2 else n + 1
self.assertEqual(self.g.steps, steps)
owner = {k: pid for k, pid in enumerate(self.g.order)}
own = {self.g.order.index(p.id): p.id for p in ps}
for t in range(steps):
chains = [self.g.chain_for(p.id, t) for p in ps]
self.assertEqual(sorted(chains), list(range(n)), "hver kæde har præcis én spiller pr. trin")
at_home = t == 0 or (t == 1 and n % 2 == 0)
for p, c in zip(ps, chains):
self.assertEqual(own[c] == p.id, at_home, "egen kæde kun i trin 0 (og 1 ved lige N)")
t = 0
while self.g.phase in ("word", "draw", "guess"):
self.assertEqual(self.g.step, t)
snap = self.g.snapshot(self.now, host=True)
self.assertEqual(snap["step"], {"index": t, "number": t + 1, "of": steps, "kind": self.g.phase,
"own": t == 1 and n % 2 == 0})
for p in ps:
task = self.g.task(p.id)
if t == 1 and n % 2 == 0: # ejeren får sit eget ord
self.assertEqual((task["kind"], task["from"], task["text"]), ("draw", p.id, f"ord {p.name}".lower()))
elif t > 0:
self.assertNotEqual(task["from"], p.id)
self.play_step(ps)
t += 1
self.assertEqual(t, steps)
self.assertEqual(self.g.phase, "reveal")
for k, chain in enumerate(self.g.chains):
self.assertEqual(len(chain), steps)
self.assertEqual(chain[-1]["kind"], "guess", "kæden slutter på et gæt")
self.assertEqual(chain[0]["by"], owner[k])
self.assertEqual([e["kind"] for e in chain],
["word"] + ["drawing" if t % 2 else "guess" for t in range(1, steps)])
self.assertNotIn(owner[k], [e["by"] for e in chain if e["kind"] == "guess"],
"ingen gætter på sin egen kæde")
self.assertEqual(len({e["by"] for e in chain}), n, "alle bidrager til hver kæde")
if n % 2 == 0:
self.assertEqual(chain[1]["by"], owner[k], "ejeren tegner selv i trin 1")
self.assertEqual(len({e["by"] for e in chain[1 if n % 2 == 0 else 0:]}), n,
"ud over ejerens to første trin ser ingen kæden to gange")
for t in range(steps):
by = sorted(chain[t]["by"] for chain in self.g.chains)
self.assertEqual(by, sorted(p.id for p in ps), "hver spiller ét bidrag pr. trin")
def test_kicks_before_the_chains_start_go_back_to_lobby(self):
a, b, c = self.join(3)
self.g.start(None, self.now)
self.g.kick(c.id, self.now)
self.g.tick(self.now + engine.THEME_SECONDS)
self.assertEqual(self.g.phase, "lobby")
def test_too_few_players(self):
self.join(2)
with self.assertRaises(GameError) as e:
self.g.start(None, self.now)
self.assertEqual(e.exception.code, "too_few_players")
class FlowTests(TegnTestCase):
def test_theme_vip_or_host_chooses_and_timeout_picks_one(self):
a, b, c = self.join(3)
self.g.start(a.id, self.now)
self.assertEqual(self.g.phase, "theme")
with self.assertRaises(GameError):
self.g.choose_theme(b.id, "dyr", self.now)
with self.assertRaises(GameError):
self.g.choose_theme(a.id, "findes-ikke", self.now)
self.g.tick(self.now + engine.THEME_SECONDS)
self.assertEqual(self.g.phase, "word")
self.assertIn(self.g.theme["id"], {"dyr", "mad"})
def test_task_shows_only_the_previous_entry(self):
a, b, c = self.start(3)
self.assertEqual(self.g.task(a.id)["kind"], "word")
self.g.submit_word(a.id, "Kat", self.now)
self.g.submit_word(b.id, "hund", self.now)
self.g.submit_word(c.id, "ko", self.now)
self.assertEqual(self.g.phase, "draw")
# a tegner c's ord (kæde 2), og ser kun ordet
t = self.g.task(a.id)
self.assertEqual((t["kind"], t["text"], t["drawing"], t["from"]), ("draw", "ko", None, c.id))
snap = self.g.snapshot(self.now, viewer=a)
self.assertNotIn("reveal", snap)
self.assertNotIn("kat", str(snap)) # eget ord er ikke synligt mere, og andres aldrig
self.assertNotIn("hund", str(snap))
self.g.submit_drawing(a.id, [STROKE], self.now)
self.g.submit_drawing(b.id, [dict(STROKE, color=engine.PALETTE[1])], self.now)
self.g.submit_drawing(c.id, [dict(STROKE, color=engine.PALETTE[2])], self.now)
self.assertEqual(self.g.phase, "guess")
# a gætter i kæde (0 - 2) mod 3 = 1, b's kæde, som c tegnede
t = self.g.task(a.id)
self.assertEqual(t["kind"], "guess")
self.assertIsNone(t["text"])
self.assertEqual(t["drawing"][0]["color"], engine.PALETTE[2])
self.assertNotIn("hund", str(self.g.snapshot(self.now, viewer=a)))
def test_texts_are_cleaned_and_validated(self):
a, b, c = self.start(3)
with self.assertRaises(GameError) as e:
self.g.submit_word(a.id, "🙂🙂", self.now)
self.assertEqual(e.exception.code, "word_invalid")
with self.assertRaises(GameError):
self.g.submit_word(a.id, "x" * (engine.MAX_TEXT + 1), self.now)
self.g.submit_word(a.id, " Rød Kat! ", self.now)
self.assertEqual(self.g.chains[0][0]["text"], "rød kat")
with self.assertRaises(GameError) as e:
self.g.submit_word(a.id, "igen", self.now)
self.assertEqual(e.exception.code, "already_done")
def test_suggestion_avoids_taken_words(self):
a, b, c = self.start(3, theme="mad")
self.g.submit_word(a.id, "pizza", self.now)
self.assertEqual({self.g.suggest(b.id) for _ in range(5)}, {"is"})
def test_audience_and_late_joiners(self):
ps = self.start(3)
late = self.g.join("Sen")
self.assertEqual(late.role, "audience")
self.assertIsNone(self.g.snapshot(self.now, viewer=late)["me"]["task"])
with self.assertRaises(GameError):
self.g.submit_word(late.id, "hej", self.now)
class TimeoutTests(TegnTestCase):
def test_timeouts_use_drafts_or_defaults_and_never_hang(self):
a, b, c = self.start(3)
self.g.save_draft(b.id, "Halvt ord")
self.g.submit_word(a.id, "kat", self.now)
self.now += engine.WORD_SECONDS
self.assertTrue(self.g.tick(self.now))
self.assertEqual(self.g.phase, "draw")
words = {e["by"]: e for chain in self.g.chains for e in chain}
self.assertEqual(words[b.id]["text"], "halvt ord")
self.assertTrue(words[b.id]["auto"])
self.assertIn(words[c.id]["text"], THEMES[0]["words"]) # ingen kladde: et eksempel-ord fra temaet
self.g.save_draft(a.id, [STROKE])
with self.assertRaises(GameError):
self.g.save_draft(b.id, [{"color": "red", "width": 0.01, "points": [[0, 0]]}])
self.now += engine.DRAW_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "guess")
drawings = {e["by"]: e["strokes"] for chain in self.g.chains for e in chain if e["kind"] == "drawing"}
self.assertEqual(drawings[a.id], [STROKE])
self.assertEqual(drawings[b.id], []) # tom tegning sendes alligevel
self.now += engine.GUESS_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "reveal")
self.assertEqual({e["text"] for chain in self.g.chains for e in chain if e["kind"] == "guess"}, {"???"})
def test_disconnected_players_do_not_hold_the_others_back(self):
a, b, c = self.start(3)
self.g.set_connected(c.id, False, self.now)
self.g.submit_word(a.id, "kat", self.now)
self.g.submit_word(b.id, "hund", self.now)
self.assertEqual(self.g.phase, "draw")
self.g.submit_drawing(a.id, [STROKE], self.now)
self.assertEqual(self.g.phase, "draw")
self.g.submit_drawing(b.id, [STROKE], self.now)
self.assertEqual(self.g.phase, "guess")
def test_kicked_player_leaves_the_chains_intact(self):
a, b, c, d = self.start(4)
self.g.kick(d.id, self.now)
self.play_all([a, b, c])
self.assertTrue(all(len(chain) == 5 for chain in self.g.chains))
self.assertTrue(all(e["auto"] for chain in self.g.chains for e in chain if e["by"] == d.id))
def test_held_phase_waits_for_the_speaker(self):
a, b, c = self.start(3)
self.g.hold_until = self.now + 3
self.play_step([a, b, c])
self.assertEqual(self.g.phase, "word")
self.assertFalse(self.g.tick(self.now + 1))
self.assertTrue(self.g.tick(self.now + 3))
self.assertEqual(self.g.phase, "draw")
class DrawingValidationTests(SimpleTestCase):
def test_valid_drawing_is_rounded(self):
out = clean_drawing([{"color": engine.PALETTE[1], "width": 0.0123456, "points": [[0.123456, 1], [0, 0.5]]}])
self.assertEqual(out, [{"color": engine.PALETTE[1], "width": 0.0123, "points": [[0.1235, 1], [0, 0.5]]}])
self.assertEqual(clean_drawing([]), [])
def test_invalid_drawings_are_rejected(self):
ok = dict(STROKE)
bad = [
"streger", {"strokes": []}, [ok, "x"],
[dict(ok, color="red")], [dict(ok, color="javascript:alert(1)")],
[dict(ok, width=0)], [dict(ok, width=1)], [dict(ok, width=True)], [dict(ok, width=float("nan"))],
[dict(ok, points=[])], [dict(ok, points=[[0.1]])], [dict(ok, points=[[0.1, 1.2]])],
[dict(ok, points=[[-0.1, 0.5]])], [dict(ok, points=[[True, 0.5]])], [dict(ok, points=[["0.1", 0.5]])],
[ok] * (engine.MAX_STROKES + 1),
[dict(ok, points=[[0.5, 0.5]] * (engine.MAX_POINTS + 1))],
]
for strokes in bad:
with self.subTest(strokes=str(strokes)[:60]):
with self.assertRaises(GameError) as e:
clean_drawing(strokes)
self.assertEqual(e.exception.code, "drawing_invalid")
def test_size_limit(self):
# under punktgrænsen, men over 64 KB
many = [{"color": engine.PALETTE[0], "width": 0.01, "points": [[0.1234, 0.5678]] * 50} for _ in range(390)]
with self.assertRaises(GameError):
clean_drawing(many)
class RevealTests(TegnTestCase):
def play_game(self, n=3, survive=False):
ps = self.start(n)
while self.g.phase in ("word", "draw", "guess"):
if survive and self.g.phase != "draw":
self.play_step(ps, value="kat") # alle skriver/gætter "kat": alle kæder overlever
else:
self.play_step(ps)
return ps
def test_plan_covers_every_chain_and_entry(self):
self.play_game(4)
plan = self.g.plan
self.assertEqual(len(plan), 4 * (5 + 2)) # 4 spillere: 5 trin, så kæden slutter på et gæt
self.assertEqual([b["entry"] for b in plan[:7]], [-1, 0, 1, 2, 3, 4, 5])
snap = self.g.snapshot(self.now, host=True)
self.assertNotIn("strokes", str(snap["reveal"])) # tegningerne kommer i beskeden "drawings"
self.assertEqual(len(self.g.drawings()), 4 * 2)
self.assertEqual(snap["reveal"]["chains"][0]["entries"][0]["kind"], "word")
def test_phones_only_get_what_the_tv_has_shown(self):
a, b, c = self.play_game(3, survive=True)
def chains(t):
return self.g.snapshot(t, viewer=a)["reveal"]["chains"]
self.assertEqual([len(ch["entries"]) for ch in chains(self.now)], [0, 0, 0]) # kædens intro
t = self.now + (engine.CHAIN_INTRO_MS + 10) / 1000
self.assertEqual([e["id"] for e in chains(t)[0]["entries"]], ["c0e0"])
self.assertIsNone(chains(t)[0]["survived"]) # afgøres først på storskærmen
t = self.now + (engine.CHAIN_INTRO_MS + engine.WORD_MS + engine.DRAWING_MS + engine.GUESS_MS + 10) / 1000
ch = chains(t)
self.assertEqual((len(ch[0]["entries"]), ch[0]["survived"], len(ch[1]["entries"])), (3, True, 0))
self.assertNotIn("kat", str(ch[1:]))
self.g.plan[5]["audio"] = {"url": "/tts/x.mp3", "ms": 900, "text": "Den startede som kat og endte som kat."}
phone_plan = self.g.snapshot(self.now, viewer=a)["reveal"]["plan"]
self.assertTrue(all("audio" not in b for b in phone_plan)) # speakerens tekst ville røbe sidste gæt
self.assertEqual(self.g.snapshot(self.now, host=True)["reveal"]["plan"][5]["audio"]["ms"], 900)
host = self.g.snapshot(self.now, host=True)["reveal"]["chains"]
self.assertEqual([len(x["entries"]) for x in host], [3, 3, 3]) # storskærmen får alt
starts = self.g.beat_starts()
self.assertEqual(len(starts), len(self.g.plan))
self.assertEqual(starts[1], self.now + engine.CHAIN_INTRO_MS / 1000)
def test_likes_only_on_revealed_drawings_and_guesses_by_others(self):
a, b, c = self.play_game(3)
word, drawing, guess = self.g.chains[0] # a's kæde: ord af a, tegning af b, gæt af c
self.assertEqual((word["by"], drawing["by"], guess["by"]), (a.id, b.id, c.id))
with self.assertRaises(GameError):
self.g.like(b.id, drawing["id"], self.now) # endnu ikke vist
t = self.now + (engine.CHAIN_INTRO_MS + engine.WORD_MS + 10) / 1000
with self.assertRaises(GameError):
self.g.like(b.id, word["id"], t) # ord får ikke hjerter
with self.assertRaises(GameError):
self.g.like(b.id, drawing["id"], t) # ikke sin egen
self.g.like(a.id, drawing["id"], t)
self.assertEqual(self.g.players[b.id].score, engine.POINTS_LIKE)
self.g.like(a.id, drawing["id"], t) # slår fra igen
self.assertEqual(self.g.players[b.id].score, 0)
fan = self.g.join("Fan")
self.g.like(fan.id, drawing["id"], t)
self.g.like(a.id, drawing["id"], t)
self.assertEqual(self.g.players[b.id].likes, 2)
self.assertEqual(self.g.snapshot(t, viewer=fan)["me"]["likes"], [drawing["id"]])
def test_survived_bonus_and_awards(self):
a, b, c = self.play_game(3, survive=True)
self.assertEqual(self.g.survived, [True, True, True])
g = self.g.chains[0][2]
t = self.now + 60
self.g.like(a.id, g["id"], t)
self.g.tick(self.now + 600)
self.assertEqual(self.g.phase, "final")
scores = {p.id: p.score for p in self.g.players.values()}
self.assertEqual(scores[a.id], engine.POINTS_SURVIVED)
self.assertEqual(scores[g["by"]], engine.POINTS_SURVIVED + engine.POINTS_LIKE)
kinds = [x["kind"] for x in self.g.final_result["awards"]]
self.assertEqual(kinds, ["guess", "survived"])
self.assertEqual(self.g.final_result["ranking"][0]["player_id"], g["by"])
def test_biggest_transformation(self):
a, b, c = self.play_game(3)
self.g.tick(self.now + 600)
awards = {x["kind"]: x for x in self.g.final_result["awards"]}
self.assertNotIn("survived", awards)
self.assertIn("transformed", awards)
self.assertIn("blev til", awards["transformed"]["detail"])
def test_skip_jumps_to_next_chain_then_final(self):
self.play_game(3)
self.assertEqual(self.g.plan[self.g.beat_at(self.now)]["chain"], 0)
self.g.skip(self.now + 1)
self.assertEqual(self.g.beat_at(self.now + 1), 5) # kæde 1's intro (hver kæde: intro, 3 bidrag, slut)
plan_end = self.g.reveal_started_at + sum(b["ms"] for b in self.g.plan) / 1000
self.assertEqual(self.g.deadline, plan_end) # ellers står skærmen stille efter sidste kæde
self.assertTrue(self.g.tick(plan_end))
self.assertEqual(self.g.phase, "final")
def test_skip_twice_then_final_when_the_last_chain_ends(self):
self.play_game(3)
self.g.skip(self.now + 1)
self.g.skip(self.now + 1)
end = self.g.reveal_started_at + sum(b["ms"] for b in self.g.plan) / 1000
self.assertFalse(self.g.tick(end - 0.1))
self.assertTrue(self.g.tick(end))
self.assertEqual(self.g.phase, "final")
self.g = Game("TEST", THEMES, rng=random.Random(4))
self.play_game(3)
for _ in range(3):
self.g.skip(self.now + 1)
self.assertEqual(self.g.phase, "final")
def test_reveal_waits_for_audio_then_starts_by_itself(self):
self.play_game(3)
self.g.hold_reveal(self.now, max_wait=5)
self.assertIsNone(self.g.snapshot(self.now, host=True)["reveal"]["started_at"])
self.g.tick(self.now + 5)
self.assertEqual(self.g.reveal_started_at, self.now + 5)
def test_play_again_keeps_players_and_promotes_audience(self):
a, b, c = self.play_game(3)
late = self.g.join("Sen")
self.g.tick(self.now + 600)
self.g.play_again(a.id, self.now + 601)
self.assertEqual(self.g.phase, "theme") # reglerne kun første gang
self.assertIn(late.id, self.g.players)
self.assertTrue(all(p.score == 0 for p in self.g.players.values()))
class RulesTests(TegnTestCase):
def test_rules_once_then_theme(self):
self.g.show_rules = True
self.join(3)
self.g.start(None, self.now)
self.assertEqual(self.g.phase, "rules")
self.g.tick(self.now + 60)
self.assertEqual(self.g.phase, "theme")
class ThemeFileTests(SimpleTestCase):
def test_themes_file(self):
themes = load_themes()
self.assertTrue(10 <= len(themes) <= 15)
self.assertEqual(len({t["id"] for t in themes}), len(themes))
for t in themes:
with self.subTest(theme=t["name"]):
self.assertTrue(15 <= len(t["words"]) <= 20)
self.assertEqual(len(set(t["words"])), len(t["words"]))
for w in t["words"]:
self.assertEqual(engine.clean_text(w, "x", "x"), w) # kan bruges som ord uden rettelser