diff --git a/README.md b/README.md index b3eba52..bff3ab8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ the Opal device that is paired with this node. Due to licensing, the full set of graphics required for the game is available on request from students in the Personal Robots Group. Please email students in the group to inquire. +### Stories + +Due to licensing, only a couple sample stories are provided in this repository. +These can imported into the database, and story scripts generated, using the +`source_ods/story-test-set.ods` spreadsheet and the provided +`ss_process_story_ods.py` script (described below). The full game requires a +set of 42 stories. This full set is available on request from students in the +Personal Robots Group. Please email students in the group to inquire. + ## ROS messages This node subscribes to the ROS topic `/sar/robot_state` to receive messages of @@ -317,6 +326,15 @@ will look like this: `STORY` +That said: Depending on your script, you may want to specify that a new story +should be selected before attempting to load the story or play back the story. +A `STORY` line may optionally take a string argument "SETUP", which indicates +that the next story should be selected. You will then need to use the usual +script lines for loading and playing back the story. + +`STORY SETUP` + + ### Story scripts The story scripts follow the same format as the main session scripts. See the @@ -352,10 +370,15 @@ load graphics and tell the robot how to read aloud the story, and add meta-information about the stories and the questions to ask about each story to the database. - Note that the script assumes a very particular organization of the +Note that the script assumes a very particular organization of the spreadsheet. An example spreadsheet containing two stories is provided in `source_ods/story-test-set.ods`. +In addition, the script clears existing data from several tables, so you should +run the script with all the spreadsheets you need to import at once. If you run +the script again later, some or all of the previously imported data may be +deleted. + Run as follows: `python ss_process_story_ods.py [-h] [-d [DB]] [-o [OUT_DIR]] ods_files @@ -381,7 +404,7 @@ Optional arguments: ### Personalization -There are two levels of personalization. First is the level of the story +There are two kinds of personalization. First is the level of the story presented. Players start at level 1. If they get sufficient emotion questions correct about the stories they hear, in the next session, they are leveled up. The percentage of questions they need to get correct to level up can be set in @@ -406,6 +429,22 @@ couple guiding principles: We query the database to determine the player's past performance and stories heard. +## Testing + +We are using python's unittest framework for testing. Some of the tests require +an initialized and filled database, so you will need to create one prior to +running the tests. + +Steps: +- Initialize the database with the `ss_init_db.py` script as described above. +- Fill the database with the full set of 42 SAR stories using the + `ss_process_story_ods.py` script. If you use only the example stories, a + couple tests may need to be modified, since they assume that the full set of + stories will be present and can be referenced. +- Run `python -m unittest discover` from the `src/` directory. This will + automatically find all files in that directory containing tests, and will run + all the tests. + ## Version notes This program was developed and tested with: diff --git a/game_scripts/session_scripts/demo-story.txt b/game_scripts/session_scripts/demo-story.txt index 408bd10..8d9c73d 100644 --- a/game_scripts/session_scripts/demo-story.txt +++ b/game_scripts/session_scripts/demo-story.txt @@ -5,6 +5,7 @@ ROBOT DO "Touch start when you are ready to hear the story!" WAIT START 300 OPAL CLEAR PAUSE 1 +STORY SETUP OPAL LOAD_STORY ROBOT STORY_INTRO ROBOT DO "Let's look at the story together." diff --git a/game_scripts/session_scripts/demo-yesno-scene.txt b/game_scripts/session_scripts/demo-yesno-scene.txt index cda7c9a..d1da8e2 100644 --- a/game_scripts/session_scripts/demo-yesno-scene.txt +++ b/game_scripts/session_scripts/demo-yesno-scene.txt @@ -1 +1 @@ -{ "name": "buttons/start_button_wide.png", "tag": "PlayObject", "draggable": "false", "slot": "1", "isAnswerSlot": "false"} +{ "name": "buttons/start_button_square.png", "tag": "PlayObject", "draggable": "false", "slot": "1", "isAnswerSlot": "false"} diff --git a/game_scripts/story_scripts/demo-story-1.txt b/game_scripts/story_scripts/demo-story-1.txt index 4ca7e29..584e59c 100644 --- a/game_scripts/story_scripts/demo-story-1.txt +++ b/game_scripts/story_scripts/demo-story-1.txt @@ -1,23 +1,31 @@ -ROBOT DO "Lisa was putting on her shoes in the morning. Bella the dog took Lisa's favorite shoe. Lisa tried to get her shoe back from Bella. Bella wouldn't give it back. Lisa tried again but Bella still wouldn't give it back. Lisa's mom came into the room. She took the shoe from Bella. Then Lisa's mom gave the shoe back to her." +OPAL HIGHLIGHT scene0 +ROBOT DO "Lisa was putting on her shoes in the morning. Bella the dog took Lisa's favorite shoe." +OPAL HIGHLIGHT scene1 +ROBOT DO "Lisa tried to get her shoe back from Bella. Bella wouldn't give it back." +OPAL HIGHLIGHT scene2 +ROBOT DO "Lisa tried again but Bella still wouldn't give it back." +OPAL HIGHLIGHT scene3 +ROBOT DO "Lisa's mom came into the room. She took the shoe from Bella. Then Lisa's mom gave the shoe back to her." ROBOT DO "The end." +OPAL HIGHLIGHT PAUSE 2 OPAL LOAD_ANSWERS answers/lisa_happy.png, answers/lisa_sad.png, answers/lisa_excited.png, answers/lisa_surprised.png OPAL SET_CORRECT {"correct":["lisa_sad"], "incorrect":["lisa_happy","lisa_excited","lisa_surprised"]} -ROBOT DO How did Lisa feel when she saw Bella take her favorite shoe? +ROBOT DO "How did Lisa feel when she saw Bella take her favorite shoe?" WAIT CORRECT_INCORRECT 10 -ROBOT DO Lisa felt sad. +ROBOT DO "Lisa felt sad." OPAL CLEAR ANSWERS PAUSE 1 OPAL LOAD_ANSWERS answers/lisa_excited.png, answers/lisa_happy.png, answers/lisa_bored.png, answers/lisa_frustrated.png OPAL SET_CORRECT {"correct":["lisa_frustrated"], "incorrect":["lisa_excited","lisa_happy","lisa_bored"]} -ROBOT DO How did Lisa feel when she couldn't get her shoe back? +ROBOT DO "How did Lisa feel when she couldn't get her shoe back?" WAIT CORRECT_INCORRECT 10 -ROBOT DO Lisa felt frustrated. +ROBOT DO "Lisa felt frustrated." OPAL CLEAR ANSWERS PAUSE 1 -OPAL LOAD_ANSWERS answers/lisa_angry.png, answers/lisa_afraid.png, answers/lisa_frustrated.png, answers/lisa_happy.png -OPAL SET_CORRECT {"correct":["lisa_happy"], "incorrect":["lisa_angry","lisa_afraid","lisa_frustrated"]} -ROBOT DO How did Lisa feel when she got her shoe back from her mom? +OPAL LOAD_ANSWERS answers/lisa_mad.png, answers/lisa_scared.png, answers/lisa_frustrated.png, answers/lisa_happy.png +OPAL SET_CORRECT {"correct":["lisa_happy"], "incorrect":["lisa_mad","lisa_scared","lisa_frustrated"]} +ROBOT DO "How did Lisa feel when she got her shoe back from her mom?" WAIT CORRECT_INCORRECT 10 -ROBOT DO Lisa felt happy. +ROBOT DO "Lisa felt happy." OPAL CLEAR ANSWERS diff --git a/source_ods/story-test-set.ods b/source_ods/story-test-set.ods index 98a7733..2f0354b 100644 Binary files a/source_ods/story-test-set.ods and b/source_ods/story-test-set.ods differ diff --git a/src/ss_db_manager.py b/src/ss_db_manager.py index e56729b..83d07b7 100644 --- a/src/ss_db_manager.py +++ b/src/ss_db_manager.py @@ -60,10 +60,10 @@ def get_most_recent_level(self, participant, current_session): try: result = self._cursor.execute(""" - SELECT level_id + SELECT level FROM stories_played - WHERE participant=(?) - AND session=(?) + WHERE participant = (?) + AND session = (?) ORDER BY time DESC LIMIT 1""", (participant, (current_session-1))).fetchone() @@ -76,9 +76,9 @@ def get_most_recent_level(self, participant, current_session): # Database gives us a tuple, so return the first element. return result[0] except Exception as e: - self._logger.exception("Could not find level of previous session" - + " for " + participant + " for session " - + str(current_session) + " in the database!") + self._logger.exception("Failed when trying to find the level of " + "previous session" + " for " + participant + " for session " + + str(current_session) + " in the database!") # Pass on exception for now. raise @@ -95,18 +95,19 @@ def get_percent_correct_responses(self, participant, session, # Get the number of correct responses (i.e., the questions # from the participant's last session where their response # was equal to the target response). + # The responses table can be empty if no responses from the + # participant have been recorded yet. The questions table + # should never be empty (filled when stories are imported). total_correct = self._cursor.execute(""" SELECT COUNT(responses.response) FROM responses JOIN questions ON questions.id = responses.questions_id - WHERE questions.target_response = responses.response - AND responses.stories_played_id in ( - SELECT id - FROM stories_played - WHERE participant = (?) - AND session = (?) - ORDER BY time DESC) + JOIN stories_played + ON responses.stories_played_id = stories_played.id + WHERE questions.target_response = responses.response + AND stories_played.participant = (?) + AND stories_played.session = (?) """ # Only filter by question type if one was provided. + ("" if (question_type is None) else \ @@ -115,10 +116,13 @@ def get_percent_correct_responses(self, participant, session, ((participant, session) if (question_type is None) else \ (participant, session, question_type)) ).fetchone() + #TODO helper function, "correct" as parameter like question type + # The participant may not have responded to any questions + # correctly. if total_correct is None: self._logger.warn("Could not find any correct responses for " - + participant + " for session " + session + + participant + " for session " + str(session) + " in the database!") total_correct = 0 @@ -144,19 +148,20 @@ def get_percent_correct_responses(self, participant, session, (participant, session, question_type)) ).fetchone() + # The participant may not have responded to any questions. if total_responses is None or total_responses[0] == 0: self._logger.warn("Could not find any responses for " - + participant + " for session " + session + + participant + " for session " + str(session) + " in the database!") total_responses = 0 - return 0 + return None else: # Return percent responses correct (database gave us # these values in tuples). return float(correct_responses[0]) / total_responses[0] except Exception as e: - self._logger.exception("Could not find any responses for " - + participant + " for session " + session + self._logger.exception("Failed when trying to find responses for " + + participant + " for session " + str(session) + " in the database!") # Pass on exception for now. raise @@ -169,23 +174,25 @@ def get_most_recent_incorrect_emotions(self, participant, current_session): """ # May not be able to use ORDER BY in the subquery - if this is # a problem, fix later. + # The responses table may be empty if no responses have been + # recorded yet. try: result = self._cursor.execute(""" SELECT DISTINCT responses.response, questions.target_response FROM responses JOIN questions - ON questions.id = responses.questions_id - WHERE questions.target_response <> responses.response - AND responses.stories_played_id in ( - SELECT id - FROM stories_played - WHERE participant = (?) - AND session = (?) - ORDER BY time DESC) + ON questions.id = responses.questions_id + JOIN stories_played + ON responses.stories_played_id = stories_played.id + WHERE questions.target_response <> responses.response + AND stories_played.participant = (?) + AND stories_played.session = (?) """, (participant, current_session)).fetchall() + # The user may not have responded incorrectly to any + # questions, in which case we get no results from the query. if result is None or result == []: self._logger.warn("Could not find any incorrect responses for " - + participant + " for session " + (current_session-1) + + participant + " for session " + str(current_session-1) + " in the database!") return [] else: @@ -193,19 +200,20 @@ def get_most_recent_incorrect_emotions(self, participant, current_session): # a list before returning. return [emotion[0] for emotion in result] except Exception as e: - self._logger.exception("Could not find any incorrect responses for " - + participant + " for session " + (current_session-1) - + " in the database!") + self._logger.exception("Failed when trying to find incorrect " + "responses for " + participant + " for session " + + str(current_session-1) + " in the database!") # Pass on exception for now. raise - def get_next_new_story(self, participant, current_session, emotions): - """ Get the next unplayed story from the story table with at - least one of the listed emotions present in the story. If no - unplayed story has the desired emotions or if there are no - desired emotions, return the name of the next unplayed story. - If there are no more unplayed stories, return None. + def get_next_new_story(self, participant, emotions, level): + """ Get the next unplayed story for the desired level from the + story table with at least one of the listed emotions present in + the story. If no unplayed story has the desired emotions or if + there are no desired emotions, return the name of the next + unplayed story. If there are no more unplayed stories, return + None. """ try: # Parameters are the list of emotions, participant, session. @@ -216,72 +224,69 @@ def get_next_new_story(self, participant, current_session, emotions): # correct number of ?'s into the query for the number of # emotions and supply a list with a matching number of # parameters. - params = emotions + params = list(emotions) + params.append(participant) + params.append(level) params.append(participant) - params.append(current_session) - result = self._cursor.execute(""" - SELECT DISTINCT stories.story_name + # The stories and questions tables should not be empty. + # The stories_played table may be empty. + # The first half of the query looks for a story with the + # specified emotions; the second half looks for any unplayed + # story, not caring about emotions. + # 0 for the first query and 1 for the second query lets us + # order the results by anything found from the first query (the + # first half of the union) before anything from the second half. + query1 = """ + SELECT stories.story_name, stories.id, 0 AS found_emotion FROM stories JOIN questions ON questions.story_id = stories.id - JOIN stories_played + LEFT JOIN stories_played ON stories_played.story_id = stories.id WHERE questions.target_response IN (%s) - AND stories.id - NOT IN ( - SELECT stories_played.story_id - FROM stories_played - WHERE stories_played.participant = (?) - AND stories_played.session = (?)) - ORDER BY stories.id - LIMIT 1 - """ % ",".join("?"*len(emotions)), params).fetchall() + AND (stories_played.participant <> (?) + OR stories_played.participant IS NULL) + AND questions.level = (?) + """ % ",".join("?"*len(emotions)) - if result is None or result == []: - self._logger.warn("Could not find any unplayed stories for " - + participant + " for session " + str(current_session) - + " with emotions " + emotions + " in the database!" + - " Will try to find any unplayed story...") + query2 = """ + SELECT stories.story_name, stories.id, 1 AS found_emotion + FROM stories + LEFT JOIN stories_played + ON stories_played.story_id = stories.id + AND (stories_played.participant <> (?) + OR stories_played.participant IS NULL) + """ - # Query again, but look for any unplayed stories, not - # just unplayed stories with particular emotions. - result = self._cursor.execute(""" - SELECT DISTINCT stories.story_name - FROM stories - JOIN stories_played - ON stories_played.story_id = stories.id - WHERE stories.id - NOT IN ( - SELECT stories_played.story_id - FROM stories_played - WHERE stories_played.participant = (?) - AND stories_played.session = (?)) - ORDER BY stories.id - LIMIT 1 - """ , (participant, session)).fetchall() + query = query1 + " UNION " + query2 + """ + ORDER BY found_emotion, stories.id + LIMIT 1 """ - if result is None or result == []: - self._logger.warn("Could not find unplayed stories for " - + participant + " for session " + str(current_session) - + " in the database!") - return None + result = self._cursor.execute(query, params).fetchone() + + if result is None or result == []: + self._logger.warn("Could not find any unplayed stories for " + + participant + " in the database!") + return None # We either found an unplayed story with the right emotions # or didn't, and found an unplayed story without them. # Return the name of a new story to play. The DB gives # us the name of the story in a tuple. + self._logger.info("Found a story to play: " + str(result[0])) return result[0] except Exception as e: - self._logger.exception("Could not find any unplayed stories for " - + participant + " for session " + str(current_session) - + " with emotions " + emotions + " in the database!") + self._logger.exception("Failed when trying to find unplayed " + "stories for " + participant + " with emotions " + + str(emotions) + " in the database!") # Pass on exception for now. raise - def get_next_review_story(self, participant, current_session, emotions): + def get_next_review_story(self, participant, current_session, emotions, + level): """ Get a review story with at least one of the listed emotions present in the story that wasn't played in the current session. If no played stories have the desired emotions, return the @@ -297,13 +302,16 @@ def get_next_review_story(self, participant, current_session, emotions): # correct number of ?'s into the query for the number of # emotions and supply a list with a matching number of # parameters. - params = emotions + params = list(emotions) params.append(participant) params.append(current_session) + params.append(level) # This gives us a randomly picked story from a list of # stories played not this session with at least one of the # desired emotions. + # The stories and questions tables should not be empty. + # The stories_played table may be empty. result = self._cursor.execute(""" SELECT DISTINCT stories.story_name FROM stories @@ -312,12 +320,9 @@ def get_next_review_story(self, participant, current_session, emotions): JOIN stories_played ON stories_played.story_id = stories.id WHERE questions.target_response IN (%s) - AND stories.id - IN ( - SELECT stories_played.story_id - FROM stories_played - WHERE stories_played.participant = (?) - AND stories_played.session <> (?)) + AND stories_played.participant = (?) + AND stories_played.session <> (?) + AND questions.level = (?) ORDER BY RANDOM() LIMIT 1 """ % ",".join("?"*len(emotions)), params).fetchone() @@ -325,7 +330,7 @@ def get_next_review_story(self, participant, current_session, emotions): if result is None: self._logger.warn("Could not find any stories to review for " + participant + " for session " + str(current_session) - + " with emotions " + emotions + " in the database!" + + " with emotions " + str(emotions) + " in the database!" + " Looking for a story without those emotions...") # If no stories have the desired emotions to review, @@ -342,9 +347,9 @@ def get_next_review_story(self, participant, current_session, emotions): AND stories_played.session <> (?) GROUP BY stories_played.story_id ORDER BY count(stories_played.story_id) ASC, - stories_played.time ASC + max(stories_played.time) ASC LIMIT 1 - """, (participant, session)).fetchone() + """, (participant, current_session)).fetchone() if result is None or result == []: self._logger.warn("Could not find any review stories for " @@ -360,9 +365,10 @@ def get_next_review_story(self, participant, current_session, emotions): return result[0] except Exception as e: - self._logger.exception("Could not find any stories to review for " - + participant + " for session " + str(current_session) - + " with emotions " + emotions + " in the database!") + self._logger.exception("Failed when trying to find stories to " + "review for " + participant + " for session " + + str(current_session) + " with emotions " + str(emotions) + + " in the database!") # Pass on exception for now. raise @@ -376,10 +382,10 @@ def get_level_info(self, level): result = self._cursor.execute(""" SELECT num_answers, in_order FROM levels - WHERE level=(?) - """, (level,)) + WHERE level = (?) + """, (level,)).fetchone() if result is None: - self._logger.warn("Could not find info for level " + level + self._logger.warn("Could not find info for level " + str(level) + " in the database!") return None else: @@ -388,8 +394,8 @@ def get_level_info(self, level): # to a boolean. return result[0], (True if result[1] == 1 else False) except Exception as e: - self._logger.exception("Could not find info for level " + level - + " in the database!") + self._logger.exception("Failed when trying to find info for level " + + str(level) + " in the database!") # Pass on exception for now. raise @@ -402,28 +408,29 @@ def get_graphics(self, story, level): result = self._cursor.execute(""" SELECT graphic FROM graphics - WHERE level_id=(?) - AND story_id=( + WHERE level = (?) + AND story_id = ( SELECT id FROM stories - WHERE story_name=(?)) + WHERE story_name = (?)) """, (level, story)).fetchall() - if result is None: + if result is None or result == []: self._logger.warn("Could not find graphics for story " + story - + " at level " + level + " in the database!") + + " at level " + str(level) + " in the database!") return None else: # Database gives us a list of tuples of graphic names, # so make this into a list of graphic names. return [name[0] for name in result] except Exception as e: - self._logger.exception("Could not find graphics for story " + story - + " at level " + level + " in the database!") + self._logger.exception("Failed when trying to find graphics for " + "story " + story + " at level " + str(level) + + " in the database!") # Pass on exception for now. raise - def record_story_played(participant, session, level, story): + def record_story_played(self, participant, session, level, story): """ Insert the participant ID, session number, story level, current date and time, and a reference to the current story into the stories_played table. @@ -431,29 +438,27 @@ def record_story_played(participant, session, level, story): try: self._cursor.execute(""" INSERT INTO stories_played (participant, session, - level_id, story_id) + level, story_id) VALUES ( (?), (?), (SELECT id FROM stories - WHERE story_name=(?)), - (SELECT level - FROM levels - WHERE level=(?))) + WHERE story_name = (?)), + (?)) """, (participant, session, story, level)) # Commit after recording the story. self._conn.commit() except Exception as e: self._logger.exception("Could not insert record into stories_played" + " table in database! Tried to insert: participant=" + - participant + ", session=" + session + ", level=" + level + - ", story=" + story) + participant + ", session=" + str(session) + ", level=" + + str(level) + ", story=" + story) # Pass on exception for now. raise - def record_response(participant, session, level, story, question_num, + def record_response(self, participant, session, level, story, question_num, question_type, response): """ Insert a user response into the responses table: we need the question ID, stories_played ID, and the actual response. @@ -464,30 +469,24 @@ def record_response(participant, session, level, story, question_num, response VALUES ( (SELECT id from stories_played - WHERE participant=(?) - AND session=(?) - AND level_id=( - SELECT level - FROM levels - WHERE level=(?)) - AND story_id=( + WHERE participant = (?) + AND session = (?) + AND level = (?) + AND story_id = ( SELECT id FROM stories - WHERE story_name=(?)) + WHERE story_name = (?)) ORDER BY time DESC LIMIT 1), (SELECT id FROM questions - WHERE question_num=(?) - AND question_type=(?) - AND level=( - SELECT level - FROM levels - WHERE level=(?)) - AND story_id=( + WHERE question_num = (?) + AND question_type = (?) + AND level = (?) + AND story_id = ( SELECT id FROM stories - WHERE story_name=(?))), + WHERE story_name = (?))), (?)) """, (participant, session, level, story, question_num, question_type, level, story, response)) @@ -496,8 +495,9 @@ def record_response(participant, session, level, story, question_num, except Exception as e: self._logger.exception("Could not insert record into questions" + " table in database! Tried to insert: participant=" + - participant + ", session=" + session + ", level=" + level + - ", story=" + story + ", question_num=" + question_num + - ", question_type=" + question_type + ", response=" + response) + participant + ", session=" + str(session) + ", level=" + + str(level) + ", story=" + story + ", question_num=" + + str(question_num) + ", question_type=" + question_type + + ", response=" + response) # Pass on exception for now. raise diff --git a/src/ss_game_node.py b/src/ss_game_node.py index 4fd0a29..e5881e7 100755 --- a/src/ss_game_node.py +++ b/src/ss_game_node.py @@ -47,16 +47,8 @@ class ss_game_node(): platforms). """ - # Set up ROS node globally. - # TODO If running on network where DNS does not resolve local - # hostnames, get the public IP address of this machine and - # export to the environment variable $ROS_IP to set the public - # address of this node, so the user doesn't have to remember - # to do this before starting the node. - _ros_node = rospy.init_node('social_story_game', anonymous=True) - # We could set the ROS log level here if we want: - #log_level=rospy.DEBUG) - # The rest of our logging is set up in the log config file. + # We will have a ROS node, which we initialize when we launch the game. + _ros_node = None def __init__(self): """ Initialize anything that needs initialization """ @@ -70,7 +62,7 @@ def __init__(self): with open(config_file) as json_file: json_data = json.load(json_file) logging.config.dictConfig(json_data) - self._logger.debug("==============================\n" + + self._logger.debug("\n==============================\n" + "STARTING\nLogger configuration:\n %s", json_data) except Exception as e: # Could not read config file -- use basic configuration. @@ -82,7 +74,7 @@ def __init__(self): + "log to \"ss.log\". Will not be logging to rosout!") - def parse_arguments_and_launch(self): + def parse_arguments(self): # Parse python arguments. # The game node requires the session number and participant ID be # provided so the appropriate game scripts can be loaded. @@ -105,24 +97,42 @@ def parse_arguments_and_launch(self): args = parser.parse_args() self._logger.debug("Args received: %s", args) - # Give the session number and participant ID to the game launcher - # where they will be used to load appropriate game scripts. + # Return the session number and participant ID so they can be + # used by the game launcher, where they will be used to load + # appropriate game scripts. # - # If the session number doesn't make sense, or we've specified that - # this is a demo, run demo. - if args.session < 0 or args.participant == 'DEMO': - self._launch_game(-1, 'DEMO') - # Otherwise, launch the game for the provided session and ID + # If the session number doesn't make sense, throw an error. + if args.session < -1: + raise ValueError("Session number out of range. Should be -1 to " + "play the demo or a positive integer to play a particular " + "session.") + + # If the args indicate that this is a demo, return demo args. + if args.session <= 0 or args.participant.lower() == "demo": + return (-1, "DEMO") + + # Otherwise, return the provided session and ID. else: - self._launch_game(args.session, args.participant) + return (args.session, args.participant) - def _launch_game(self, session, participant): + def launch_game(self, session, participant): """ Load game based on the current session and participant """ # Log session and participant ID. - self._logger.info("==============================\nSOCIAL STORIES " + + self._logger.info("\n==============================\nSOCIAL STORIES " + "GAME\nSession: %s, Participant ID: %s", session, participant) + # Initialize the ROS node. + # TODO If running on network where DNS does not resolve local + # hostnames, get the public IP address of this machine and + # export to the environment variable $ROS_IP to set the public + # address of this node, so the user doesn't have to remember + # to do this before starting the node. + self._ros_node = rospy.init_node('social_story_game', anonymous=True) + # We could set the ROS log level here if we want: + #log_level=rospy.DEBUG) + # The rest of our logging is set up in the log config file. + # Set up ROS node publishers and subscribers. self._ros_ss = ss_ros(self._queue) @@ -201,6 +211,9 @@ def _launch_game(self, session, participant): # Set up signal handler to catch SIGINT (e.g., ctrl-c). signal.signal(signal.SIGINT, self._signal_handler) + # Ready to start the game. Send a "READY" message. + self._ros_ss.send_game_state("READY") + while (not self._stop): try: try: @@ -338,7 +351,8 @@ def _signal_handler(self, sig, frame): # Try launching the game! try: game_node = ss_game_node() - game_node.parse_arguments_and_launch() + (session, participant) = game_node.parse_arguments() + game_node.launch_game(session, participant) # If roscore isn't running or shuts down unexpectedly... except rospy.ROSInterruptException: diff --git a/src/ss_init_db.py b/src/ss_init_db.py index 87e776d..3139520 100755 --- a/src/ss_init_db.py +++ b/src/ss_init_db.py @@ -76,11 +76,11 @@ def ss_init_db(): # each level. cursor.execute(""" CREATE TABLE graphics ( story_id integer NOT NULL, - level_id integer NOT NULL, + level integer NOT NULL, scene_num integer NOT NULL, graphic text NOT NULL, FOREIGN KEY(story_id) REFERENCES stories(id), - FOREIGN KEY(level_id) REFERENCES levels(level) + FOREIGN KEY(level) REFERENCES levels(level) )""") # The QUESTIONS table lists meta-information about questions that @@ -129,10 +129,10 @@ def ss_init_db(): time timestamp NOT NULL default current_timestamp, participant text NOT NULL, session integer NOT NULL, - level_id integer NOT NULL, + level integer NOT NULL, story_id text NOT NULL, FOREIGN KEY(story_id) REFERENCES stories(id), - FOREIGN KEY(level_id) REFERENCES levels(level) + FOREIGN KEY(level) REFERENCES levels(level) )""") conn.commit() diff --git a/src/ss_personalization_manager.py b/src/ss_personalization_manager.py index 9d2f8a5..4a8a828 100644 --- a/src/ss_personalization_manager.py +++ b/src/ss_personalization_manager.py @@ -91,37 +91,67 @@ def get_level_for_session(self): # If there is no previous data, start at level 1. if (level is None): return 1 + # If participant got 75%-80% questions correct last time, level - # up. If no responses were found, do not level up. + # up. If no responses were found or not enough were answered + # correctly, do not level up. #TODO total performance or just last time's performance? - if(self._db_man.get_percent_correct_responses( - self._participant, (self._session - 1)) > percent_correct_to_level): - self._logger.info("Participant got more than " + - (percent_correct_to_level*100) + "% questions correct last " - + "time, so we can level up! Level will be " + str(level+1) + past_performance = self._db_man.get_percent_correct_responses( + self._participant, (self._session - 1)) + if past_performance is None: + self._logger.info("Participant did not answer any questions last " + + "time, so we will not level up. Level will be " + str(level) + ".") - return level + 1 + return level + elif (past_performance >= self._percent_correct_to_level): + self._logger.info("Participant got more than " + + str(self._percent_correct_to_level*100) + + "% questions correct last time, so we can level up! Level will" + " be " + str(level+1) + ".") + return level + 1 if level < 10 else level else: self._logger.info("Participant got less than " + - (percent_correct_to_level*100) + "% questions correct last " - + "time, so we don't level up. Level will be " + str(level) - + ".") + str(self._percent_correct_to_level*100) + + "% questions correct last time, so we don't level up. Level" + " will be " + str(level) + ".") return level - def get_emotion_performance_this_session(self): - """ Get the user's performance on the emotion questions in this - session (and ignore performance on any other questions). + def get_performance_this_session(self): + """ Get the user's performance on all questions asked this + session, by question type. """ # Only get the user's performance if this isn't a DEMO session. if (self._session != -1): - return self._db_man.get_percent_correct_responses(self._participant, - self._session, "emotion") + # Get the user's performance on the emotion questions, on + # the theory of mind questions, and on the order questions. + return self._db_man.get_percent_correct_responses( + self._participant, self._session, "emotion"), \ + self._db_man.get_percent_correct_responses(self._participant, + self._session, "ToM"), \ + self._db_man.get_percent_correct_responses( \ + self._participant, self._session, "order") else: return None def get_next_story_script(self): + """ Return the name of the next story script to load. """ + # If this is a demo session, use the demo script. + if (self._session == -1): + return "demo-story-1.txt" + + # If no story has been picked yet, print error and pick a story. + elif (self._current_story is None): + self._logger.error("We were asked for the story script, but we " + "haven't picked a story yet! Picking a story...") + self._current_story = self.pick_next_story() + + # Return name of story script: story name + level + file extension. + return (self._current_story + "-" + str(self._level) + ".txt").lower() + + + def pick_next_story(self): """ Determine which story should be heard next. We have 40 stories. Alternate telling new stories and telling review stories. Earlier sessions will use more new stories since there @@ -133,15 +163,20 @@ def get_next_story_script(self): # If this is a demo session, use the demo script. if (self._session == -1): self._logger.debug("Using DEMO script.") - return "demo-story-1.txt" + # Save that we are using the demo story. + self._current_story = "demo-story-1" + return "demo-story-1" + + # We start without having picked the next story. + story = None # If we should tell a new story, get the next new story that # has one of the emotions to practice in it. If there aren't # any stories with one of those emotions, just get the next new # story. - elif self._tell_new_story: + if self._tell_new_story: story = self._db_man.get_next_new_story(self._participant, - self._session, self._emotion_list) + self._emotion_list, self._level) # If there are no more new stories to tell, or if we need to # tell a review story next, get a review story that has one of @@ -149,14 +184,14 @@ def get_next_story_script(self): # those emotions, get the oldest, least played review story. if (story is None) or not self._tell_new_story: story = self._db_man.get_next_review_story(self._participant, - self._session, self._emotion_list) + self._session, self._emotion_list, self._level) # If there are no review stories available, get a new story # instead (this may happen if we are supposed to tell a review # story but haven't told very many stories yet). if (story is None): story = self._db_man.get_next_new_story(self._participant, - self._session, self._emotion_list) + self._emotion_list, self._level) # If we still don't have a story, then for some reason there # are no new stories and no review stories we can tell. This is @@ -175,8 +210,8 @@ def get_next_story_script(self): # Save current story so we can provide story details later. self._current_story = story - # Return name of story script: story name + level + file extension. - return story + str(self._level) + ".txt" + # Return name of the story. + return story def get_next_story_details(self): @@ -186,8 +221,8 @@ def get_next_story_details(self): # If this is a demo session, load a demo scene. if (self._session == -1): # Demo set: - graphic_names = ["scenes/CR1-scene1.png", "scenes/CR1-scene2.png", - "scenes/CR1-scene3.png", "scenes/CR1-scene4.png"] + graphic_names = ["scenes/CR1-a-b.png", "scenes/CR1-b-b.png", + "scenes/CR1-c-b.png", "scenes/CR1-d-b.png"] # Demo story has scenes in order. in_order = True @@ -199,15 +234,14 @@ def get_next_story_details(self): + "\nIn order: " + str(in_order) + "\nNum answers: " + str(num_answers)) - # If the current story isn't set, throw exception. - elif (self._current_story is None): - self._logger.error("We were asked for story details, but we \ - haven't picked the next story yet!") - raise NoStoryFound("No current story is set.", self._participant, - self._session) - - # Otherwise, we have the current story. + # Otherwise, we will get the details for the current story. else: + # If the current story isn't set, print error, and pick a story. + if (self._current_story is None): + self._logger.error("We were asked for story details, but we \ + haven't picked a story yet! Picking a story...") + self._current_story = self.pick_next_story() + # Get story information from the database: scene graphics # names, whether the scenes are shown in order, how many # answer options there are per question at this level. diff --git a/src/ss_process_story_ods.py b/src/ss_process_story_ods.py index f45ae72..a92ecfe 100755 --- a/src/ss_process_story_ods.py +++ b/src/ss_process_story_ods.py @@ -41,11 +41,15 @@ def ss_process_story_ods(): # which will each be parsed for stories. parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, - description="""Read .ods spreadsheets containing story info for the - SAR Social Stories game stories. Generate game scripts that will be - used to load graphics and tell the robot how to read aloud the - story. Add meta-information about the stories and the questions to - ask about each story to the database.""") + description="Read .ods spreadsheets containing story info for the" + " SAR Social Stories game stories. Generate game scripts that will" + " be used to load graphics and tell the robot how to read aloud" + " the story. Add meta-information about the stories and the" + " questions to ask about each story to the database.\nThis script" + " will clear any existing data from the tables, so you should run" + " it with all the spreadsheets at once -- if you run it again" + " later, some or all of the previously imported data may be" + " deleted.") parser.add_argument('-d', '--database', dest='db', action='store', nargs='?', type=str, default='socialstories.db', help= "The database filename for storing story and question info. " @@ -142,8 +146,8 @@ def ss_process_story_ods(): continue # Add question to questions table at this level. - insert_to_questions_table(cursor, sheet.name, level+1, - question_num, question_type, + insert_to_questions_table(cursor, sheet.name.lower(), + level+1, question_num, question_type, # Target response is the first in the list # of response options sheet_dict[responses][level].split(',')[0] @@ -151,8 +155,8 @@ def ss_process_story_ods(): # Add responses to emotions_in_question table # at this level. - insert_to_responses_table(cursor, sheet.name, level+1, - question_num, question_type, + insert_to_responses_table(cursor, sheet.name.lower(), + level+1, question_num, question_type, sheet_dict[responses][level].split(',')) # Make dict of level: question text, responses. @@ -197,16 +201,17 @@ def ss_process_story_ods(): or (sheet[level,key] == ["-"]): print("Skipping empty cell") continue - insert_to_graphics_table(cursor, sheet.name, - level + 1, scene_num + 1, + # Graphics scene numbers are 1-indexed. + insert_to_graphics_table(cursor, + sheet.name.upper(), level + 1, scene_num, sheet[level,key].lower()) # For each level, generate story. # Rows are 0-indexed but levels are 1-indexed. for level in range(0,10): # Use story to generate game script for robot - generate_script_for_story(args.out_dir, sheet.name, level+1, - sheet[level, "Story"], question_list[level], + generate_script_for_story(args.out_dir, sheet.name.lower(), + level+1, sheet[level, "Story"], question_list[level], midway_question_list[level]) # Commit after each story. @@ -224,7 +229,7 @@ def insert_to_stories_table(cursor, story_names): cursor.execute(""" INSERT INTO stories (story_name) VALUES (?) - """, (name,)) + """, (name.lower(),)) except sqlite3.IntegrityError as e: print("Error adding story " + name + " to DB! It may already " "exist. Exception: " + str(e)) @@ -232,27 +237,28 @@ def insert_to_stories_table(cursor, story_names): def insert_to_graphics_table(cursor, story_name, level, scene, graphic_tag): """ Add a list of graphics names to the graphics table.""" - # story_id = The id from the stories table for this story. - # level_id = The level number from the levels table for this level. + # story_name = The name of the story. + # level = The level number from the levels table for this level. # scene = Scene number (1,2,3,4) to load this graphic into. # graphic_tag = Tag of graphic to load (lowercase letter). print("ADD GRAPHIC: " + story_name + "-" + str(level) + " scene" + str(scene) + " " + graphic_tag) # Graphics file names: - # [env][story_num]-[background_type]-[tag].png - # e.g., LR1-B-a.png or CF1-P-f.png - # Levels 1-5: tag P for plain background. - # Levels 6-10: tag B for complex background. - graphic_name = story_name.replace("Story-","") + "-" + \ - ("P" if level < 6 else "B") + "-" + graphic_tag + ".png" + # [env][story_num]-[tag]-[background_type].png + # where background_type is b=background or p=plain + # e.g., LR1-a-b.png or CF1-f-p.png + # Levels 1-5: p for plain background. + # Levels 6-10: b for complex background. + graphic_name = story_name.replace("STORY-","") + "-" + graphic_tag + "-" \ + + ("p" if level < 6 else "b") + ".png" cursor.execute(""" - INSERT INTO graphics (story_id, level_id, scene_num, graphic) + INSERT INTO graphics (story_id, level, scene_num, graphic) VALUES ( (SELECT id FROM stories WHERE story_name=(?)), (SELECT level FROM levels WHERE level=(?)), (?), (?)) - """, (story_name, level, scene, graphic_name)) + """, (story_name.lower(), level, scene, graphic_name)) def insert_to_questions_table(cursor, story, level, question_num, @@ -312,24 +318,24 @@ def insert_to_responses_table(cursor, story, level, question_num, def fill_levels_table(cursor): """ Initialize levels table. """ # level = The level number. - # num_scenes = The number of answer options for questions asked + # num_answers = The number of answer options for questions asked # about the story this level. # in_order = Whether the scenes for stories at that level are shown # in order (1=True) or out of order (0=False). try: cursor.execute(""" - INSERT INTO levels (level, num_scenes, in_order) + INSERT INTO levels (level, num_answers, in_order) VALUES - ("1", "1", "1"), - ("2", "2", "1"), + ("1", "3", "1"), + ("2", "3", "1"), ("3", "3", "1"), - ("4", "4", "1"), - ("5", "4", "0"), + ("4", "3", "1"), + ("5", "3", "0"), ("6", "4", "0"), ("7", "4", "0"), ("8", "4", "0"), ("9", "4", "0"), - ("10", "4", "0"), + ("10", "5", "0"), ("11", "4", "0"), ("12", "4", "0") """) @@ -351,21 +357,41 @@ def generate_script_for_story(output_dir, story_name, level, story, questions, # We can't split on sentences because splitting by period may # make some quoted speech in the stories be split onto multiple # lines, since periods may be inside of the quotations... - if "*" in story: - # There's a question to ask partway through the story, so - # we need to tell half the story, ask the question, then - # tell the second half. - story = story.split("*") - # Add first half of story. - f.write("ROBOT\tDO\t" + story[0].strip() + "\n") - - # Add midway question - add_question_to_script(midway_questions[0], f) - - # Add second half of story. - f.write("ROBOT\tDO\t" + story[1].strip() + "\n") - + # We do need to split by scenes, using "//" as a delimiter, so + # that we can send a "highlight scene" message to highlight the + # scene corresponding to the current story text being read. + if "//" in story: + # There's a scene delimiter, so there is text for more than + # one scene here. + story = story.split("//") + + # Add each story segment with a scene highlight command. + counter = 0 + for s in story: + # Highlight current scene. + f.write("OPAL\tHIGHLIGHT\tscene" + str(counter) + "\n") + # Add story text. + if "*" in s: + # There's a question to ask partway through the + # story, so we need to tell half the story, ask the + # question, then tell the second half. + s = s.split("*") + # Add first half of story. + f.write("ROBOT\tDO\t" + s[0].strip() + "\n") + # Add midway question + add_question_to_script(midway_questions[0], f) + # Add second half of story. + f.write("ROBOT\tDO\t" + s[1].strip() + "\n") + else: + # There's no question to ask partway through this + # story segment. + f.write("ROBOT\tDO\t" + s.strip() + "\n") + counter += 1 + + # Or, there's only text for one scene in this story. else: + # Highlight current scene. + f.write("OPAL\tHIGHLIGHT\tscene0\n") f.write("ROBOT\tDO\t" + story.strip() + "\n") # Add "The end" and a pause. @@ -393,30 +419,69 @@ def find_character(words): def add_question_to_script(question, outfile): """ Add a question to a game script. """ - # Find character this question is about. + # Find the character this question is about. + # The question provided has two parts: + # [0] = the question text + # [1] = the comma-separated list of responses character = find_character(question[0].split()) - # Load answers line. - outfile.write("OPAL\tLOAD_ANSWERS\t") # Make a string so we can deal with commas. s = "" - for response in question[1]: - s += "answers/" + character + "_" + response.lower() \ - + ".png, " - # Remove last comma before adding ending punctuation and - # writing the rest of the line to the file. - outfile.write(s[:-2] + "\n") - - # Set correct line. - outfile.write("OPAL\tSET_CORRECT\t{\"correct\":[\"" + character + "_" - + question[1][0].lower() + "\"], \"incorrect\":[") - # Make a string so we can deal with commas. - s = "" - for i in range (1, len(question[1])): - s += "\"" + character + "_" + question[1][i].lower() + "\"," - # Remove last comma before adding ending punctuation and - # writing the rest of the line to the file. - outfile.write(s[:-1] + "]}" + "\n") + + # We only want to load character faces as answers if the question is + # an emotion or ToM question about a character. + if "scene" not in question[1][0]: + # Load answers line. + outfile.write("OPAL\tLOAD_ANSWERS\t") + for response in question[1]: + s += "answers/" + character + "_" + response.lower().strip() \ + + ".png, " + # Remove last comma before adding ending punctuation and + # writing the rest of the line to the file. + outfile.write(s[:-2] + "\n") + + # Set correct line. + outfile.write("OPAL\tSET_CORRECT\t{\"correct\":[\"" + character + "_" + + question[1][0].lower().strip() + "\"], \"incorrect\":[") + # Make a string so we can deal with commas. + s = "" + for i in range (1, len(question[1])): + s += "\"" + character + "_" + question[1][i].lower().strip() + "\"," + # Remove last comma before adding ending punctuation and + # writing the rest of the line to the file. + outfile.write(s[:-1] + "]}" + "\n") + + # For order questions, we don't need to load answers -- we will use + # the scenes as the answer slots. So we will just need to set the + # scenes as correct or incorrect. + else: + # Set correct line. Scene slots in the game are 0-indexed, but + # in the story scripts, they are 1-indexed, so we need to + # convert them. + responses_0indexed = [] + for response in question[1]: + try: + num = re.findall(r'\d+', response)[0] + responses_0indexed.append("scene" + str(int(num)-1)) + except: + # If there is no number, we have a problem. Order + # questions should always have numbered scene responses. + print("No scene number found in question responses!") + raise + + # Now that we have 0-indexed responses, build a line to set + # the correct and incorrect responses. + outfile.write("OPAL\tSET_CORRECT\t{\"correct\":[\"" + + responses_0indexed[0] + + "\"], \"incorrect\":[") + # Make a string so we can deal with commas. + s = "" + for i in range (1, len(responses_0indexed)): + s += "\"" + responses_0indexed[i] + "\"," + + # Remove last comma before adding ending punctuation and + # writing the rest of the line to the file. + outfile.write(s[:-1] + "]}" + "\n") # Robot will say the question text next. outfile.write("ROBOT\tDO\t" + question[0] + "\n") @@ -427,7 +492,8 @@ def add_question_to_script(question, outfile): # Robot will say the answer, but only for ToM and emotion questions. if "scene" not in question[1][0]: outfile.write("ROBOT\tDO\t" + (character[0].upper() + character[1:]) - + " felt " + question[1][0].lower() + ".\n") + + " felt " + question[1][0].lower() + " <" + + question[1][0].lower() + ">.\n") # Add clear and pause lines. outfile.write("OPAL\tCLEAR\tANSWERS\n" diff --git a/src/ss_ros.py b/src/ss_ros.py index ec70951..7212e9e 100644 --- a/src/ss_ros.py +++ b/src/ss_ros.py @@ -135,15 +135,14 @@ def send_opal_command(self, command, properties=None, response=None, self._logger.warning("Did not get properties for a " + "MOVE_OBJECT command! Not sending empty command.") return - elif "HIGHLIGHT_OBJECT" in command: + elif "HIGHLIGHT" in command: msg.command = OpalCommand.HIGHLIGHT_OBJECT # Properties: a string with name of the object to highlight. if properties: msg.properties = properties else: self._logger.warning("Did not get properties for a " - + "HIGHLIGHT_OBJECT command! Not sending empty command.") - return + + "HIGHLIGHT_OBJECT command! Adding null properties.") elif "REQUEST_KEYFRAME" in command: msg.command = OpalCommand.REQUEST_KEYFRAME elif "FADE_SCREEN" in command: @@ -250,6 +249,8 @@ def send_game_state(self, state, performance=None): msg.state = GameState.PAUSED if "TIMEOUT" in state: msg.state = GameState.USER_TIMEOUT + if "READY" in state: + msg.state = GameState.READY if "END" in state: msg.state = GameState.END if performance is not None: diff --git a/src/ss_script_handler.py b/src/ss_script_handler.py index 18b2eea..de8665e 100644 --- a/src/ss_script_handler.py +++ b/src/ss_script_handler.py @@ -46,7 +46,7 @@ class ss_script_handler(): # Constants for script playback: # Time to pause after showing answer feedback and playing robot # feedback speech before moving on to the next question. - ANSWER_FEEDBACK_PAUSE_TIME = 3 + ANSWER_FEEDBACK_PAUSE_TIME = 2 # Time to wait for robot to finish speaking or acting before # moving on to the next script line (in seconds). WAIT_TIME = 30 @@ -92,14 +92,16 @@ def __init__(self, ros_node, session, participant, script_path, self._story_parser = None self._repeat_parser = None - # Get session script from script parser and story scripts from - # the personalization manager, and give to the script parser. + # Get session script from script parser and give to the script + # parser. Story scripts we will get later from the + # personalization manager. try: self._script_parser.load_script(self._script_path + self._session_script_path + self._script_parser.get_session_script(session)) except IOError: - self._logger.exception("Script parser could not open session script!") + self._logger.exception("Script parser could not open session " + + "script!") # Pass exception up so whoever wanted a script handler knows # they didn't get a script. raise @@ -197,9 +199,20 @@ def iterate_once(self): self._logger.info("No more script lines to get!") # Pass on the stop iteration exception, with additional # information about the player's performance during the - # game. - e.performance = self._personalization_man. \ - get_emotion_performance_this_session() + # game, formatted as a json object. + emotion, tom, order = self._personalization_man. \ + get_performance_this_session() + performance = {} + if emotion is not None: + performance["child-emotion-question-accuracy"] = \ + performance_emotion + if tom is not None: + performance["child-tom-question-accuracy"] = \ + performance_emotion + if order is not None: + performance["child-order-question-accuracy"] = \ + performance_emotion + e.performance = json.dumps(performance) raise except ValueError: @@ -244,7 +257,7 @@ def iterate_once(self): # Do different stuff depending on what the first element is. ######################################################### - # only STORY lines have only one part to the command. + # Some STORY lines have only one part to the command. elif len(elements) == 1: # For STORY lines, play back the next story for this # participant. @@ -253,7 +266,7 @@ def iterate_once(self): # If line indicates we need to start a story, do so. self._doing_story = True # Create a script parser for the filename provided, - # assume it is in the session_scripts directory. + # assuming it is in the story scripts directory. self._story_parser = ss_script_parser() try: self._story_parser.load_script(self._script_path @@ -277,6 +290,14 @@ def iterate_once(self): self._doing_story = False # Line has 2+ elements, so check the other commands. + ######################################################### + # For STORY SETUP lines, pick the next story to play so + # we can load its graphics and play back the story. + if "STORY" in elements[0] and "SETUP" in elements[1]: + self._logger.debug("STORY SETUP") + # Pick the next story to play. + self._personalization_man.pick_next_story() + ######################################################### # For ROBOT lines, send command to the robot. elif "ROBOT" in elements[0]: @@ -799,8 +820,8 @@ def _load_next_story(self): self._personalization_man.get_next_story_details() except NoStoryFound: # If no story was found, we can't load the story! - self._logger.exception("Cannot load story - no story to load was \ - found!") + self._logger.exception("Cannot load story - no story to load was" + + " found!") self._doing_story = False return diff --git a/src/ss_script_parser.py b/src/ss_script_parser.py index 4d62ea8..8c07127 100644 --- a/src/ss_script_parser.py +++ b/src/ss_script_parser.py @@ -35,18 +35,43 @@ def __init__(self): self._logger = logging.getLogger(__name__) self._logger.info("Setting up script parser...") + def get_session_script(self, session): """ Get scripts for the specified session """ - if session == -1: - # We will use the demo session script. - # TODO get script! + if not isinstance(session, int): + raise TypeError("session should be an integer") + + if session < -1: + raise ValueError("Session number out of range. Should be -1 to " + "play the demo or a positive integer to play a particular " + "session.") + + if session <= 0: + # We will use the demo session script if this is a demo + # session or if the session number doesn't make sense. return "demo.txt" + + # This isn't a demo session, so we need to select a script for + # the specified session. We only have specific scripts some + # sessions (e.g., to give extra instructions for the first time + # the user plays); the rest will use a generic session script. + elif session < 3: + self._logger.info("We assume session scripts are named with the " + + "pattern \"session-[session_number].txt\", where the " + + "session number is an integer starting at 1 for session 1. " + + "But if this is a later session, we will use a generic " + + "session script instead, which we expect to be called " + + "\"session-general.txt\". So for this session, we will load" + + " \"session-" + str(session) + ".txt\".") + return "session-" + str(session) + ".txt" else: - # This isn't a demo session, so we need to select a script - # for the specified session. - # TODO get script! - self._logger.info("TODO pick session script -- using DEMO script") - return "demo.txt" + self._logger.info("We assume session scripts are named with the " + + "pattern \"session-[session_number].txt\", where the " + + "session number is an integer starting at 1 for session 1. " + + "But this is a later session, so we will use a generic " + + "session script instead, which we expect to be called " + + "\"session-general.txt\".") + return "session-general.txt" def load_script(self, script): @@ -56,7 +81,7 @@ def load_script(self, script): self._fh = open(script, "r") except IOError as e: self._logger.exception("Cannot open script: " + str(script)) - #Ppass exception up so anyone trying to load a script + # Pass exception up so anyone trying to load a script # knows it didn't work. raise else: diff --git a/src/test_db_manager.py b/src/test_db_manager.py new file mode 100644 index 0000000..101c77a --- /dev/null +++ b/src/test_db_manager.py @@ -0,0 +1,323 @@ +# Jacqueline Kory Westlund +# September 2016 +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Personal Robots Group +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import unittest +import mock +import random +from mock import Mock +from ss_db_manager import ss_db_manager + +class test_db_manager(unittest.TestCase): + + def setUp(self): + self.dbm = ss_db_manager("ss_test_no_participant_data.db") + #self.dbm2 = ss_db_manager("ss_test_with_participant_data.db") + #TODO add second database with participant data + + + def test_get_most_recent_level(self): + # Test different participant and session values. + # If there is no participant data, we should always get None. + self.assertEqual(self.dbm.get_most_recent_level("P001", 1), None) + self.assertEqual(self.dbm.get_most_recent_level("P001", -5), None) + self.assertEqual(self.dbm.get_most_recent_level("P001", 0), None) + self.assertEqual(self.dbm.get_most_recent_level("P001", 7), None) + self.assertEqual(self.dbm.get_most_recent_level("P001", 0.7), None) + self.assertEqual(self.dbm.get_most_recent_level("aaaa", 2), None) + self.assertEqual(self.dbm.get_most_recent_level("0928u4ijos", 2), None) + self.assertEqual(self.dbm.get_most_recent_level("", 2), None) + self.assertEqual(self.dbm.get_most_recent_level("0xa7", 2), None) + + + def test_get_percent_correct_responses(self): + # If there is no participant data, we should always get None. + self.assertIsNone(self.dbm.get_percent_correct_responses("p234", 2)) + self.assertIsNone(self.dbm.get_percent_correct_responses("93", 1)) + self.assertIsNone(self.dbm.get_percent_correct_responses("93", 0.1)) + self.assertIsNone(self.dbm.get_percent_correct_responses("1", 0)) + self.assertIsNone(self.dbm.get_percent_correct_responses("", 0)) + self.assertIsNone(self.dbm.get_percent_correct_responses("p234", 2, + "order")) + self.assertIsNone(self.dbm.get_percent_correct_responses("p234", 2, + "emotion")) + self.assertIsNone(self.dbm.get_percent_correct_responses("p234", 2, + "ToM")) + + + def test_get_most_recent_incorrect_emotions(self): + # If there is no participant data, we should get an empty list. + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("p134", 4), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("1", 0), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("d81", -33), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("d81", -1), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("p134", 1), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("0xa7", 4), []) + self.assertEqual( + self.dbm.get_most_recent_incorrect_emotions("", 4), []) + + + def test_get_next_new_story(self): + # If there is no participant data, we should get the name of an + # unplayed story with the relevant emotions at that level. Some + # emotions are only present at higher levels, so for these, at + # lower levels, we expect to get whichever story is first in + # stories table. + # + # Note that these tests assume that all 42 SAR stories have + # been imported into the database, and that the ods sheets were + # imported in alphabetical order. A more general version of the + # tests here could merely assert that we get back a string, or + # could provide a list of all the story names, and assert that + # the string we get back is one of the story names. However, + # that would not test whether the story included the correct + # emotions for the story's level, since not all stories have + # the same emotions present at every level. + self.assertEqual(self.dbm.get_next_new_story("p391", ["angry"], 1), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("", ["angry"], 10), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("", ["angry"], 22), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("p391", ["sad"], 2), + "story-cr1") + self.assertEqual(self.dbm.get_next_new_story("33", ["sad"], 10), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("p391", ["happy"], 3), + "story-am1") + self.assertEqual(self.dbm.get_next_new_story("33", ["happy"], 8), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("3811", ["nervous"], + 8), "story-am2") + self.assertEqual(self.dbm.get_next_new_story("0x7a", ["nervous"], + 3), "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("", ["excited"], 9), + "story-fo2") + self.assertEqual(self.dbm.get_next_new_story("P001", ["excited"], + 4), "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("002", ["guilty"], 10), + "story-am1") + self.assertEqual(self.dbm.get_next_new_story("0.03a", ["guilty"], 1), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("0", ["surprised"], 7), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("P01", ["surprised"], + 6), "story-sr1") + self.assertEqual(self.dbm.get_next_new_story("0", ["afraid"], 5), + "story-fo2") + self.assertEqual(self.dbm.get_next_new_story("P01", ["afraid"], 10), + "story-fo2") + self.assertEqual(self.dbm.get_next_new_story("0", ["frustrated"], + 8), "story-cr1") + self.assertEqual(self.dbm.get_next_new_story("P01", ["frustrated"], + 3), "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("0", ["calm"], 10), + "story-st1") + self.assertEqual(self.dbm.get_next_new_story("P01", ["calm"], 1), + "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("0", ["bored"], 8), + "story-sr2") + self.assertEqual(self.dbm.get_next_new_story("P01", ["bored"], 3), + "story-fo1") + + self.assertEqual(self.dbm.get_next_new_story("0", ["frustrated", + "bored", "happy"], 8), "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("0", ["frustrated", + "surprised", "sad"], 8), "story-fo1") + self.assertEqual(self.dbm.get_next_new_story("p391", ["happy", + "frustrated", "afraid", "sad"], 1), "story-fo2") + self.assertEqual(self.dbm.get_next_new_story("p391", [""], 1), + "story-fo1") + + + def test_get_next_review_story(self): + # If there is no participant data, there will be no stories to + # review, since none have been recorded as played yet. + self.assertIsNone(self.dbm.get_next_review_story("p391", 1, ["angry"], + 1)) + self.assertIsNone(self.dbm.get_next_review_story("", 2, ["angry"], 2)) + self.assertIsNone(self.dbm.get_next_review_story("", 2, ["angry"], 22)) + self.assertIsNone(self.dbm.get_next_review_story("p391", 1, ["sad"], + 2)) + self.assertIsNone(self.dbm.get_next_review_story("33", 10, ["sad"], + 10)) + self.assertIsNone(self.dbm.get_next_review_story("p391", 1, ["happy"], + 3)) + self.assertIsNone(self.dbm.get_next_review_story("33", 2, ["happy"], + 8)) + self.assertIsNone(self.dbm.get_next_review_story("3811", 13, + ["nervous"], 8)) + self.assertIsNone(self.dbm.get_next_review_story("0x7a", -0.1, + ["nervous"], 3)) + self.assertIsNone(self.dbm.get_next_review_story("", 55, ["excited"], + 9)) + self.assertIsNone(self.dbm.get_next_review_story("P001", -22, + ["excited"], 4)) + self.assertIsNone(self.dbm.get_next_review_story("002", 0, ["guilty"], + 10)) + self.assertIsNone(self.dbm.get_next_review_story("0.03a", 9, + ["guilty"], 1)) + self.assertIsNone(self.dbm.get_next_review_story("0", 8, ["surprised"], + 7)) + self.assertIsNone(self.dbm.get_next_review_story("P01", 2, + ["surprised"], 6)) + self.assertIsNone(self.dbm.get_next_review_story("0", 5, ["afraid"], + 5)) + self.assertIsNone(self.dbm.get_next_review_story("P01", 2, ["afraid"], + 10)) + self.assertIsNone(self.dbm.get_next_review_story("0", 9, + ["frustrated"], 8)) + self.assertIsNone(self.dbm.get_next_review_story("P01", 2, + ["frustrated"], 3)) + self.assertIsNone(self.dbm.get_next_review_story("0", 5, ["calm"], + 10)) + self.assertIsNone(self.dbm.get_next_review_story("P01", 2, ["calm"], + 1)) + self.assertIsNone(self.dbm.get_next_review_story("0", 5, ["bored"], 8)) + self.assertIsNone(self.dbm.get_next_review_story("P01", 2, ["bored"], + 3)) + + self.assertIsNone(self.dbm.get_next_review_story("0", 9, ["frustrated", + "bored", "happy"], 8)) + self.assertIsNone(self.dbm.get_next_review_story("0", 9, ["frustrated", + "surprised", "sad"], 8)) + self.assertIsNone(self.dbm.get_next_review_story("p391", 1, ["happy", + "frustrated", "afraid", "sad"], 1)) + self.assertIsNone(self.dbm.get_next_review_story("p391", 2, [""], 1)) + + + def test_get_level_info(self): + # If a level doesn't exist, we should get None. + self.assertIsNone(self.dbm.get_level_info(0)) + self.assertIsNone(self.dbm.get_level_info(0.04)) + self.assertIsNone(self.dbm.get_level_info(14)) + self.assertIsNone(self.dbm.get_level_info(-5)) + self.assertIsNone(self.dbm.get_level_info(50333330)) + + # If a level does exist, we get its number of answers and if it + # should be shown in order. + ans, order = self.dbm.get_level_info(1) + self.assertEqual(ans, 3) + self.assertEqual(order, 1) + + ans, order = self.dbm.get_level_info(2) + self.assertEqual(ans, 3) + self.assertEqual(order, 1) + + ans, order = self.dbm.get_level_info(5) + self.assertEqual(ans, 3) + self.assertEqual(order, 0) + + ans, order = self.dbm.get_level_info(10) + self.assertEqual(ans, 5) + self.assertEqual(order, 0) + + ans, order = self.dbm.get_level_info(7) + self.assertEqual(ans, 4) + self.assertEqual(order, 0) + + + def test_get_graphics(self): + # If the story requested or the level requested doesn't exist, + # we should get None. + self.assertIsNone(self.dbm.get_graphics("-3-139uadfbio", 1)) + self.assertIsNone(self.dbm.get_graphics("dioufad-story", 4)) + self.assertIsNone(self.dbm.get_graphics("story-bb3", 9)) + self.assertIsNone(self.dbm.get_graphics("STORY-FO1", 10)) + self.assertIsNone(self.dbm.get_graphics("", 10)) + self.assertIsNone(self.dbm.get_graphics("story-fo1", 11)) + self.assertIsNone(self.dbm.get_graphics("story-cl2", -5)) + self.assertIsNone(self.dbm.get_graphics("story-fo3", 50)) + self.assertIsNone(self.dbm.get_graphics("story-sp2", 0)) + + # If a story exists at the level, we get a list of graphics + # file names. Note that these tests assume that all 42 SAR + # stories have been imported into the database. + self.assertIsInstance(self.dbm.get_graphics("story-fo1", 1), list) + self.assertIsInstance(self.dbm.get_graphics("story-sr2", 10), list) + self.assertIsInstance(self.dbm.get_graphics("story-fo2", 3), list) + self.assertIsInstance(self.dbm.get_graphics("story-ki2", 4), list) + + self.assertListEqual(self.dbm.get_graphics("story-fo1", 1), + ["FO1-a-p.png"]) + self.assertListEqual(self.dbm.get_graphics("story-fo1", 2), + ["FO1-a-p.png", "FO1-b-p.png"]) + self.assertListEqual(self.dbm.get_graphics("story-fo1", 8), + ["FO1-a-b.png", "FO1-b-b.png", + "FO1-c-b.png", "FO1-d-b.png"]) + + + def test_record_story_played(self): + # Add a record to the stories_played table. + # args: participant, session, level, story + #self.dbm.record_story_played("P001", 1, 1, "story-fo1") + # Check that it was inserted correctly. + # Reset database: remove all the data we added. + pass + + + def test_record_response(self): + # Add a record to the responses table. + # args: participant, session, level, story, q_num, q_type, response + #self.dbm.record_response("P001", 1, 1, "story-fo1", 1, "emotion", + #"happy") + # Check that it was inserted correctly. + # Reset database: remove all the data we added. + pass + + + def test_exceptions(self): + # For each function, test that we handle exceptions properly. + m = Mock() + self.dbm._cursor = m + m.execute.side_effect = Exception + + with self.assertRaises(Exception): + self.dbm.get_most_recent_level("p023", 1) + + with self.assertRaises(Exception): + self.dbm.get_percent_correct_responses("p023", 1) + with self.assertRaises(Exception): + self.dbm.get_percent_correct_responses("p023", 1, "emotion") + + with self.assertRaises(Exception): + self.dbm.get_most_recent_incorrect_emotions("p023", 1) + + with self.assertRaises(Exception): + self.dbm.get_next_new_story("p391", ["happy", "frustrated"], 1) + + with self.assertRaises(Exception): + self.dbm.get_next_review_story("40", 5, ["bored"], 8) + + with self.assertRaises(Exception): + self.dbm.get_level_info(2) + + with self.assertRaises(Exception): + self.dbm.get_graphics("story-ki2", 4) diff --git a/src/test_game_node.py b/src/test_game_node.py new file mode 100644 index 0000000..1bdd686 --- /dev/null +++ b/src/test_game_node.py @@ -0,0 +1,65 @@ +# Jacqueline Kory Westlund +# September 2016 +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Personal Robots Group +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import unittest +import mock +import random +from ss_game_node import ss_game_node + +class test_game_node(unittest.TestCase): + + #def setUp(self): + #self.gn = ss_game_node() + + #@mock.patch('argparse.ArgumentParser', autospec=True) + #def test_parse_arguments(self, mock_args): + #mock_args.return_value.parse_args.side_effect = [ + #{"session": 1, "participant": "test"}, + #{"session": -1, "participant": "test"}, + #{"session": -4, "participant": "test"}, + #{"session": 0.4, "participant": "test"} + #] + + #(session, participant) = self.gn.parse_arguments() + #self.assertEqual(session, 1) + #self.assertEqual(participant, 'test') + + #(session, participant) = self.gn.parse_arguments() + #self.assertEqual(session, -1) + #self.assertEqual(participant, "DEMO") + + #with self.assertRaises(ValueError): + #self.gn.parse_arguments() + + #with self.assertRaises(ValueError): + #self.gn.parse_arguments() + + + def test_launch_game(self): + pass + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/test_personalization_manager.py b/src/test_personalization_manager.py new file mode 100644 index 0000000..08c9756 --- /dev/null +++ b/src/test_personalization_manager.py @@ -0,0 +1,292 @@ +# Jacqueline Kory Westlund +# September 2016 +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Personal Robots Group +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import unittest +import mock +import random +from mock import Mock, patch +from ss_personalization_manager import ss_personalization_manager +from SS_Errors import NoStoryFound + +class test_personalization_manager(unittest.TestCase): + # TODO test for different participants, sessions, including DEMO! + + def setup_demo(self): + # Set up to test a demo session, with no participant data in + # the database yet. + #args: session, participant, database, percent_correct_to_level + self.pm = ss_personalization_manager(-1, "DEMO", + "ss_test_no_participant_data.db", 0.75) + + # Mock the database manager for a participant who has no data + # in the database yet. + m = Mock() + self.pm._db_man = m + m.get_most_recent_level.return_value = None + m.get_percent_correct_responses.return_value = None + m.get_most_recent_incorrect_emotions.return_value = [] + m.get_next_new_story.return_value = "story-fo1" + m.get_next_review_story.return_value = None + m.get_level_info.return_value = (3, 1) + m.get_graphics.return_value = ["FO1-a-p.png"] + + + def setup_no_participant_data(self, participant, session): + # Set up test for a participant on their first session, with no + # participant data in the database yet. + + #args: session, participant, database, percent_correct_to_level + self.pm = ss_personalization_manager(session, participant, + "ss_test_no_participant_data.db", 0.75) + + # Mock the database manager for a participant who has no data + # in the database yet. + m = Mock() + self.pm._db_man = m + m.get_most_recent_level.return_value = None + m.get_percent_correct_responses.return_value = None + m.get_most_recent_incorrect_emotions.return_value = [] + m.get_next_new_story.return_value = "story-fo1" + m.get_next_review_story.return_value = None + m.get_level_info.return_value = (3, 1) + m.get_graphics.return_value = ["FO1-a-p.png"] + return m + + + def test_get_level_for_session(self): + # Test demo session. + self.setup_demo() + self.assertEqual(self.pm.get_level_for_session(), 1) + + # Test a participant with no data on their first session. + dbm = self.setup_no_participant_data("P001", 1) + + # Mock past play and performance data so we can test different + # values. + + # Last level = exception + #dbm.get_most_recent_level.return_value = Exception + #with self.assertRaises(Exception): + #self.pm.get_level_for_session() + # TODO add exception handling to get_level_for_session? + + # Last level = None (never played before) + dbm.get_most_recent_level.return_value = None + self.assertEqual(self.pm.get_level_for_session(), 1) + + # Last level played was... + last_level = [1,4,10] + # Percent questions correct were... None = no questions + # answered, 1 = all questions correct, 0.75 = 75% correct, etc. + percent_questions_correct = [None, 1, 0.75, 0.74, -0.03] + # Then we expect to play at level... + expected_level = [ + (1, 2, 2, 1, 1), + (4, 5, 5, 4, 4), + (10, 10, 10, 10, 10) + ] + + # Test at each level. + for i in range(0, len(last_level)): + dbm.get_most_recent_level.return_value = last_level[i] + + # Test for each percentage of questions correct. + for j in range(0, len(percent_questions_correct)): + dbm.get_percent_correct_responses.return_value = \ + percent_questions_correct[j] + self.assertEqual(self.pm.get_level_for_session(), + expected_level[i][j]) + + + def test_get_performance_this_session(self): + # Test demo session. + self.setup_demo() + self.assertEqual(self.pm.get_performance_this_session(), None) + + # Test a participant with no data on their first session. + dbm = self.setup_no_participant_data("P001", 1) + + # Mock past play and performance data so we can test different + # values. Returns (emotion, ToM, order) performance. + performance_data = [ + # No questions answered. + (None, None, None), + # All correct. + (1, 1, 1), + # All incorrect. + (0, 0, 0), + # Some correct. + (0.5, 0.75, 0.5), + # Didn't answer some, answered others. + (None, 0, 0), + (0, None, 0), + (0, 0, None), + (1, None, None), + (None, None, 1), + (None, 1, None), + (0.4, -1, 5), + ] + + for pd in performance_data: + dbm.get_percent_correct_responses.side_effect = [ pd[0], pd[1], + pd[2] ] + self.assertEqual(self.pm.get_performance_this_session(), pd) + + + @patch("ss_personalization_manager.ss_personalization_manager." + + "pick_next_story") + def test_get_next_story_script(self, mock): + # The get_next_story_script function is also tested in the next test, + # when testing the pick_next_story function. + # Test a participant with no data on their first session. + dbm = self.setup_no_participant_data("P001", 1) + mock.return_value = "story-cr1" + self.assertEqual(self.pm.get_next_story_script(), "story-cr1-1.txt") + self.assertTrue(mock.called) + + + def test_pick_next_story(self): + # Test demo session. + self.setup_demo() + self.assertEqual(self.pm.pick_next_story(), "demo-story-1") + self.assertEqual(self.pm._current_story, "demo-story-1") + self.assertEqual(self.pm.get_next_story_script(), "demo-story-1.txt") + + # Test a participant with no data on their first session. + dbm = self.setup_no_participant_data("P001", 1) + + # Tell new story starts out True, toggles after each call to + # pick_new_story. + self.assertTrue(self.pm._tell_new_story) + + # Mock relevant story data. + # Need new story, no new or review story found. + dbm.get_next_new_story.side_effect = [ None, None ] + dbm.get_next_review_story.side_effect = [ None ] + with self.assertRaises(NoStoryFound): + self.pm.pick_next_story() + + # Need new story, no new story found. + dbm.get_next_new_story.side_effect = [ None, None ] + dbm.get_next_review_story.side_effect = [ "story-cr1" ] + self.assertEqual(self.pm.pick_next_story(), "story-cr1") + self.assertEqual(self.pm._current_story, "story-cr1") + self.assertEqual(self.pm.get_next_story_script(), "story-cr1-1.txt") + + # Tell new story flag should toggle. + self.assertFalse(self.pm._tell_new_story) + + # Need review story, review story found on 1st try. + dbm.get_next_review_story.side_effect = [ "story-cf1" ] + self.assertEqual(self.pm.pick_next_story(), "story-cf1") + self.assertEqual(self.pm._current_story, "story-cf1") + self.assertEqual(self.pm.get_next_story_script(), "story-cf1-1.txt") + + # Tell new story flag should toggle. + self.assertTrue(self.pm._tell_new_story) + + # Need new story, new story found on 1st try. + dbm.get_next_new_story.side_effect = [ "story-cr1", None ] + dbm.get_next_review_story.side_effect = [ None ] + self.assertEqual(self.pm.pick_next_story(), "story-cr1") + self.assertEqual(self.pm._current_story, "story-cr1") + self.assertEqual(self.pm.get_next_story_script(), "story-cr1-1.txt") + + # Tell new story flag should toggle. + self.assertFalse(self.pm._tell_new_story) + + # Need review story, no review or new story found. + dbm.get_next_new_story.side_effect = [ None ] + dbm.get_next_review_story.side_effect = [ None ] + with self.assertRaises(NoStoryFound): + self.pm.pick_next_story() + + # Need review story, no review story found, get new story. + dbm.get_next_new_story.side_effect = [ "story-cf1" ] + dbm.get_next_review_story.side_effect = [ None ] + self.assertEqual(self.pm.pick_next_story(), "story-cf1") + self.assertEqual(self.pm._current_story, "story-cf1") + self.assertEqual(self.pm.get_next_story_script(), "story-cf1-1.txt") + + # Tell new story flag should toggle. + self.assertTrue(self.pm._tell_new_story) + + # Need new story, new story found on 2nd try. + dbm.get_next_new_story.side_effect = [ None, "story-cr1" ] + dbm.get_next_review_story.side_effect = [ None ] + self.assertEqual(self.pm.pick_next_story(), "story-cr1") + self.assertEqual(self.pm._current_story, "story-cr1") + self.assertEqual(self.pm.get_next_story_script(), "story-cr1-1.txt") + + # Tell new story flag should toggle. + self.assertFalse(self.pm._tell_new_story) + + + @patch("ss_personalization_manager.ss_personalization_manager." + + "pick_next_story") + def test_get_next_story_details(self, mock): + # Test demo session. + self.setup_demo() + self.assertEqual(self.pm.get_next_story_details(), + (["scenes/CR1-a-b.png", "scenes/CR1-b-b.png", "scenes/CR1-c-b.png", + "scenes/CR1-d-b.png"], True, 4)) + + # Test a participant with no data on their first session. + dbm = self.setup_no_participant_data("P001", 1) + + # Mock relevant story data and personalization. + # The current story starts out set to None, so get_next_story_details + # will call pick_next_story. + mock.return_value = "story-cr1" + dbm.get_graphics.return_value = ["scenes/CR1-a-p.png"] + dbm.get_level_info.return_value = (1, True) + self.assertEqual(self.pm.get_next_story_details(), + (["scenes/CR1-a-p.png"], True, 1)) + self.assertTrue(mock.called) + + # If we start with a current story set, the mock will not be called. + mock.reset_mock() + self.assertEqual(self.pm.get_next_story_details(), + (["scenes/CR1-a-p.png"], True, 1)) + self.assertFalse(mock.called) + + + def test_record_story_loaded(self): + pass + + + def test_record_user_response(self): + pass + + + def test_set_start_level(self): + pass + + + def test_get_joint_attention_level(self): + pass + + + diff --git a/src/test_script_parser.py b/src/test_script_parser.py new file mode 100755 index 0000000..705ad3c --- /dev/null +++ b/src/test_script_parser.py @@ -0,0 +1,102 @@ +# Jacqueline Kory Westlund +# September 2016 +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Personal Robots Group +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import unittest +import mock +import random +from mock import Mock +from ss_script_parser import ss_script_parser + +class test_script_parser(unittest.TestCase): + + def setUp(self): + self.sp = ss_script_parser() + + + def test_get_session_script(self): + # Test different session values. + self.assertEqual(self.sp.get_session_script(-1), "demo.txt") + self.assertEqual(self.sp.get_session_script(1), "session-1.txt") + self.assertEqual(self.sp.get_session_script(2), "session-2.txt") + self.assertEqual(self.sp.get_session_script(3), "session-general.txt") + self.assertEqual(self.sp.get_session_script(333), "session-general.txt") + # Test invalid session values. + with self.assertRaises(TypeError): + self.sp.get_session_script("hi") + with self.assertRaises(TypeError): + self.sp.get_session_script() + with self.assertRaises(TypeError): + self.sp.get_session_script(3.14) + with self.assertRaises(TypeError): + self.sp.get_session_script(0.5) + with self.assertRaises(ValueError): + self.sp.get_session_script(-5) + + + @mock.patch("__builtin__.open", create=True, autospec=True) + def test_load_script(self, mock_open): + value = random.randint(-1000,1000) + mock_open.side_effect = [ IOError, value ] + + # Get IOError when we try to load a non-existent script. + with self.assertRaises(IOError): + self.sp.load_script("demo.txt") + + # Get file handle when we try to load an existent script. + self.sp.load_script("demo.txt") + self.assertEqual(self.sp._fh, value) + + + def test_next_line(self): + # Set up mock iterator for the file handle that returns lines + # from the file. + value = str(random.randint(-1000,1000)) + m = Mock() + self.sp._fh = m + m.next.side_effect = [ + value, + AttributeError, + ValueError, + StopIteration ] + + # A line is returned. + self.assertEqual(self.sp.next_line(), value) + + # Check each error type: + # No script file loaded. + with self.assertRaises(AttributeError): + self.sp.next_line() + + # Script file closed. + with self.assertRaises(ValueError): + self.sp.next_line() + + # End of script file. + with self.assertRaises(StopIteration): + self.sp.next_line() + + +if __name__ == '__main__': + unittest.main(verbosity=2)