Skip to content

Commit 8a9d7b3

Browse files
committed
bots: port trivia_quiz to game_handler
1 parent 5883b32 commit 8a9d7b3

File tree

5 files changed

+358
-359
lines changed

5 files changed

+358
-359
lines changed
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 TriviaQuizModel(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: 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+
}

zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py

Lines changed: 128 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -12,146 +12,145 @@
1212
)
1313

1414
from zulip_bots.request_test_lib import (
15-
mock_request_exception,
15+
mock_request_exception
16+
)
17+
18+
from zulip_bots.bots.trivia_quiz.controller import (
19+
TriviaQuizModel,
20+
NotAvailableException
1621
)
1722

1823
from zulip_bots.bots.trivia_quiz.trivia_quiz import (
19-
get_quiz_from_payload,
20-
fix_quotes,
21-
get_quiz_from_id,
22-
update_quiz,
23-
handle_answer,
24+
TriviaQuizMessageHandler
2425
)
2526

27+
from zulip_bots.game_handler import BadMoveException
28+
2629
class TestTriviaQuizBot(BotTestCase, DefaultTests):
27-
bot_name = "trivia_quiz" # type: str
30+
bot_name = 'trivia_quiz' # type: str
2831

2932
new_question_response = '\nQ: Which class of animals are newts members of?\n\n' + \
3033
'* **A** Amphibian\n' + \
3134
'* **B** Fish\n' + \
3235
'* **C** Reptiles\n' + \
3336
'* **D** Mammals\n' + \
34-
'**reply**: answer Q001 <letter>'
35-
36-
def get_test_quiz(self) -> Tuple[Dict[str, Any], Any]:
37-
bot_handler = StubBotHandler()
38-
quiz_payload = read_bot_fixture_data('trivia_quiz', 'test_new_question')['response']
39-
with patch('random.shuffle'):
40-
quiz = get_quiz_from_payload(quiz_payload)
41-
return quiz, bot_handler
42-
43-
def _test(self, message: str, response: str, fixture: Optional[str]=None) -> None:
44-
if fixture:
45-
with self.mock_http_conversation(fixture):
46-
self.verify_reply(message, response)
47-
else:
48-
self.verify_reply(message, response)
49-
50-
def test_bot_responds_to_empty_message(self) -> None:
51-
self._test('', 'type "new" for a new question')
52-
53-
def test_bot_new_question(self) -> None:
54-
with patch('random.shuffle'):
55-
self._test('new', self.new_question_response, 'test_new_question')
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"}]}}'
5660

