Skip to content

Commit 8ea6dd3

Browse files
committed
bots: create trivia_quiz_game bot
1 parent 0b8e5d1 commit 8ea6dd3

File tree

9 files changed

+427
-4
lines changed

9 files changed

+427
-4
lines changed

tools/run-mypy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ force_include = [
9090
"zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py",
9191
"zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py",
9292
"zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py",
93+
"zulip_bots/zulip_bots/bots/trivia_quiz_game/trivia_quiz_game.py",
94+
"zulip_bots/zulip_bots/bots/trivia_quiz_game/test_trivia_quiz_game.py",
9395
"zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py",
9496
"zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py",
9597
"zulip_bots/zulip_bots/bots/trello/trello.py",

zulip_bots/zulip_bots/bots/trivia_quiz_game/__init__.py

Whitespace-only changes.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from zulip_bots.game_handler import BadMoveException
2+
import html
3+
import requests
4+
import random
5+
6+
from typing import Optional, Any, Dict
7+
8+
class NotAvailableException(Exception):
9+
def __init__(self, message: str) -> None:
10+
self.message = message
11+
12+
def __str__(self) -> str:
13+
return self.message
14+
15+
class TriviaQuizGameModel(object):
16+
def __init__(self):
17+
# This could throw an exception. It will be picked up by
18+
# game_handler and the game will end
19+
self.current_board = self.get_trivia_quiz()
20+
self.scores = {} # type: Dict[int, int]
21+
22+
def validate_move(self, answer):
23+
return answer in "ABCD"
24+
25+
def make_move(self, move, player_number, is_computer=False):
26+
if player_number not in self.scores:
27+
self.scores[player_number] = 0
28+
if not self.validate_move(move):
29+
raise BadMoveException("Move not valid")
30+
if move == self.current_board['correct_letter']:
31+
self.scores[player_number] += 1
32+
else:
33+
self.scores[player_number] -= 1
34+
if self.scores[player_number] < 0:
35+
self.scores[player_number] = 0
36+
self.current_board = self.get_trivia_quiz()
37+
return {
38+
'correct': move == self.current_board['correct_letter'],
39+
'correct_letter': self.current_board['correct_letter'],
40+
'score': self.scores[player_number]
41+
}
42+
43+
def determine_game_over(self, players):
44+
for player_number, score in self.scores.items():
45+
if score >= 5:
46+
# Game over
47+
return players[player_number]
48+
return
49+
50+
def get_trivia_quiz(self) -> Dict[str, Any]:
51+
payload = self.get_trivia_payload()
52+
quiz = self.get_quiz_from_payload(payload)
53+
return quiz
54+
55+
def get_trivia_payload(self) -> Dict[str, Any]:
56+
57+
url = 'https://opentdb.com/api.php?amount=1&type=multiple'
58+
59+
try:
60+
data = requests.get(url)
61+
62+
except requests.exceptions.RequestException:
63+
raise NotAvailableException("Uh-Oh! Trivia service is down.")
64+
65+
if data.status_code != 200:
66+
raise NotAvailableException("Uh-Oh! Trivia service is down.")
67+
68+
payload = data.json()
69+
return payload
70+
71+
def get_quiz_from_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
72+
result = payload['results'][0]
73+
question = result['question']
74+
letters = ['A', 'B', 'C', 'D']
75+
random.shuffle(letters)
76+
correct_letter = letters[0]
77+
answers = dict()
78+
answers[correct_letter] = result['correct_answer']
79+
for i in range(3):
80+
answers[letters[i+1]] = result['incorrect_answers'][i]
81+
82+
def fix_quotes(s: str) -> Optional[str]:
83+
# opentdb is nice enough to escape HTML for us, but
84+
# we are sending this to code that does that already :)
85+
#
86+
# Meanwhile Python took until version 3.4 to have a
87+
# simple html.unescape function.
88+
try:
89+
return html.unescape(s)
90+
except Exception:
91+
raise Exception('Please use python3.4 or later for this bot.')
92+
answers = {
93+
letter: fix_quotes(answer)
94+
for letter, answer
95+
in answers.items()
96+
}
97+
quiz = dict(
98+
question=fix_quotes(question),
99+
answers=answers,
100+
correct_letter=correct_letter,
101+
)
102+
return quiz
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"request":{
3+
"api_url":"https://opentdb.com/api.php?amount=1&type=multiple"
4+
},
5+
"response":{
6+
"response_code":0,
7+
"results":[
8+
{
9+
"category":"Animals",
10+
"type":"multiple",
11+
"difficulty":"easy",
12+
"question":"Which class of animals are newts members of?",
13+
"correct_answer":"Amphibian",
14+
"incorrect_answers":["Fish","Reptiles","Mammals"]
15+
}
16+
]
17+
},
18+
"response-headers":{
19+
"status":200,
20+
"ok":true,
21+
"content-type":"application/json; charset=utf-8"
22+
}
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"question": "Which class of animals are newts members of?",
3+
"answers": {
4+
"A": "Amphibian",
5+
"B": "Fish",
6+
"C": "Reptiles",
7+
"D": "Mammals"
8+
},
9+
"correct_letter": "A"
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"request": {
3+
"api_url":"https://opentdb.com/api.php?amount=1&type=multiple"
4+
},
5+
"response": {
6+
"data": {
7+
"status_code":404
8+
}
9+
},
10+
"response-headers":{
11+
"status":404,
12+
"content-type":"text/html"
13+
}
14+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import json
2+
3+
from unittest.mock import patch
4+
5+
from zulip_bots.test_lib import (
6+
BotTestCase,
7+
DefaultTests,
8+
read_bot_fixture_data,
9+
)
10+
11+
from zulip_bots.request_test_lib import (
12+
mock_request_exception
13+
)
14+
15+
from zulip_bots.bots.trivia_quiz_game.controller import (
16+
TriviaQuizGameModel,
17+
NotAvailableException
18+
)
19+
20+
from zulip_bots.bots.trivia_quiz_game.trivia_quiz_game import (
21+
TriviaQuizGameMessageHandler
22+
)
23+
24+
from zulip_bots.game_handler import BadMoveException
25+
26+
class TestTriviaQuizGameBot(BotTestCase, DefaultTests):
27+
bot_name = 'trivia_quiz_game' # type: str
28+
29+
new_question_response = '\nQ: Which class of animals are newts members of?\n\n' + \
30+
'* **A** Amphibian\n' + \
31+
'* **B** Fish\n' + \
32+
'* **C** Reptiles\n' + \
33+
'* **D** Mammals\n' + \
34+
'**reply**: <letter>'
35+
36+
test_question = {
37+
'question': 'Question 1?',
38+
'answers': {
39+
'A': 'Correct',
40+
'B': 'Incorrect 1',
41+
'C': 'Incorrect 2',
42+
'D': 'Incorrect 3'
43+
},
44+
'correct_letter': 'A'
45+
}
46+
47+
test_question_message_content = '''
48+
Q: Question 1?
49+
50+
* **A** Correct
51+
* **B** Incorrect 1
52+
* **C** Incorrect 2
53+
* **D** Incorrect 3
54+
**reply**: <letter>'''
55+
56+
test_question_message_widget = '{"widget_type": "zform", "extra_data": {"type": "choices", "heading": "Question 1?", "choices": [{"type": "multiple_choice", "short_name": "A", "long_name": "Correct", "reply": "A"}, {"type": "multiple_choice", "short_name": "B", "long_name": "Incorrect 1", "reply": "B"}, {"type": "multiple_choice", "short_name": "C", "long_name": "Incorrect 2", "reply": "C"}, {"type": "multiple_choice", "short_name": "D", "long_name": "Incorrect 3", "reply": "D"}]}}'
57+
58+
def test_question_not_available(self) -> None:
59+
with self.mock_http_conversation('test_new_question'):
60+
model = TriviaQuizGameModel()
61+
# Exception
62+
with self.assertRaises(NotAvailableException):
63+
with mock_request_exception():
64+
model.get_trivia_quiz()
65+
# non-ok status code
66+
with self.assertRaises(NotAvailableException):
67+
with self.mock_http_conversation("test_status_code"):
68+
model.get_trivia_quiz()
69+
70+
def test_validate_move(self) -> None:
71+
with self.mock_http_conversation('test_new_question'):
72+
model = TriviaQuizGameModel()
73+
valid_moves = [
74+
'A',
75+
'B',
76+
'C',
77+
'D'
78+
]
79+
invalid_moves = [
80+
'AA',
81+
'1'
82+
]
83+
for valid_move in valid_moves:
84+
self.assertTrue(model.validate_move(valid_move))
85+
for invalid_move in invalid_moves:
86+
self.assertFalse(model.validate_move(invalid_move))
87+
88+
def test_make_move(self) -> None:
89+
with self.mock_http_conversation('test_new_question'):
90+
model = TriviaQuizGameModel()
91+
model.current_board = self.test_question
92+
model.scores = {
93+
0: 0,
94+
1: 1
95+
}
96+
# Invalid move should raise BadMoveException
97+
with self.assertRaises(BadMoveException):
98+
model.make_move('AA', 0)
99+
# Correct move should:
100+
with self.mock_http_conversation('test_new_question'):
101+
with patch('random.shuffle'):
102+
move_data = model.make_move('A', 0)
103+
# Increment score
104+
self.assertEqual(model.scores[0], 1)
105+
# Change question
106+
self.assertEqual(model.current_board, read_bot_fixture_data("trivia_quiz_game", "test_new_question_dict"))
107+
# Move data correct should be true
108+
self.assertTrue(move_data['correct'])
109+
# Move data score should be the same as model.scores[player_number]
110+
self.assertEqual(move_data['score'], 1)
111+
# Incorrect move should:
112+
with self.mock_http_conversation('test_new_question'):
113+
model.current_board = self.test_question
114+
move_data = model.make_move('B', 1)
115+
# Decrement score
116+
self.assertEqual(model.scores[1], 0)
117+
# Move data correct should be false
118+
self.assertFalse(move_data['correct'])
119+
120+
def test_determine_game_over(self) -> None:
121+
with self.mock_http_conversation('test_new_question'):
122+
model = TriviaQuizGameModel()
123+
model.scores = {
124+
0: 0,
125+
1: 5,
126+
2: 1
127+
}
128+
self.assertEqual(model.determine_game_over(["Test 0", "Test 1", "Test 2"]), "Test 1")
129+
model.scores = {
130+
0: 0,
131+
1: 4,
132+
2: 1
133+
}
134+
self.assertIsNone(model.determine_game_over(["Test 0", "Test 1", "Test 2"]))
135+
136+
def test_message_handler_parse_board(self) -> None:
137+
message_handler = TriviaQuizGameMessageHandler()
138+
board_message_content, board_message_widget = message_handler.parse_board(self.test_question)
139+
self.assertEqual(board_message_content, self.test_question_message_content)
140+
self.assertEqual(json.loads(board_message_widget), json.loads(self.test_question_message_widget))
141+
142+
def test_message_handler_alert_move_message(self) -> None:
143+
message_handler = TriviaQuizGameMessageHandler()
144+
correct_responses = [
145+
(("Test User", "A", {'correct': True, 'score': 5}), ":tada: Correct Test User (5 points)!"),
146+
(("Test User", "B", {'correct': False, 'score': 1, 'correct_letter': "B"}), ":disappointed: Incorrect Test User (1 points). The correct answer was **B**")
147+
]
148+
for args, response in correct_responses:
149+
self.assertEqual(message_handler.alert_move_message(*args), response)
150+
151+
def test_message_handler_get_player_color(self) -> None:
152+
message_handler = TriviaQuizGameMessageHandler()
153+
self.assertIsNone(message_handler.get_player_color(0))

0 commit comments

Comments
 (0)