Skip to content

Commit ee2bc20

Browse files
committed
bots: create trivia_quiz_game bot
1 parent 1514fc4 commit ee2bc20

File tree

8 files changed

+435
-4
lines changed

8 files changed

+435
-4
lines changed

zulip_bots/zulip_bots/bots/trivia_quiz_game/__init__.py

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

0 commit comments

Comments
 (0)