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