Files
agw c3f9a5c8b4
CI / test (push) Successful in 1m32s
Fest-Z: eget emoji-sæt og nyt navn
Fest-spillet hedder nu Fest-Z (kategorierne "Fest-Z: …"), og telefonerne
får zoomer-emojis (💀 😭 🧢 🗿 💅 🍻) i stedet for familie-sættet. Serveren
sender sættet i tilstanden, så klienten altid viser det, spillet tager imod.
Reglerne og speakeren taler om slang og memes frem for pop-sladder.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-18 13:17:26 +02:00

374 lines
17 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import random
from django.test import SimpleTestCase
from fupogfakta import engine
from fupogfakta.engine import Game, GameError
def bank(n_cats=5, per_cat=6):
qs = []
for c in range(1, n_cats + 1):
for i in range(per_cat):
qs.append({"id": c * 100 + i, "category_id": c, "category": f"Kategori {c}",
"prompt": f"Spørgsmål {c}-{i} har svaret _____.", "answer": f"sandhed{c}{i}",
"alternatives": [], "decoys": [f"lok{c}{i}a", f"lok{c}{i}b", f"lok{c}{i}c"], "note": "Fordi."})
return qs
def setUpModule():
from fupogfakta.engine import Game
assert Game.show_rules is True # standarden i drift
Game.show_rules = False # reglerne testes for sig (RulesTests)
def tearDownModule():
from fupogfakta.engine import Game
Game.show_rules = True
class GameTestCase(SimpleTestCase):
def setUp(self):
self.now = 1000.0
self.g = Game("TEST", bank(), join_url="http://x/?code=TEST", rng=random.Random(4))
def join(self, *names):
return [self.g.join(n) for n in names]
def through_category(self):
if self.g.phase == "category":
self.g.choose_category(self.g.chooser_id, self.g.category_options[0]["id"], self.now)
self.assertEqual(self.g.phase, "intro")
self.now += engine.INTRO_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "lie")
def truth_id(self):
return next(a.id for a in self.g.answers if a.is_truth)
class LobbyTests(GameTestCase):
def test_vip_is_first_player_and_only_vip_starts(self):
a, b = self.join("Asger", "Sus")
with self.assertRaises(GameError) as e:
self.g.start(b.id, self.now)
self.assertEqual(e.exception.code, "not_allowed")
self.g.start(a.id, self.now)
self.assertEqual(self.g.phase, "category")
def test_too_few_players(self):
self.join("Asger")
with self.assertRaises(GameError) as e:
self.g.start(None, self.now)
self.assertEqual(e.exception.code, "too_few_players")
def test_name_rules(self):
self.join("Asger")
for bad, code in [("asger", "name_taken"), ("", "name_invalid"), ("x" * 13, "name_invalid")]:
with self.assertRaises(GameError) as e:
self.g.join(bad)
self.assertEqual(e.exception.code, code)
def test_ninth_and_late_joiners_become_audience(self):
players = self.join(*[f"P{i}" for i in range(8)])
self.assertTrue(all(p.role == "player" for p in players))
self.assertEqual(self.g.join("Ni").role, "audience")
self.g.start(None, self.now)
self.assertEqual(self.g.join("Sen").role, "audience")
def test_resume_by_token(self):
a, = self.join("Asger")
self.assertIs(self.g.resume(a.token), a)
with self.assertRaises(GameError):
self.g.resume("nope")
class RoundTests(GameTestCase):
def setUp(self):
super().setUp()
self.a, self.b, self.c = self.join("A", "B", "C")
self.g.start(None, self.now)
self.through_category()
def test_only_chooser_may_choose(self):
self.g._begin_question(self.now)
other = next(p for p in self.g.players if p != self.g.chooser_id)
with self.assertRaises(GameError):
self.g.choose_category(other, self.g.category_options[0]["id"], self.now)
def test_lie_equal_or_close_to_truth_is_rejected(self):
truth = self.g.question["answer"]
for attempt in [truth, truth.upper() + "!", " " + truth + " ", "en " + truth, truth[:-1]]:
with self.assertRaises(GameError) as e:
self.g.submit_lie(self.a.id, attempt, self.now)
self.assertEqual(e.exception.code, "lie_is_truth", attempt)
def test_all_lies_moves_to_guess_and_hides_own_lie(self):
self.g.submit_lie(self.a.id, "katte", self.now)
self.g.submit_lie(self.b.id, "hunde", self.now)
self.assertEqual(self.g.phase, "lie")
self.g.submit_lie(self.c.id, "KATTE", self.now) # samme løgn som A
self.assertEqual(self.g.phase, "guess")
texts = [a.text for a in self.g.answers]
self.assertEqual(len(self.g.answers), 4) # katte, hunde, sandhed + husets løgn
shared = next(a for a in self.g.answers if a.text == "katte")
self.assertEqual(sorted(shared.authors), sorted([self.a.id, self.c.id]))
snap = self.g.snapshot(self.now, viewer=self.a)
self.assertNotIn(shared.id, [x["id"] for x in snap["answers"]])
self.assertIn(self.g.question["answer"], texts)
def test_cannot_guess_own_lie_and_scoring(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
by_text = {a.text: a.id for a in self.g.answers}
with self.assertRaises(GameError):
self.g.guess(self.a.id, by_text["katte"], self.now)
self.g.guess(self.a.id, self.truth_id(), self.now)
self.g.guess(self.b.id, by_text["katte"], self.now)
self.g.guess(self.c.id, by_text["katte"], self.now)
self.assertEqual(self.g.phase, "reveal")
# A: rigtigt (1000) + narrede B og C (2 × 500)
self.assertEqual(self.a.score, 2000)
self.assertEqual(self.b.score, 0)
steps = self.g.reveal_steps
self.assertTrue(steps[-1]["is_truth"])
self.assertEqual(steps[0]["text"], "katte")
def test_like_only_in_reveal_gives_points_and_toggles(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
katte = next(a.id for a in self.g.answers if a.text == "katte")
with self.assertRaises(GameError): # ikke i gættefasen
self.g.like(self.b.id, katte)
for p in (self.a, self.b, self.c):
self.g.guess(p.id, self.truth_id(), self.now)
self.assertEqual(self.g.phase, "reveal")
base = self.a.score
self.g.like(self.b.id, katte)
self.assertEqual(self.a.score, base + engine.POINTS_LIKE)
with self.assertRaises(GameError): # ikke sandheden
self.g.like(self.b.id, self.truth_id())
snap = self.g.snapshot(self.now, host=True)
self.assertEqual(next(x for x in snap["reveal"]["all"] if x["id"] == katte)["likes"], 1)
self.g.like(self.b.id, katte)
self.assertEqual(self.a.score, base)
def test_lie_for_me_gives_half_points(self):
suggestion = self.g.lie_for_me(self.a.id)
self.g.submit_lie(self.a.id, suggestion, self.now)
self.g.submit_lie(self.b.id, "hunde", self.now)
self.g.submit_lie(self.c.id, "heste", self.now)
mine = next(x.id for x in self.g.answers if self.a.id in x.authors)
hunde = next(x.id for x in self.g.answers if x.text == "hunde")
self.g.guess(self.b.id, mine, self.now)
self.g.guess(self.c.id, mine, self.now)
self.g.guess(self.a.id, hunde, self.now)
self.assertEqual(self.a.score, engine.POINTS_FOOLED * 2 // 2) # to narret, halve point
self.assertEqual(self.b.score, engine.POINTS_FOOLED) # egen løgn, fulde point
all_ = self.g.snapshot(self.now, host=True)["reveal"]["all"]
self.assertEqual(next(x for x in all_ if x["id"] == mine)["assisted"], [self.a.id])
def test_edited_suggestion_counts_as_own_lie(self):
suggestion = self.g.lie_for_me(self.a.id)
self.g.submit_lie(self.a.id, suggestion + " måske", self.now)
self.assertNotIn(self.a.id, self.g.assisted)
def test_timeouts_advance_and_fill_with_house_lies(self):
self.g.submit_lie(self.a.id, "katte", self.now)
self.assertTrue(self.g.tick(self.now + engine.LIE_SECONDS + 1))
self.assertEqual(self.g.phase, "guess")
self.assertGreaterEqual(len(self.g.answers), 4)
self.assertTrue(any(a.house for a in self.g.answers))
self.g.tick(self.now + 200)
self.assertEqual(self.g.phase, "reveal")
def test_disconnected_player_does_not_block(self):
self.g.set_connected(self.c.id, False, self.now)
self.g.submit_lie(self.a.id, "katte", self.now)
self.g.submit_lie(self.b.id, "hunde", self.now)
self.assertEqual(self.g.phase, "guess")
def test_audience_votes_give_bonus(self):
u = self.g.join("Publikum")
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
katte = next(a.id for a in self.g.answers if a.text == "katte")
self.g.guess(u.id, katte, self.now)
for p in (self.a, self.b, self.c):
self.g.guess(p.id, self.truth_id(), self.now)
self.assertEqual(self.a.score, 1000 + engine.POINTS_AUDIENCE)
class FullGameTests(GameTestCase):
def test_full_game_reaches_final_with_awards(self):
players = self.join("A", "B", "C")
self.g.start(players[0].id, self.now)
seen_phases = []
for _ in range(200):
if self.g.phase == "final":
break
seen_phases.append(self.g.phase)
if self.g.phase == "lie":
for i, p in enumerate(players):
self.g.submit_lie(p.id, f"løgn {p.name} {self.g.q_idx} {self.g.round_idx}", self.now)
elif self.g.phase == "guess":
for p in players:
if p.id not in self.g.guesses:
opts = [a for a in self.g.answers if p.id not in a.authors]
self.g.guess(p.id, opts[0].id, self.now)
else:
self.now += 100
self.g.tick(self.now)
self.assertEqual(self.g.phase, "final")
self.assertEqual(seen_phases.count("lie"), 7)
self.assertEqual(seen_phases.count("intro"), 7)
self.assertEqual(seen_phases.count("scores"), 2)
self.assertEqual(seen_phases.count("category"), 6) # finalen vælger selv
final = self.g.snapshot(self.now, host=True)["final"]
self.assertEqual(len(final["ranking"]), 3)
self.assertTrue(final["awards"])
self.assertEqual(len(self.g.used_questions), 7)
self.g.play_again(None, self.now)
self.assertEqual(self.g.phase, "category")
self.assertEqual(engine.slugify("Mad og drikke"), "mad_og_drikke")
self.assertEqual(engine.slugify("Rummet og naturen"), "rummet_og_naturen")
self.assertTrue(all(p.score == 0 for p in players))
def test_kick_chooser_picks_random_category(self):
a, b, c = self.join("A", "B", "C")
self.g.start(None, self.now)
self.g.kick(self.g.chooser_id, self.now)
self.assertEqual(self.g.phase, "intro")
self.assertEqual(len(self.g.players), 2)
class PresentAndReactionTests(GameTestCase):
def _to_round2_lie(self):
a, b, c = self.join("A", "B", "C")
self.g.start(None, self.now)
self.g.round_idx, self.g.q_idx = 1, 0 # spring direkte til runde 2 (oplæsning)
self.g._begin_question(self.now)
self.through_category()
return a, b, c
def test_round2_reads_answers_before_guessing(self):
a, b, c = self._to_round2_lie()
for p, lie in [(a, "katte"), (b, "hunde"), (c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
self.assertEqual(self.g.phase, "present")
with self.assertRaises(GameError): # ingen gæt under oplæsningen
self.g.guess(a.id, self.g.answers[0].id, self.now)
snap = self.g.snapshot(self.now, viewer=a)
self.assertEqual(len(snap["present"]["steps_ms"]), len(self.g.answers))
self.g.tick(self.g.deadline)
self.assertEqual(self.g.phase, "guess")
def test_reactions_are_rate_limited_and_follow_the_featured_answer(self):
a, b, c = self._to_round2_lie()
for p, lie in [(a, "katte"), (b, "hunde"), (c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
t = self.g.present_started_at + (self.g.present_intro_ms + 100) / 1000 # første svar i fokus
first = self.g.answers[0].id
sent = [self.g.react(b.id, "😂", t) for _ in range(10)]
self.assertEqual(sum(1 for r in sent if r), engine.REACT_PER_SECOND)
self.assertEqual(self.g.reactions[first], engine.REACT_PER_SECOND)
self.assertIsNone(self.g.react(b.id, "💩", t + 2)) # kun de faste emojis
self.assertIsNotNone(self.g.react(b.id, "🔥", t + 2)) # spanden er tom igen efter et sekund
class RulesTests(GameTestCase):
def setUp(self):
super().setUp()
self.g.show_rules = True
def test_rules_before_first_game_only(self):
a, b = self.join("Asger", "Sus")
self.g.start(a.id, self.now)
self.assertEqual(self.g.phase, "rules")
snap = self.g.snapshot(self.now, viewer=a)
self.assertEqual(len(snap["rules"]["items"]), len(engine.RULES))
self.assertNotIn("say", snap["rules"]["items"][0]) # replikken er til speakeren, kortet har sin egen tekst
self.g.hold_rules(self.now, max_wait=3) # speakeren laver lyd: ingen tidslinje endnu
self.g.tick(self.now + 3) # lyden blev aldrig klar: kortene vises uden stemme
self.assertEqual(self.g.phase, "rules")
self.g.tick(self.g.deadline)
self.assertEqual(self.g.phase, "category")
# spil igen: ingen regler
self.g.phase = "final"
self.g.play_again(a.id, self.now)
self.assertEqual(self.g.phase, "category")
class SanitizeTests(GameTestCase):
def test_lies_are_cleaned_and_lowercased(self):
a, b = self.join("Asger", "Sus")
self.g.start(a.id, self.now)
self.through_category()
with self.assertRaises(GameError) as e:
self.g.submit_lie(a.id, "😂🔥", self.now) # kun emojis: intet tilbage
self.assertEqual(e.exception.code, "lie_invalid")
with self.assertRaises(GameError):
self.g.submit_lie(a.id, "x" * (engine.MAX_LIE + 1), self.now)
self.g.submit_lie(a.id, " Røde​ Krager 🐦!! ", self.now)
self.assertEqual(self.g.lies[a.id], "røde krager")
def test_sanitize_and_names(self):
self.assertEqual(engine.sanitize("Rock'n'roll & co."), "Rock'n'roll & co")
self.assertEqual(engine.sanitize("full width 12"), "full width 12")
self.assertEqual(engine.sanitize("a\tb\nc"), "a b c")
p = self.g.join("Bo 👑")
self.assertEqual(p.name, "Bo")
with self.assertRaises(GameError) as e:
self.g.join("🎉🎉")
self.assertEqual(e.exception.code, "name_invalid")
class FestTests(GameTestCase):
def setUp(self):
super().setUp()
self.g = Game("TEST", bank(), join_url="http://x/?code=TEST", rng=random.Random(4), fest=True)
self.a, self.b, self.c = self.join("A", "B", "C")
self.g.start(None, self.now)
self.through_category()
def test_fooled_players_drink(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
by_text = {a.text: a.id for a in self.g.answers}
self.g.guess(self.a.id, self.truth_id(), self.now)
self.g.guess(self.b.id, by_text["katte"], self.now)
self.g.guess(self.c.id, by_text["katte"], self.now)
self.assertEqual((self.a.sips, self.b.sips, self.c.sips), (0, 1, 1))
self.assertEqual(self.a.score, 2000) # pointene findes stadig
snap = self.g.snapshot(self.now, viewer=self.b)
self.assertTrue(snap["fest"])
self.assertEqual(snap["me"]["last_sips"], 1)
self.assertEqual({d["player_id"]: d["n"] for d in snap["reveal"]["sip_deltas"]}[self.b.id], 1)
def test_nobody_found_truth_everyone_drinks(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
by_text = {a.text: a.id for a in self.g.answers}
self.g.guess(self.a.id, by_text["hunde"], self.now)
self.g.guess(self.b.id, by_text["katte"], self.now)
self.g.guess(self.c.id, by_text["katte"], self.now)
truth = next(s for s in self.g.reveal_steps if s["is_truth"])
self.assertTrue(truth["everyone_drinks"])
self.assertEqual((self.a.sips, self.b.sips, self.c.sips), (2, 2, 2))
def test_fest_rules_and_no_sips_in_normal_games(self):
self.assertIs(self.g.rules, engine.RULES_FEST)
normal = Game("N", bank())
self.assertIs(normal.rules, engine.RULES)
self.assertFalse(normal.snapshot(self.now)["fest"])
def test_fest_has_its_own_emojis(self):
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
self.g.submit_lie(p.id, lie, self.now)
self.assertEqual(self.g.phase, "guess")
self.assertEqual(self.g.snapshot(self.now)["reactions"], list(engine.REACTIONS_FEST))
self.assertIsNotNone(self.g.react(self.a.id, "💀", self.now))
self.assertIsNone(self.g.react(self.a.id, "😂", self.now + 2)) # familie-sættet gælder ikke her
normal = Game("N", bank())
self.assertEqual(normal.snapshot(self.now)["reactions"], list(engine.REACTIONS))