5761
def test_question_not_available(self) -> None:
58-
self._test('new', 'Uh-Oh! Trivia service is down.', 'test_status_code')
59-
60-
with mock_request_exception():
61-
self.verify_reply('new', 'Uh-Oh! Trivia service is down.')
62-
63-
def test_fix_quotes(self) -> None:
64-
self.assertEqual(fix_quotes('test &amp; test'), html.unescape('test &amp; test'))
65-
print('f')
66-
with patch('html.unescape') as mock_html_unescape:
67-
mock_html_unescape.side_effect = Exception
68-
with self.assertRaises(Exception) as exception:
69-
fix_quotes('test')
70-
self.assertEqual(str(exception.exception), "Please use python3.4 or later for this bot.")
71-
72-
def test_invalid_answer(self) -> None:
73-
invalid_replies = ['answer A',
74-
'answer A Q10',
75-
'answer Q001 K',
76-
'answer 001 A']
77-
for reply in invalid_replies:
78-
self._test(reply, 'Invalid answer format')
79-
80-
def test_invalid_quiz_id(self) -> None:
81-
self._test('answer Q100 A', 'Invalid quiz id')
82-
83-
def test_answers(self) -> None:
84-
quiz_payload = read_bot_fixture_data('trivia_quiz', 'test_new_question')['response']
85-
with patch('random.shuffle'):
86-
quiz = get_quiz_from_payload(quiz_payload)
87-
88-
# Test initial storage
89-
self.assertEqual(quiz['question'], 'Which class of animals are newts members of?')
90-
self.assertEqual(quiz['correct_letter'], 'A')
91-
self.assertEqual(quiz['answers']['D'], 'Mammals')
92-
self.assertEqual(quiz['answered_options'], [])
93-
self.assertEqual(quiz['pending'], True)
94-
95-
# test incorrect answer
96-
with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id',
97-
return_value=json.dumps(quiz)):
98-
self._test('answer Q001 B', ':disappointed: WRONG, Foo Test User! B is not correct.')
99-
100-
# test correct answer
101-
with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id',
102-
return_value=json.dumps(quiz)):
103-
with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.start_new_quiz') as mock_new_quiz:
104-
self._test('answer Q001 A', ':tada: **Amphibian** is correct, Foo Test User!')
105-
106-
def test_update_quiz(self) -> None:
107-
quiz, bot_handler = self.get_test_quiz()
108-
update_quiz(quiz, 'Q001', bot_handler)
109-
test_quiz = json.loads(bot_handler.storage.get('Q001'))
110-
self.assertEqual(test_quiz, quiz)
111-
112-
def test_get_quiz_from_id(self) -> None:
113-
quiz, bot_handler = self.get_test_quiz()
114-
bot_handler.storage.put('Q001', quiz)
115-
self.assertEqual(get_quiz_from_id('Q001', bot_handler), quiz)
116-
117-
def test_handle_answer(self) -> None:
118-
quiz, bot_handler = self.get_test_quiz()
119-
# create test initial storage
120-
update_quiz(quiz, 'Q001', bot_handler)
121-
122-
# test for a correct answer
123-
start_new_question, response = handle_answer(quiz, 'A', 'Q001', bot_handler, 'Test user')
124-
self.assertTrue(start_new_question)
125-
self.assertEqual(response, ':tada: **Amphibian** is correct, Test user!')
126-
127-
# test for an incorrect answer
128-
start_new_question, response = handle_answer(quiz, 'D', 'Q001', bot_handler, 'Test User')
129-
self.assertFalse(start_new_question)
130-
self.assertEqual(response, ':disappointed: WRONG, Test User! D is not correct.')
131-
132-
def test_handle_answer_three_failed_attempts(self) -> None:
133-
quiz, bot_handler = self.get_test_quiz()
134-
# create test storage for a question which has been incorrectly answered twice
135-
quiz['answered_options'] = ['C', 'B']
136-
update_quiz(quiz, 'Q001', bot_handler)
137-
138-
# test response and storage after three failed attempts
139-
start_new_question, response = handle_answer(quiz, 'D', 'Q001', bot_handler, 'Test User')
140-
self.assertEqual(response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.')
141-
self.assertTrue(start_new_question)
142-
quiz_reset = json.loads(bot_handler.storage.get('Q001'))
143-
self.assertEqual(quiz_reset['pending'], False)
144-
145-
# test response after question has ended
146-
incorrect_answers = ['B', 'C', 'D']
147-
for ans in incorrect_answers:
148-
start_new_question, response = handle_answer(quiz, ans, 'Q001', bot_handler, 'Test User')
149-
self.assertEqual(response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.')
150-
self.assertFalse(start_new_question)
151-
start_new_question, response = handle_answer(quiz, 'A', 'Q001', bot_handler, 'Test User')
152-
self.assertEqual(response, ':tada: **Amphibian** is correct, Test User!')
153-
self.assertFalse(start_new_question)
154-
155-
# test storage after question has ended
156-
quiz_reset = json.loads(bot_handler.storage.get('Q001'))
157-
self.assertEqual(quiz_reset['pending'], False)
62+
with self.mock_http_conversation('test_new_question'):
63+
model = TriviaQuizModel()
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 = TriviaQuizModel()
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):
92+
with self.mock_http_conversation('test_new_question'):
93+
model = TriviaQuizModel()
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", "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):
124+
with self.mock_http_conversation('test_new_question'):
125+
model = TriviaQuizModel()
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):
140+
message_handler = TriviaQuizMessageHandler()
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(board_message_widget, self.test_question_message_widget)
144+
145+
def test_message_handler_alert_move_message(self):
146+
message_handler = TriviaQuizMessageHandler()
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):
155+
message_handler = TriviaQuizMessageHandler()
156+
self.assertIsNone(message_handler.get_player_color(0))

0 commit comments

Comments
 (0)