Not quite. Try again (or maybe use a search engine).
"
+ content_id: "default_outcome"
+ }
+ }
+ customization_args {
+ key: "rows"
+ value {
+ signed_int: 1
+ }
+ }
+ customization_args {
+ key: "placeholder"
+ value {
+ custom_schema_value {
+ subtitled_html {
+ html: "Enter a language"
+ content_id: "ca_placeholder_0"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+states {
+ key: "End"
+ value {
+ name: "End"
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ content {
+ html: "Congratulations, you have finished!"
+ content_id: "content"
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ interaction {
+ id: "EndExploration"
+ customization_args {
+ key: "recommendedExplorationIds"
+ value {
+ schema_object_list {
+ }
+ }
+ }
+ }
+ }
+}
+init_state_name: "Text"
+objective: "Test exploration."
+title: "Prototype exploration without hints or a solution"
+language_code: "en"
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.json b/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.json
new file mode 100644
index 00000000000..323d604f3da
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.json
@@ -0,0 +1,127 @@
+{
+ "exploration_id": "test_single_interactive_state_exp_with_hints_and_solution",
+ "version": 1,
+ "exploration": {
+ "init_state_name": "Text",
+ "states": {
+ "Text": {
+ "content": {
+ "content_id": "content",
+ "html": "",
+ "normalizedStrSet": ["finnish"]
+ }
+ }
+ }],
+ "outcome": {
+ "dest": "End",
+ "feedback": {
+ "content_id": "feedback_1",
+ "html": "Correct!
"
+ },
+ "labelled_as_correct": false
+ }
+ }],
+ "default_outcome": {
+ "dest": "Text",
+ "feedback": {
+ "content_id": "default_outcome",
+ "html": "Not quite. Try again (or maybe use a search engine).
"
+ },
+ "labelled_as_correct": false
+ },
+ "hints": [{
+ "hint_content": {
+ "content_id": "hint_1",
+ "html": "Hint: 'Oppia' is not English.
"
+ }
+ }, {
+ "hint_content": {
+ "content_id": "hint_2",
+ "html": "Try looking for nordic countries in Europe.
"
+ }
+ }],
+ "solution": {
+ "answer_is_exclusive": false,
+ "correct_answer": "Finnish",
+ "explanation": {
+ "content_id": "solution",
+ "html": "'Oppia' is translated from Finnish.
"
+ }
+ }
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {},
+ "hint_2": {},
+ "solution": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {},
+ "hint_2": {},
+ "solution": {}
+ }
+ }
+ },
+ "End": {
+ "content": {
+ "content_id": "content",
+ "html": "Congratulations, you have finished!"
+ },
+ "param_changes": [],
+ "interaction": {
+ "id": "EndExploration",
+ "customization_args": {
+ "recommendedExplorationIds": {
+ "value": []
+ }
+ },
+ "answer_groups": [],
+ "default_outcome": null,
+ "hints": [],
+ "solution": null
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "content": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "content": {}
+ }
+ }
+ }
+ },
+ "objective": "Test exploration.",
+ "language_code": "en",
+ "title": "Prototype exploration with multiple hints and a solution"
+ }
+}
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.textproto b/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.textproto
new file mode 100644
index 00000000000..b1d36f1d760
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_hints_and_solution.textproto
@@ -0,0 +1,175 @@
+id: "test_single_interactive_state_exp_with_hints_and_solution"
+states {
+ key: "Text"
+ value {
+ name: "Text"
+ recorded_voiceovers {
+ key: "feedback_1"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "default_outcome"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "solution"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "hint_1"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "hint_2"
+ value {
+ }
+ }
+ content {
+ html: "In which language does Oppia mean \'to learn\'?
"
+ content_id: "content"
+ }
+ written_translations {
+ key: "feedback_1"
+ value {
+ }
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ written_translations {
+ key: "default_outcome"
+ value {
+ }
+ }
+ written_translations {
+ key: "solution"
+ value {
+ }
+ }
+ written_translations {
+ key: "hint_1"
+ value {
+ }
+ }
+ written_translations {
+ key: "hint_2"
+ value {
+ }
+ }
+ interaction {
+ id: "TextInput"
+ answer_groups {
+ outcome {
+ dest_state_name: "End"
+ feedback {
+ html: "Correct!
"
+ content_id: "feedback_1"
+ }
+ }
+ rule_specs {
+ input {
+ key: "x"
+ value {
+ translatable_set_of_normalized_string {
+ content_id: ""
+ normalized_strings: "finnish"
+ }
+ }
+ }
+ rule_type: "Equals"
+ }
+ }
+ solution {
+ interaction_id: "TextInput"
+ correct_answer {
+ correct_answer: "Finnish"
+ }
+ explanation {
+ html: "'Oppia' is translated from Finnish.
"
+ content_id: "solution"
+ }
+ }
+ hint {
+ hint_content {
+ html: "Hint: 'Oppia' is not English.
"
+ content_id: "hint_1"
+ }
+ }
+ hint {
+ hint_content {
+ html: "Try looking for nordic countries in Europe.
"
+ content_id: "hint_2"
+ }
+ }
+ default_outcome {
+ dest_state_name: "Text"
+ feedback {
+ html: "Not quite. Try again (or maybe use a search engine).
"
+ content_id: "default_outcome"
+ }
+ }
+ customization_args {
+ key: "rows"
+ value {
+ signed_int: 1
+ }
+ }
+ customization_args {
+ key: "placeholder"
+ value {
+ custom_schema_value {
+ subtitled_html {
+ html: "Enter a language"
+ content_id: "ca_placeholder_0"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+states {
+ key: "End"
+ value {
+ name: "End"
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ content {
+ html: "Congratulations, you have finished!"
+ content_id: "content"
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ interaction {
+ id: "EndExploration"
+ customization_args {
+ key: "recommendedExplorationIds"
+ value {
+ schema_object_list {
+ }
+ }
+ }
+ }
+ }
+}
+init_state_name: "Text"
+objective: "Test exploration."
+title: "Prototype exploration with multiple hints and a solution"
+language_code: "en"
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.json b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.json
new file mode 100644
index 00000000000..68cb3565371
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.json
@@ -0,0 +1,111 @@
+{
+ "exploration_id": "test_single_interactive_state_exp_with_one_hint_and_no_solution",
+ "version": 1,
+ "exploration": {
+ "init_state_name": "Text",
+ "states": {
+ "Text": {
+ "content": {
+ "content_id": "content",
+ "html": "In which language does Oppia mean 'to learn'?
"
+ },
+ "interaction": {
+ "id": "TextInput",
+ "customization_args": {
+ "rows": {
+ "value": 1.0
+ },
+ "placeholder": {
+ "value": {
+ "content_id": "ca_placeholder_0",
+ "unicode_str": "Enter a language"
+ }
+ }
+ },
+ "answer_groups": [{
+ "rule_specs": [{
+ "rule_type": "Equals",
+ "inputs": {
+ "x": {
+ "contentId": "",
+ "normalizedStrSet": ["finnish"]
+ }
+ }
+ }],
+ "outcome": {
+ "dest": "End",
+ "feedback": {
+ "content_id": "feedback_1",
+ "html": "Correct!
"
+ },
+ "labelled_as_correct": false
+ }
+ }],
+ "default_outcome": {
+ "dest": "Text",
+ "feedback": {
+ "content_id": "default_outcome",
+ "html": "Not quite. Try again (or maybe use a search engine).
"
+ },
+ "labelled_as_correct": false
+ },
+ "hints": [{
+ "hint_content": {
+ "content_id": "hint_1",
+ "html": "Hint: 'Oppia' is not English.
"
+ }
+ }],
+ "solution": null
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {}
+ }
+ }
+ },
+ "End": {
+ "content": {
+ "content_id": "content",
+ "html": "Congratulations, you have finished!"
+ },
+ "param_changes": [],
+ "interaction": {
+ "id": "EndExploration",
+ "customization_args": {
+ "recommendedExplorationIds": {
+ "value": []
+ }
+ },
+ "answer_groups": [],
+ "default_outcome": null,
+ "hints": [],
+ "solution": null
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "content": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "content": {}
+ }
+ }
+ }
+ },
+ "objective": "Test exploration.",
+ "language_code": "en",
+ "title": "Prototype exploration with one hint and no solution"
+ }
+}
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.textproto b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.textproto
new file mode 100644
index 00000000000..b00a23ff8aa
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_no_solution.textproto
@@ -0,0 +1,139 @@
+id: "test_single_interactive_state_exp_with_one_hint_and_no_solution"
+states {
+ key: "Text"
+ value {
+ name: "Text"
+ recorded_voiceovers {
+ key: "feedback_1"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "default_outcome"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "hint_1"
+ value {
+ }
+ }
+ content {
+ html: "In which language does Oppia mean \'to learn\'?
"
+ content_id: "content"
+ }
+ written_translations {
+ key: "feedback_1"
+ value {
+ }
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ written_translations {
+ key: "default_outcome"
+ value {
+ }
+ }
+ written_translations {
+ key: "hint_1"
+ value {
+ }
+ }
+ interaction {
+ id: "TextInput"
+ answer_groups {
+ outcome {
+ dest_state_name: "End"
+ feedback {
+ html: "Correct!
"
+ content_id: "feedback_1"
+ }
+ }
+ rule_specs {
+ input {
+ key: "x"
+ value {
+ translatable_set_of_normalized_string {
+ content_id: ""
+ normalized_strings: "finnish"
+ }
+ }
+ }
+ rule_type: "Equals"
+ }
+ }
+ hint {
+ hint_content {
+ html: "Hint: 'Oppia' is not English.
"
+ content_id: "hint_1"
+ }
+ }
+ default_outcome {
+ dest_state_name: "Text"
+ feedback {
+ html: "Not quite. Try again (or maybe use a search engine).
"
+ content_id: "default_outcome"
+ }
+ }
+ customization_args {
+ key: "rows"
+ value {
+ signed_int: 1
+ }
+ }
+ customization_args {
+ key: "placeholder"
+ value {
+ custom_schema_value {
+ subtitled_html {
+ html: "Enter a language"
+ content_id: "ca_placeholder_0"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+states {
+ key: "End"
+ value {
+ name: "End"
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ content {
+ html: "Congratulations, you have finished!"
+ content_id: "content"
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ interaction {
+ id: "EndExploration"
+ customization_args {
+ key: "recommendedExplorationIds"
+ value {
+ schema_object_list {
+ }
+ }
+ }
+ }
+ }
+}
+init_state_name: "Text"
+objective: "Test exploration."
+title: "Prototype exploration with one hint and no solution"
+language_code: "en"
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.json b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.json
new file mode 100644
index 00000000000..9ef0a1c1a37
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.json
@@ -0,0 +1,120 @@
+{
+ "exploration_id": "test_single_interactive_state_exp_with_one_hint_and_solution",
+ "version": 1,
+ "exploration": {
+ "init_state_name": "Text",
+ "states": {
+ "Text": {
+ "content": {
+ "content_id": "content",
+ "html": "In which language does Oppia mean 'to learn'?
"
+ },
+ "interaction": {
+ "id": "TextInput",
+ "customization_args": {
+ "rows": {
+ "value": 1.0
+ },
+ "placeholder": {
+ "value": {
+ "content_id": "ca_placeholder_0",
+ "unicode_str": "Enter a language"
+ }
+ }
+ },
+ "answer_groups": [{
+ "rule_specs": [{
+ "rule_type": "Equals",
+ "inputs": {
+ "x": {
+ "contentId": "",
+ "normalizedStrSet": ["finnish"]
+ }
+ }
+ }],
+ "outcome": {
+ "dest": "End",
+ "feedback": {
+ "content_id": "feedback_1",
+ "html": "Correct!
"
+ },
+ "labelled_as_correct": false
+ }
+ }],
+ "default_outcome": {
+ "dest": "Text",
+ "feedback": {
+ "content_id": "default_outcome",
+ "html": "Not quite. Try again (or maybe use a search engine).
"
+ },
+ "labelled_as_correct": false
+ },
+ "hints": [{
+ "hint_content": {
+ "content_id": "hint_1",
+ "html": "Hint: 'Oppia' is not English.
"
+ }
+ }],
+ "solution": {
+ "answer_is_exclusive": false,
+ "correct_answer": "Finnish",
+ "explanation": {
+ "content_id": "solution",
+ "html": "'Oppia' is translated from Finnish.
"
+ }
+ }
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {},
+ "solution": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "hint_1": {},
+ "solution": {}
+ }
+ }
+ },
+ "End": {
+ "content": {
+ "content_id": "content",
+ "html": "Congratulations, you have finished!"
+ },
+ "param_changes": [],
+ "interaction": {
+ "id": "EndExploration",
+ "customization_args": {
+ "recommendedExplorationIds": {
+ "value": []
+ }
+ },
+ "answer_groups": [],
+ "default_outcome": null,
+ "hints": [],
+ "solution": null
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "content": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "content": {}
+ }
+ }
+ }
+ },
+ "objective": "Test exploration.",
+ "language_code": "en",
+ "title": "Prototype exploration with one hint and a solution"
+ }
+}
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.textproto b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.textproto
new file mode 100644
index 00000000000..8cb51023b23
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_one_hint_and_solution.textproto
@@ -0,0 +1,159 @@
+id: "test_single_interactive_state_exp_with_one_hint_and_solution"
+states {
+ key: "Text"
+ value {
+ name: "Text"
+ recorded_voiceovers {
+ key: "feedback_1"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "default_outcome"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "solution"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "hint_1"
+ value {
+ }
+ }
+ content {
+ html: "In which language does Oppia mean \'to learn\'?
"
+ content_id: "content"
+ }
+ written_translations {
+ key: "feedback_1"
+ value {
+ }
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ written_translations {
+ key: "default_outcome"
+ value {
+ }
+ }
+ written_translations {
+ key: "solution"
+ value {
+ }
+ }
+ written_translations {
+ key: "hint_1"
+ value {
+ }
+ }
+ interaction {
+ id: "TextInput"
+ answer_groups {
+ outcome {
+ dest_state_name: "End"
+ feedback {
+ html: "Correct!
"
+ content_id: "feedback_1"
+ }
+ }
+ rule_specs {
+ input {
+ key: "x"
+ value {
+ translatable_set_of_normalized_string {
+ content_id: ""
+ normalized_strings: "finnish"
+ }
+ }
+ }
+ rule_type: "Equals"
+ }
+ }
+ solution {
+ interaction_id: "TextInput"
+ correct_answer {
+ correct_answer: "Finnish"
+ }
+ explanation {
+ html: "'Oppia' is translated from Finnish.
"
+ content_id: "solution"
+ }
+ }
+ hint {
+ hint_content {
+ html: "Hint: 'Oppia' is not English.
"
+ content_id: "hint_1"
+ }
+ }
+ default_outcome {
+ dest_state_name: "Text"
+ feedback {
+ html: "Not quite. Try again (or maybe use a search engine).
"
+ content_id: "default_outcome"
+ }
+ }
+ customization_args {
+ key: "rows"
+ value {
+ signed_int: 1
+ }
+ }
+ customization_args {
+ key: "placeholder"
+ value {
+ custom_schema_value {
+ subtitled_html {
+ html: "Enter a language"
+ content_id: "ca_placeholder_0"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+states {
+ key: "End"
+ value {
+ name: "End"
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ content {
+ html: "Congratulations, you have finished!"
+ content_id: "content"
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ interaction {
+ id: "EndExploration"
+ customization_args {
+ key: "recommendedExplorationIds"
+ value {
+ schema_object_list {
+ }
+ }
+ }
+ }
+ }
+}
+init_state_name: "Text"
+objective: "Test exploration."
+title: "Prototype exploration with one hint and a solution"
+language_code: "en"
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.json b/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.json
new file mode 100644
index 00000000000..e5aa182f58a
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.json
@@ -0,0 +1,113 @@
+{
+ "exploration_id": "test_single_interactive_state_exp_with_only_solution",
+ "version": 1,
+ "exploration": {
+ "init_state_name": "Text",
+ "states": {
+ "Text": {
+ "content": {
+ "content_id": "content",
+ "html": "In which language does Oppia mean 'to learn'?
"
+ },
+ "interaction": {
+ "id": "TextInput",
+ "customization_args": {
+ "rows": {
+ "value": 1.0
+ },
+ "placeholder": {
+ "value": {
+ "content_id": "ca_placeholder_0",
+ "unicode_str": "Enter a language"
+ }
+ }
+ },
+ "answer_groups": [{
+ "rule_specs": [{
+ "rule_type": "Equals",
+ "inputs": {
+ "x": {
+ "contentId": "",
+ "normalizedStrSet": ["finnish"]
+ }
+ }
+ }],
+ "outcome": {
+ "dest": "End",
+ "feedback": {
+ "content_id": "feedback_1",
+ "html": "Correct!
"
+ },
+ "labelled_as_correct": false
+ }
+ }],
+ "default_outcome": {
+ "dest": "Text",
+ "feedback": {
+ "content_id": "default_outcome",
+ "html": "Not quite. Try again (or maybe use a search engine).
"
+ },
+ "labelled_as_correct": false
+ },
+ "hints": [],
+ "solution": {
+ "answer_is_exclusive": false,
+ "correct_answer": "Finnish",
+ "explanation": {
+ "content_id": "solution",
+ "html": "'Oppia' is translated from Finnish.
"
+ }
+ }
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "solution": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "feedback_1": {},
+ "content": {},
+ "default_outcome": {},
+ "solution": {}
+ }
+ }
+ },
+ "End": {
+ "content": {
+ "content_id": "content",
+ "html": "Congratulations, you have finished!"
+ },
+ "param_changes": [],
+ "interaction": {
+ "id": "EndExploration",
+ "customization_args": {
+ "recommendedExplorationIds": {
+ "value": []
+ }
+ },
+ "answer_groups": [],
+ "default_outcome": null,
+ "hints": [],
+ "solution": null
+ },
+ "recorded_voiceovers": {
+ "voiceovers_mapping": {
+ "content": {}
+ }
+ },
+ "written_translations": {
+ "translations_mapping": {
+ "content": {}
+ }
+ }
+ }
+ },
+ "objective": "Test exploration.",
+ "language_code": "en",
+ "title": "Prototype exploration with only one solution and no hints"
+ }
+}
diff --git a/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.textproto b/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.textproto
new file mode 100644
index 00000000000..87c0d655155
--- /dev/null
+++ b/domain/src/main/assets/test_single_interactive_state_exp_with_only_solution.textproto
@@ -0,0 +1,143 @@
+id: "test_single_interactive_state_exp_with_only_solution"
+states {
+ key: "Text"
+ value {
+ name: "Text"
+ recorded_voiceovers {
+ key: "feedback_1"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "default_outcome"
+ value {
+ }
+ }
+ recorded_voiceovers {
+ key: "solution"
+ value {
+ }
+ }
+ content {
+ html: "In which language does Oppia mean \'to learn\'?
"
+ content_id: "content"
+ }
+ written_translations {
+ key: "feedback_1"
+ value {
+ }
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ written_translations {
+ key: "default_outcome"
+ value {
+ }
+ }
+ written_translations {
+ key: "solution"
+ value {
+ }
+ }
+ interaction {
+ id: "TextInput"
+ answer_groups {
+ outcome {
+ dest_state_name: "End"
+ feedback {
+ html: "Correct!
"
+ content_id: "feedback_1"
+ }
+ }
+ rule_specs {
+ input {
+ key: "x"
+ value {
+ translatable_set_of_normalized_string {
+ content_id: ""
+ normalized_strings: "finnish"
+ }
+ }
+ }
+ rule_type: "Equals"
+ }
+ }
+ solution {
+ interaction_id: "TextInput"
+ correct_answer {
+ correct_answer: "Finnish"
+ }
+ explanation {
+ html: "'Oppia' is translated from Finnish.
"
+ content_id: "solution"
+ }
+ }
+ default_outcome {
+ dest_state_name: "Text"
+ feedback {
+ html: "Not quite. Try again (or maybe use a search engine).
"
+ content_id: "default_outcome"
+ }
+ }
+ customization_args {
+ key: "rows"
+ value {
+ signed_int: 1
+ }
+ }
+ customization_args {
+ key: "placeholder"
+ value {
+ custom_schema_value {
+ subtitled_html {
+ html: "Enter a language"
+ content_id: "ca_placeholder_0"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+states {
+ key: "End"
+ value {
+ name: "End"
+ recorded_voiceovers {
+ key: "content"
+ value {
+ }
+ }
+ content {
+ html: "Congratulations, you have finished!"
+ content_id: "content"
+ }
+ written_translations {
+ key: "content"
+ value {
+ }
+ }
+ interaction {
+ id: "EndExploration"
+ customization_args {
+ key: "recommendedExplorationIds"
+ value {
+ schema_object_list {
+ }
+ }
+ }
+ }
+ }
+}
+init_state_name: "Text"
+objective: "Test exploration."
+title: "Prototype exploration with only one solution and no hints"
+language_code: "en"
diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt
index 459af038a6d..3bf4c16bd62 100644
--- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt
@@ -7,13 +7,12 @@ import org.oppia.android.app.model.CheckpointState
import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.Exploration
import org.oppia.android.app.model.ExplorationCheckpoint
-import org.oppia.android.app.model.Hint
+import org.oppia.android.app.model.HelpIndex
import org.oppia.android.app.model.ProfileId
-import org.oppia.android.app.model.Solution
-import org.oppia.android.app.model.State
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.domain.classify.AnswerClassificationController
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController
+import org.oppia.android.domain.hintsandsolution.HintHandler
import org.oppia.android.domain.oppialogger.OppiaLogger
import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
import org.oppia.android.domain.topic.StoryProgressController
@@ -48,8 +47,9 @@ class ExplorationProgressController @Inject constructor(
private val explorationCheckpointController: ExplorationCheckpointController,
private val storyProgressController: StoryProgressController,
private val oppiaClock: OppiaClock,
- private val oppiaLogger: OppiaLogger
-) {
+ private val oppiaLogger: OppiaLogger,
+ private val hintHandlerFactory: HintHandler.Factory
+) : HintHandler.HintMonitor {
// TODO(#179): Add support for parameters.
// TODO(#3622): Update the internal locking of this controller to use something like an in-memory
// blocking cache to simplify state locking. However, doing this correctly requires a fix in
@@ -70,6 +70,7 @@ class ExplorationProgressController @Inject constructor(
)
private val explorationProgress = ExplorationProgress()
private val explorationProgressLock = ReentrantLock()
+ private lateinit var hintHandler: HintHandler
/** Resets this controller to begin playing the specified [Exploration]. */
internal fun beginExplorationAsync(
@@ -92,6 +93,7 @@ class ExplorationProgressController @Inject constructor(
this.shouldSavePartialProgress = shouldSavePartialProgress
checkpointState = CheckpointState.CHECKPOINT_UNSAVED
}
+ hintHandler = hintHandlerFactory.create(this)
explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.LOADING_EXPLORATION)
asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
}
@@ -107,6 +109,13 @@ class ExplorationProgressController @Inject constructor(
}
}
+ override fun onHelpIndexChanged() {
+ explorationProgressLock.withLock {
+ saveExplorationCheckpoint()
+ }
+ asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
+ }
+
/**
* Submits an answer to the current state and returns how the UI should respond to this answer.
* The returned [LiveData] will only have at most two results posted: a pending result, and then a
@@ -174,17 +183,26 @@ class ExplorationProgressController @Inject constructor(
answerOutcome =
explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome)
explorationProgress.stateDeck.submitAnswer(userAnswer, answerOutcome.feedback)
+
// Follow the answer's outcome to another part of the graph if it's different.
- if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) {
- explorationProgress.stateDeck.pushState(
- explorationProgress.stateGraph.getState(answerOutcome.stateName),
- prohibitSameStateName = true
- )
+ val ephemeralState = computeCurrentEphemeralState()
+ when {
+ answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME -> {
+ val newState = explorationProgress.stateGraph.getState(answerOutcome.stateName)
+ explorationProgress.stateDeck.pushState(newState, prohibitSameStateName = true)
+ hintHandler.finishState(newState)
+ }
+ ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.PENDING_STATE -> {
+ // Schedule, or show immediately, a new hint or solution based on the current
+ // ephemeral state of the exploration because a new wrong answer was submitted.
+ hintHandler.handleWrongAnswerSubmission(ephemeralState.pendingState.wrongAnswerCount)
+ }
}
} finally {
- // If the answer was submitted on behalf of the Continue interaction, don't save
- // checkpoint because it will be saved when the learner moves to the next state.
if (!doesInteractionAutoContinue(answerOutcome.state.interaction.id)) {
+ // If the answer was not submitted on behalf of the Continue interaction, update the
+ // hint state and save checkpoint because it will be saved when the learner moves to the
+ // next state.
saveExplorationCheckpoint()
}
@@ -204,11 +222,15 @@ class ExplorationProgressController @Inject constructor(
}
}
- fun submitHintIsRevealed(
- state: State,
- hintIsRevealed: Boolean,
- hintIndex: Int
- ): LiveData> {
+ /**
+ * Notifies the controller that the user wishes to reveal a hint.
+ *
+ * @param hintIndex index of the hint that was revealed in the hint list of the current pending
+ * state
+ * @return a one-time [LiveData] that indicates success/failure of the operation (the actual
+ * payload of the result isn't relevant)
+ */
+ fun submitHintIsRevealed(hintIndex: Int): LiveData> {
try {
explorationProgressLock.withLock {
check(
@@ -229,25 +251,16 @@ class ExplorationProgressController @Inject constructor(
) {
"Cannot submit an answer while another answer is pending."
}
- lateinit var hint: Hint
try {
- explorationProgress.stateDeck.submitHintRevealed(state, hintIsRevealed, hintIndex)
- hint = explorationProgress.stateGraph.computeHintForResult(
- state,
- hintIsRevealed,
- hintIndex
- )
- explorationProgress.stateDeck.pushStateForHint(state, hintIndex)
+ hintHandler.viewHint(hintIndex)
} finally {
- // Mark a checkpoint in the exploration everytime a new hint is revealed.
- saveExplorationCheckpoint()
// Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck
// in an 'always showing hint' situation. This can specifically happen if hint throws an
// exception.
explorationProgress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE)
}
asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
- return MutableLiveData(AsyncResult.success(hint))
+ return MutableLiveData(AsyncResult.success(null))
}
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
@@ -255,9 +268,13 @@ class ExplorationProgressController @Inject constructor(
}
}
- fun submitSolutionIsRevealed(
- state: State
- ): LiveData> {
+ /**
+ * Notifies the controller that the user has revealed the solution to the current state.
+ *
+ * @return a one-time [LiveData] that indicates success/failure of the operation (the actual
+ * payload of the result isn't relevant)
+ */
+ fun submitSolutionIsRevealed(): LiveData> {
try {
explorationProgressLock.withLock {
check(
@@ -278,15 +295,9 @@ class ExplorationProgressController @Inject constructor(
) {
"Cannot submit an answer while another answer is pending."
}
- lateinit var solution: Solution
try {
-
- explorationProgress.stateDeck.submitSolutionRevealed(state)
- solution = explorationProgress.stateGraph.computeSolutionForResult(state)
- explorationProgress.stateDeck.pushStateForSolution(state)
+ hintHandler.viewSolution()
} finally {
- // Mark a checkpoint in the exploration if the solution is revealed.
- saveExplorationCheckpoint()
// Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck
// in an 'always showing solution' situation. This can specifically happen if solution
// throws an exception.
@@ -294,7 +305,7 @@ class ExplorationProgressController @Inject constructor(
}
asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
- return MutableLiveData(AsyncResult.success(solution))
+ return MutableLiveData(AsyncResult.success(null))
}
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
@@ -334,10 +345,11 @@ class ExplorationProgressController @Inject constructor(
) {
"Cannot navigate to a previous state if an answer submission is pending."
}
+ hintHandler.navigateToPreviousState()
explorationProgress.stateDeck.navigateToPreviousState()
asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
}
- return MutableLiveData(AsyncResult.success(null))
+ return MutableLiveData(AsyncResult.success(null))
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
return MutableLiveData(AsyncResult.failed(e))
@@ -359,7 +371,6 @@ class ExplorationProgressController @Inject constructor(
* listen to this result for failures, and instead rely on [getCurrentState] for observing a
* successful transition to another state.
*/
-
fun moveToNextState(): LiveData> {
try {
explorationProgressLock.withLock {
@@ -383,20 +394,127 @@ class ExplorationProgressController @Inject constructor(
}
explorationProgress.stateDeck.navigateToNextState()
- // Only mark checkpoint if current state is pending state. This ensures that checkpoints
- // will not be marked on any of the completed states.
if (explorationProgress.stateDeck.isCurrentStateTopOfDeck()) {
+ hintHandler.navigateBackToLatestPendingState()
+
+ // Only mark checkpoint if current state is pending state. This ensures that checkpoints
+ // will not be marked on any of the completed states.
saveExplorationCheckpoint()
}
asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID)
}
- return MutableLiveData(AsyncResult.success(null))
+ return MutableLiveData(AsyncResult.success(null))
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
return MutableLiveData(AsyncResult.failed(e))
}
}
+ /**
+ * Returns a [DataProvider] monitoring the current [EphemeralState] the learner is currently
+ * viewing. If this state corresponds to a a terminal state, then the learner has completed the
+ * exploration. Note that [moveToPreviousState] and [moveToNextState] will automatically update
+ * observers of this data provider when the next state is navigated to.
+ *
+ * This [DataProvider] may initially be pending while the exploration object is loaded. It may
+ * also switch from a completed to a pending result during transient operations like submitting an
+ * answer via [submitAnswer]. Calling code should be made resilient to this by caching the current
+ * state object to display since it may disappear temporarily during answer submission. Calling
+ * code should persist this state object across configuration changes if needed since it cannot
+ * rely on this [DataProvider] for immediate state reconstitution after configuration changes.
+ *
+ * The underlying state returned by this function can only be changed by calls to
+ * [moveToNextState] and [moveToPreviousState], or the exploration data controller if another
+ * exploration is loaded. UI code can be confident only calls from the UI layer will trigger state
+ * changes here to ensure atomicity between receiving and making state changes.
+ *
+ * This method is safe to be called before an exploration has started. If there is no ongoing
+ * exploration, it should return a pending state.
+ */
+ fun getCurrentState(): DataProvider = currentStateDataProvider
+
+ private suspend fun retrieveCurrentStateAsync(): AsyncResult {
+ return try {
+ retrieveCurrentStateWithinCacheAsync()
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ AsyncResult.failed(e)
+ }
+ }
+
+ @Suppress("RedundantSuspendModifier") // Function is 'suspend' to restrict calling some methods.
+ private suspend fun retrieveCurrentStateWithinCacheAsync(): AsyncResult {
+ val explorationId: String? = explorationProgressLock.withLock {
+ if (explorationProgress.playStage == ExplorationProgress.PlayStage.LOADING_EXPLORATION) {
+ explorationProgress.currentExplorationId
+ } else null
+ }
+
+ val exploration = explorationId?.let(explorationRetriever::loadExploration)
+
+ explorationProgressLock.withLock {
+ // It's possible for the exploration ID or stage to change between critical sections. However,
+ // this is the only way to ensure the exploration is loaded since suspended functions cannot
+ // be called within a mutex. Note that it's also possible for the stage to change between
+ // critical sections, sometimes due to this suspend function being called multiple times and a
+ // former call finishing the exploration load.
+ check(
+ exploration == null ||
+ explorationProgress.currentExplorationId == explorationId
+ ) {
+ "Encountered race condition when retrieving exploration. ID changed from $explorationId" +
+ " to ${explorationProgress.currentExplorationId}"
+ }
+ return when (explorationProgress.playStage) {
+ ExplorationProgress.PlayStage.NOT_PLAYING -> AsyncResult.pending()
+ ExplorationProgress.PlayStage.LOADING_EXPLORATION -> {
+ try {
+ // The exploration must be available for this stage since it was loaded above.
+ finishLoadExploration(exploration!!, explorationProgress)
+ AsyncResult.success(
+ computeCurrentEphemeralState()
+ .toBuilder()
+ .setCheckpointState(explorationProgress.checkpointState)
+ .build()
+ )
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ AsyncResult.failed(e)
+ }
+ }
+ ExplorationProgress.PlayStage.VIEWING_STATE ->
+ AsyncResult.success(
+ computeCurrentEphemeralState()
+ .toBuilder()
+ .setCheckpointState(explorationProgress.checkpointState)
+ .build()
+ )
+ ExplorationProgress.PlayStage.SUBMITTING_ANSWER -> AsyncResult.pending()
+ }
+ }
+ }
+
+ private fun finishLoadExploration(exploration: Exploration, progress: ExplorationProgress) {
+ // The exploration must be initialized first since other lazy fields depend on it being inited.
+ progress.currentExploration = exploration
+ progress.stateGraph.reset(exploration.statesMap)
+ progress.stateDeck.resetDeck(progress.stateGraph.getState(exploration.initStateName))
+
+ // Advance the stage, but do not notify observers since the current state can be reported
+ // immediately to the UI.
+ progress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE)
+
+ hintHandler.startWatchingForHintsInNewState(progress.stateDeck.getCurrentState())
+
+ // Mark a checkpoint in the exploration once the exploration has loaded.
+ saveExplorationCheckpoint()
+ }
+
+ private fun computeCurrentEphemeralState(): EphemeralState =
+ explorationProgress.stateDeck.getCurrentEphemeralState(computeCurrentHelpIndex())
+
+ private fun computeCurrentHelpIndex(): HelpIndex = hintHandler.getCurrentHelpIndex()
+
/**
* Checks if checkpointing is enabled, if checkpointing is enabled this function creates a
* checkpoint with the latest progress and saves it using [ExplorationCheckpointController].
@@ -418,7 +536,8 @@ class ExplorationProgressController @Inject constructor(
explorationProgress.stateDeck.createExplorationCheckpoint(
explorationProgress.currentExploration.version,
explorationProgress.currentExploration.title,
- oppiaClock.getCurrentTimeMs()
+ oppiaClock.getCurrentTimeMs(),
+ computeCurrentHelpIndex()
)
val deferred = explorationCheckpointController.recordExplorationCheckpointAsync(
@@ -506,103 +625,6 @@ class ExplorationProgressController @Inject constructor(
}
}
- /**
- * Returns a [DataProvider] monitoring the current [EphemeralState] the learner is currently
- * viewing. If this state corresponds to a a terminal state, then the learner has completed the
- * exploration. Note that [moveToPreviousState] and [moveToNextState] will automatically update
- * observers of this data provider when the next state is navigated to.
- *
- * This [DataProvider] may initially be pending while the exploration object is loaded. It may
- * also switch from a completed to a pending result during transient operations like submitting an
- * answer via [submitAnswer]. Calling code should be made resilient to this by caching the current
- * state object to display since it may disappear temporarily during answer submission. Calling
- * code should persist this state object across configuration changes if needed since it cannot
- * rely on this [DataProvider] for immediate state reconstitution after configuration changes.
- *
- * The underlying state returned by this function can only be changed by calls to
- * [moveToNextState] and [moveToPreviousState], or the exploration data controller if another
- * exploration is loaded. UI code can be confident only calls from the UI layer will trigger state
- * changes here to ensure atomicity between receiving and making state changes.
- *
- * This method is safe to be called before an exploration has started. If there is no ongoing
- * exploration, it should return a pending state.
- */
- fun getCurrentState(): DataProvider = currentStateDataProvider
-
- private suspend fun retrieveCurrentStateAsync(): AsyncResult {
- return try {
- retrieveCurrentStateWithinCacheAsync()
- } catch (e: Exception) {
- exceptionsController.logNonFatalException(e)
- AsyncResult.failed(e)
- }
- }
-
- private suspend fun retrieveCurrentStateWithinCacheAsync(): AsyncResult {
- val explorationId: String? = explorationProgressLock.withLock {
- if (explorationProgress.playStage == ExplorationProgress.PlayStage.LOADING_EXPLORATION) {
- explorationProgress.currentExplorationId
- } else null
- }
-
- val exploration = explorationId?.let(explorationRetriever::loadExploration)
-
- explorationProgressLock.withLock {
- // It's possible for the exploration ID or stage to change between critical sections. However,
- // this is the only way to ensure the exploration is loaded since suspended functions cannot
- // be called within a mutex. Note that it's also possible for the stage to change between
- // critical sections, sometimes due to this suspend function being called multiple times and a
- // former call finishing the exploration load.
- check(
- exploration == null ||
- explorationProgress.currentExplorationId == explorationId
- ) {
- "Encountered race condition when retrieving exploration. ID changed from $explorationId" +
- " to ${explorationProgress.currentExplorationId}"
- }
- return when (explorationProgress.playStage) {
- ExplorationProgress.PlayStage.NOT_PLAYING -> AsyncResult.pending()
- ExplorationProgress.PlayStage.LOADING_EXPLORATION -> {
- try {
- // The exploration must be available for this stage since it was loaded above.
- finishLoadExploration(exploration!!, explorationProgress)
- AsyncResult.success(
- explorationProgress.stateDeck.getCurrentEphemeralState()
- .toBuilder()
- .setCheckpointState(explorationProgress.checkpointState)
- .build()
- )
- } catch (e: Exception) {
- exceptionsController.logNonFatalException(e)
- AsyncResult.failed(e)
- }
- }
- ExplorationProgress.PlayStage.VIEWING_STATE ->
- AsyncResult.success(
- explorationProgress.stateDeck.getCurrentEphemeralState()
- .toBuilder()
- .setCheckpointState(explorationProgress.checkpointState)
- .build()
- )
- ExplorationProgress.PlayStage.SUBMITTING_ANSWER -> AsyncResult.pending()
- }
- }
- }
-
- private fun finishLoadExploration(exploration: Exploration, progress: ExplorationProgress) {
- // The exploration must be initialized first since other lazy fields depend on it being inited.
- progress.currentExploration = exploration
- progress.stateGraph.reset(exploration.statesMap)
- progress.stateDeck.resetDeck(progress.stateGraph.getState(exploration.initStateName))
-
- // Advance the stage, but do not notify observers since the current state can be reported
- // immediately to the UI.
- progress.advancePlayStageTo(ExplorationProgress.PlayStage.VIEWING_STATE)
-
- // Mark a checkpoint in the exploration once the exploration has loaded.
- saveExplorationCheckpoint()
- }
-
/**
* Returns whether the specified interaction automatically continues the user to the next state
* upon completion.
diff --git a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt
similarity index 83%
rename from app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt
rename to domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt
index 223996ceb99..2afe7621d6c 100644
--- a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt
@@ -1,4 +1,4 @@
-package org.oppia.android.app.player.state.hintsandsolution
+package org.oppia.android.domain.hintsandsolution
import javax.inject.Qualifier
diff --git a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsMillis.kt
similarity index 81%
rename from app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt
rename to domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsMillis.kt
index 600c354db98..371a2bb8f81 100644
--- a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsMillis.kt
@@ -1,4 +1,4 @@
-package org.oppia.android.app.player.state.hintsandsolution
+package org.oppia.android.domain.hintsandsolution
import javax.inject.Qualifier
diff --git a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowInitialHintMillis.kt
similarity index 78%
rename from app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt
rename to domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowInitialHintMillis.kt
index f02d8c74d9b..5abba359af7 100644
--- a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowInitialHintMillis.kt
@@ -1,5 +1,4 @@
-package org.oppia.android.app.player.state.hintsandsolution
-
+package org.oppia.android.domain.hintsandsolution
import javax.inject.Qualifier
/**
diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensions.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensions.kt
new file mode 100644
index 00000000000..f2339f68673
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensions.kt
@@ -0,0 +1,28 @@
+package org.oppia.android.domain.hintsandsolution
+
+import org.oppia.android.app.model.HelpIndex
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.INDEXTYPE_NOT_SET
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION
+import org.oppia.android.app.model.Hint
+
+/**
+ * Returns whether the specified [hintIndex] relative to the proivded list of [Hint]s has been seen
+ * by the user based on this [HelpIndex].
+ */
+fun HelpIndex.isHintRevealed(hintIndex: Int, hintList: List): Boolean {
+ val lastShownHintIndex = when (indexTypeCase) {
+ SHOW_SOLUTION, EVERYTHING_REVEALED -> hintList.lastIndex
+ NEXT_AVAILABLE_HINT_INDEX -> nextAvailableHintIndex - 1
+ LATEST_REVEALED_HINT_INDEX -> latestRevealedHintIndex
+ INDEXTYPE_NOT_SET, null -> -1
+ }
+ return hintIndex <= lastShownHintIndex
+}
+
+/**
+ * Returns whether, based on a specified [HelpIndex], the solution has been viewed by the learner.
+ */
+fun HelpIndex.isSolutionRevealed(): Boolean = indexTypeCase == EVERYTHING_REVEALED
diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt
new file mode 100644
index 00000000000..5fdc8c22716
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt
@@ -0,0 +1,93 @@
+package org.oppia.android.domain.hintsandsolution
+
+import org.oppia.android.app.model.HelpIndex
+import org.oppia.android.app.model.State
+
+/**
+ * Handler for showing hints to the learner after a period of time in the event they submit a
+ * wrong answer.
+ *
+ * Note that the exact behavior of when a hint or solution is made available is up to the
+ * implementation, but it's assumed that:
+ * 1. This class is the sole decision maker for whether a hint is available or revealed (and ditto
+ * for solutions)
+ * 2. Hints must be viewed in order
+ * 3. Later hints are not available until all previous hints have been revealed
+ * 4. The solution cannot be revealed until all previous hints have been revealed
+ *
+ * Implementations of this class are safe to access across multiple threads, but care must be taken
+ * when calling back into this class from [HintMonitor] since that could cause deadlocks. Note also
+ * that this class may block the calling thread. While the operations this class performs
+ * synchronously should be quick, care should be taken about heavy usage of this class on the main
+ * thread as it may introduce janky behavior.
+ */
+interface HintHandler {
+
+ /**
+ * Starts watching for potential hints to be shown (e.g. if a user doesn't submit an answer after
+ * a certain amount of time). This is meant to only be called once at the beginning of a state.
+ */
+ fun startWatchingForHintsInNewState(state: State)
+
+ /**
+ * Indicates that the current state has ended and a new one should start being monitored. This
+ * will cancel any previously pending background operations and potentially starts new ones
+ * corresponding to the new state.
+ */
+ fun finishState(newState: State)
+
+ /**
+ * Notifies the handler that a wrong answer was submitted.
+ *
+ * @param wrongAnswerCount the total number of wrong answers submitted to date
+ */
+ fun handleWrongAnswerSubmission(wrongAnswerCount: Int)
+
+ /** Notifies the handler that the user revealed a hint corresponding to the specified index. */
+ fun viewHint(hintIndex: Int)
+
+ /** Notifies the handler that the user revealed the the solution for the current state. */
+ fun viewSolution()
+
+ /**
+ * Notifies the handler that the user navigated to a previously completed state. This will
+ * potentially cancel any ongoing operations to avoid the hint counter continuing when navigating
+ * an earlier state.
+ */
+ fun navigateToPreviousState()
+
+ /**
+ * Notifies the handler that the user has navigated back to the latest (pending) state. Note that
+ * this may resume background operations, but it does not guarantee that those start at the same
+ * time that they left off at (counters may be reset upon returning to the latest state).
+ */
+ fun navigateBackToLatestPendingState()
+
+ /** Returns the [HelpIndex] corresponding to the current pending state. */
+ fun getCurrentHelpIndex(): HelpIndex
+
+ /** A callback interface for monitoring changes to [HintHandler]. */
+ interface HintMonitor {
+ /**
+ * Called when the [HelpIndex] managed by the [HintHandler] has changed. To get the latest
+ * state, call [HintHandler.getCurrentHelpIndex]. Note that this method may be called on a
+ * background thread.
+ */
+ fun onHelpIndexChanged()
+ }
+
+ /**
+ * Factory for creating new [HintHandler]s.
+ *
+ * Note that instances of this class are injectable in the application component.
+ */
+ interface Factory {
+ /**
+ * Creates a new [HintHandler].
+ *
+ * @param hintMonitor a [HintMonitor] to observe async changes to hints/solution
+ * @return a new [HintHandler] for managing hint/solution state for a specific play session
+ */
+ fun create(hintMonitor: HintMonitor): HintHandler
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerImpl.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerImpl.kt
new file mode 100644
index 00000000000..6e8f972b69b
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandlerImpl.kt
@@ -0,0 +1,339 @@
+package org.oppia.android.domain.hintsandsolution
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.oppia.android.app.model.HelpIndex
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.INDEXTYPE_NOT_SET
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX
+import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION
+import org.oppia.android.app.model.State
+import org.oppia.android.util.threading.BackgroundDispatcher
+import java.util.concurrent.locks.ReentrantLock
+import javax.inject.Inject
+import kotlin.concurrent.withLock
+
+/**
+ * Production implementation of [HintHandler] that implements hints & solutions in parity with the
+ * Oppia web platform.
+ *
+ * # Flow chart for when hints are shown
+ *
+ * Submit 1st Submit wrong
+ * wrong answer answer
+ * +---+ +---+
+ * | | | |
+ * | v | v
+ * +-+---+----+ +-+---+-----+ +----------+
+ * Initial| No | Wait 60s | | View hint | Hint |
+ * state | hint +----------->+ Hint +---------->+ consumed |
+ * +----->+ released | or, submit | available | Wait 30s | |
+ * | | 2nd wrong | +<----------+ |
+ * +----------+ answer +----+------+ +----+-----+
+ * ^ |
+ * |Wait 10s |
+ * | |
+ * +----+------+ |
+ * +--->+ No | Submit wrong |
+ * Submit wrong| | hint | answer |
+ * answer | | available +<---------------+
+ * +----+ |
+ * +-----------+
+ *
+ * # Logic for selecting a hint
+ *
+ * Hints are selected based on the availability of hints to show, and any previous hints that have
+ * been shown. A new hint will only be made available if its previous hint has been viewed by the
+ * learner. Hints are always shown in order. If all hints have been exhausted and viewed by the
+ * user, then the 'hint available' state in the diagram above will trigger the solution to be
+ * made available to view, if a solution is present. Once the solution is viewed by the learner,
+ * they will reach a terminal state for hints and no additional hints or solutions will be made
+ * available.
+ */
+class HintHandlerImpl private constructor(
+ private val delayShowInitialHintMs: Long,
+ private val delayShowAdditionalHintsMs: Long,
+ private val delayShowAdditionalHintsFromWrongAnswerMs: Long,
+ backgroundCoroutineDispatcher: CoroutineDispatcher,
+ private val hintMonitor: HintHandler.HintMonitor
+) : HintHandler {
+ private val handlerLock = ReentrantLock()
+ private val backgroundCoroutineScope = CoroutineScope(backgroundCoroutineDispatcher)
+
+ private var trackedWrongAnswerCount = 0
+ private lateinit var pendingState: State
+ private var hintSequenceNumber = 0
+ private var lastRevealedHintIndex = -1
+ private var latestAvailableHintIndex = -1
+ private var solutionIsAvailable = false
+ private var solutionIsRevealed = false
+
+ override fun startWatchingForHintsInNewState(state: State) {
+ handlerLock.withLock {
+ pendingState = state
+ hintMonitor.onHelpIndexChanged()
+ maybeScheduleShowHint(wrongAnswerCount = 0)
+ }
+ }
+
+ override fun finishState(newState: State) {
+ handlerLock.withLock {
+ reset()
+ startWatchingForHintsInNewState(newState)
+ }
+ }
+
+ override fun handleWrongAnswerSubmission(wrongAnswerCount: Int) {
+ handlerLock.withLock {
+ maybeScheduleShowHint(wrongAnswerCount)
+ }
+ }
+
+ override fun viewHint(hintIndex: Int) {
+ handlerLock.withLock {
+ val helpIndex = computeCurrentHelpIndex()
+ check(
+ helpIndex.indexTypeCase == NEXT_AVAILABLE_HINT_INDEX &&
+ helpIndex.nextAvailableHintIndex == hintIndex
+ ) {
+ "Cannot reveal hint for current index: ${helpIndex.indexTypeCase} (trying to reveal hint:" +
+ " $hintIndex)"
+ }
+
+ cancelPendingTasks()
+ lastRevealedHintIndex = lastRevealedHintIndex.coerceAtLeast(hintIndex)
+ hintMonitor.onHelpIndexChanged()
+ maybeScheduleShowHint()
+ }
+ }
+
+ override fun viewSolution() {
+ handlerLock.withLock {
+ val helpIndex = computeCurrentHelpIndex()
+ check(helpIndex.indexTypeCase == SHOW_SOLUTION) {
+ "Cannot reveal solution for current index: ${helpIndex.indexTypeCase}"
+ }
+
+ cancelPendingTasks()
+ solutionIsRevealed = true
+ hintMonitor.onHelpIndexChanged()
+ }
+ }
+
+ override fun navigateToPreviousState() {
+ // Cancel tasks from the top pending state to avoid hint counters continuing after navigating
+ // away.
+ handlerLock.withLock {
+ cancelPendingTasks()
+ }
+ }
+
+ override fun navigateBackToLatestPendingState() {
+ handlerLock.withLock {
+ maybeScheduleShowHint()
+ }
+ }
+
+ override fun getCurrentHelpIndex(): HelpIndex = handlerLock.withLock {
+ computeCurrentHelpIndex()
+ }
+
+ private fun cancelPendingTasks() {
+ // Cancel any potential pending hints by advancing the sequence number. Note that this isn't
+ // reset to 0 to ensure that all previous hint tasks are cancelled, and new tasks can be
+ // scheduled without overlapping with past sequence numbers.
+ hintSequenceNumber++
+ }
+
+ private fun maybeScheduleShowHint(wrongAnswerCount: Int = trackedWrongAnswerCount) {
+ if (!pendingState.offersHelp()) {
+ // If this state has no help to show, do nothing.
+ return
+ }
+
+ // Start showing hints after a wrong answer is submitted or if the user appears stuck (e.g.
+ // doesn't answer after some duration). Note that if there's already a timer to show a hint,
+ // it will be reset for each subsequent answer.
+ val currentHelpIndex = computeCurrentHelpIndex()
+ val nextUnrevealedHelpIndex = getNextHelpIndexToReveal()
+ val isFirstHint = currentHelpIndex.indexTypeCase == INDEXTYPE_NOT_SET
+ if (wrongAnswerCount == trackedWrongAnswerCount) {
+ // If no answers have been submitted, schedule a task to automatically help after a fixed
+ // amount of time. This will automatically reset if something changes other than answers
+ // (e.g. revealing a hint), which may trigger more help to become available.
+ if (isFirstHint) {
+ // The learner needs to wait longer for the initial hint to show since they need some time
+ // to read through and consider the question.
+ scheduleShowHint(delayShowInitialHintMs, nextUnrevealedHelpIndex)
+ } else {
+ scheduleShowHint(delayShowAdditionalHintsMs, nextUnrevealedHelpIndex)
+ }
+ } else {
+ // See if the learner's new wrong answer justifies showing a hint.
+ if (isFirstHint) {
+ if (wrongAnswerCount > 1) {
+ // If more than one answer has been submitted and no hint has yet been shown, show a
+ // hint immediately since the learner is probably stuck.
+ showHintImmediately(nextUnrevealedHelpIndex)
+ }
+ } else {
+ // Otherwise, always schedule to show a hint on a new wrong answer for subsequent hints.
+ scheduleShowHint(
+ delayShowAdditionalHintsFromWrongAnswerMs,
+ nextUnrevealedHelpIndex
+ )
+ }
+ trackedWrongAnswerCount = wrongAnswerCount
+ }
+ }
+
+ /** Resets this handler to prepare it for a new state, cancelling any pending hints. */
+ private fun reset() {
+ trackedWrongAnswerCount = 0
+ // Cancel tasks rather than resetting to avoid potential cases where previous tasks can carry to
+ // the next state.
+ cancelPendingTasks()
+ lastRevealedHintIndex = -1
+ latestAvailableHintIndex = -1
+ solutionIsAvailable = false
+ solutionIsRevealed = false
+ }
+
+ private fun computeCurrentHelpIndex(): HelpIndex {
+ val hintList = pendingState.interaction.hintList
+ val hasSolution = pendingState.hasSolution()
+ val hasAtLeastOneHintAvailable = latestAvailableHintIndex != -1
+ val hasSeenAllAvailableHints = latestAvailableHintIndex == lastRevealedHintIndex
+ val hasSeenAllHints = lastRevealedHintIndex == hintList.lastIndex
+ val hasViewableSolution = hasSolution && solutionIsAvailable
+
+ return when {
+ // No hints or solution are available to be shown.
+ !pendingState.offersHelp() -> HelpIndex.getDefaultInstance()
+
+ // The solution has been revealed.
+ solutionIsRevealed -> HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ // All hints have been shown and a solution can be shown.
+ hasSeenAllHints && hasViewableSolution -> HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+
+ // All hints have been shown & there is no solution.
+ hasSeenAllHints && !hasSolution -> HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ // Hints are available (though they may have already been seen).
+ hasAtLeastOneHintAvailable ->
+ if (hasSeenAllAvailableHints) {
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = lastRevealedHintIndex
+ }.build()
+ } else {
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = latestAvailableHintIndex
+ }.build()
+ }
+
+ // No hints are available to be shown yet.
+ else -> HelpIndex.getDefaultInstance()
+ }
+ }
+
+ /**
+ * Returns the [HelpIndex] of the next hint or solution that hasn't yet been revealed, or
+ * default if there is none.
+ */
+ private fun getNextHelpIndexToReveal(): HelpIndex {
+ // Return the index of the first unrevealed hint, or the length of the list if all have been
+ // revealed.
+ val hintList = pendingState.interaction.hintList
+ val solution = pendingState.interaction.solution
+
+ val hasHints = hintList.isNotEmpty()
+ val hasHelp = hasHints || solution.hasCorrectAnswer()
+ val lastUnrevealedHintIndex = lastRevealedHintIndex + 1
+
+ return if (!hasHelp) {
+ HelpIndex.getDefaultInstance()
+ } else if (hasHints && lastUnrevealedHintIndex < hintList.size) {
+ HelpIndex.newBuilder().setNextAvailableHintIndex(lastUnrevealedHintIndex).build()
+ } else if (solution.hasCorrectAnswer() && !solutionIsRevealed) {
+ HelpIndex.newBuilder().setShowSolution(true).build()
+ } else {
+ HelpIndex.newBuilder().setEverythingRevealed(true).build()
+ }
+ }
+
+ /**
+ * Schedules to allow the hint of the specified index to be shown after the specified delay,
+ * cancelling any previously pending hints initiated by calls to this method.
+ */
+ private fun scheduleShowHint(delayMs: Long, helpIndexToShow: HelpIndex) {
+ val targetSequenceNumber = ++hintSequenceNumber
+ backgroundCoroutineScope.launch {
+ delay(delayMs)
+ handlerLock.withLock {
+ showHint(targetSequenceNumber, helpIndexToShow)
+ }
+ }
+ }
+
+ /**
+ * Immediately indicates the specified hint is ready to be shown, cancelling any previously
+ * pending hints initiated by calls to [scheduleShowHint].
+ */
+ private fun showHintImmediately(helpIndexToShow: HelpIndex) {
+ showHint(++hintSequenceNumber, helpIndexToShow)
+ }
+
+ private fun showHint(targetSequenceNumber: Int, nextHelpIndexToShow: HelpIndex) {
+ // Only finish this timer if no other hints were scheduled and no cancellations occurred.
+ if (targetSequenceNumber == hintSequenceNumber) {
+ val previousHelpIndex = computeCurrentHelpIndex()
+
+ when (nextHelpIndexToShow.indexTypeCase) {
+ NEXT_AVAILABLE_HINT_INDEX -> {
+ latestAvailableHintIndex = nextHelpIndexToShow.nextAvailableHintIndex
+ }
+ SHOW_SOLUTION -> solutionIsAvailable = true
+ else -> {} // Nothing else to do.
+ }
+
+ // Only indicate the hint is available if its index is actually new (including if it
+ // becomes null such as in the case of the solution becoming available).
+ if (nextHelpIndexToShow != previousHelpIndex) {
+ hintMonitor.onHelpIndexChanged()
+ }
+ }
+ }
+
+ /** Implementation of [HintHandler.Factory]. */
+ class FactoryImpl @Inject constructor(
+ @DelayShowInitialHintMillis private val delayShowInitialHintMs: Long,
+ @DelayShowAdditionalHintsMillis private val delayShowAdditionalHintsMs: Long,
+ @DelayShowAdditionalHintsFromWrongAnswerMillis
+ private val delayShowAdditionalHintsFromWrongAnswerMs: Long,
+ @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher
+ ) : HintHandler.Factory {
+ override fun create(hintMonitor: HintHandler.HintMonitor): HintHandler {
+ return HintHandlerImpl(
+ delayShowInitialHintMs,
+ delayShowAdditionalHintsMs,
+ delayShowAdditionalHintsFromWrongAnswerMs,
+ backgroundCoroutineDispatcher,
+ hintMonitor
+ )
+ }
+ }
+}
+
+/** Returns whether this state has a solution to show. */
+private fun State.hasSolution(): Boolean = interaction.solution.hasCorrectAnswer()
+
+/** Returns whether this state has help that the user can see. */
+private fun State.offersHelp(): Boolean = interaction.hintList.isNotEmpty() || hasSolution()
diff --git a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt
similarity index 90%
rename from app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt
rename to domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt
index b0c431c64f4..dd2709ed56b 100644
--- a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt
@@ -1,4 +1,4 @@
-package org.oppia.android.app.player.state.hintsandsolution
+package org.oppia.android.domain.hintsandsolution
import dagger.Module
import dagger.Provides
diff --git a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigModule.kt
similarity index 91%
rename from app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt
rename to domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigModule.kt
index 24cde21f077..aead25b5804 100644
--- a/app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigModule.kt
@@ -1,4 +1,4 @@
-package org.oppia.android.app.player.state.hintsandsolution
+package org.oppia.android.domain.hintsandsolution
import dagger.Module
import dagger.Provides
diff --git a/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModule.kt b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModule.kt
new file mode 100644
index 00000000000..85dffd7dd96
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModule.kt
@@ -0,0 +1,11 @@
+package org.oppia.android.domain.hintsandsolution
+
+import dagger.Binds
+import dagger.Module
+
+/** Production module for providing hints & solution related dependencies. */
+@Module
+interface HintsAndSolutionProdModule {
+ @Binds
+ fun provideHintHandlerFactoryImpl(impl: HintHandlerImpl.FactoryImpl): HintHandler.Factory
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt b/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt
index 57dcb1fa7e8..82ecc532d4d 100644
--- a/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt
@@ -4,14 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.oppia.android.app.model.AnsweredQuestionOutcome
import org.oppia.android.app.model.EphemeralQuestion
-import org.oppia.android.app.model.Hint
+import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.Question
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.State
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.app.model.UserAssessmentPerformance
import org.oppia.android.domain.classify.AnswerClassificationController
import org.oppia.android.domain.classify.ClassificationResult.OutcomeWithMisconception
+import org.oppia.android.domain.hintsandsolution.HintHandler
import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
import org.oppia.android.domain.question.QuestionAssessmentProgress.TrainStage
import org.oppia.android.util.data.AsyncDataSubscriptionManager
@@ -48,16 +48,20 @@ class QuestionAssessmentProgressController @Inject constructor(
private val dataProviders: DataProviders,
private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager,
private val answerClassificationController: AnswerClassificationController,
- private val exceptionsController: ExceptionsController
-) {
+ private val exceptionsController: ExceptionsController,
+ private val hintHandlerFactory: HintHandler.Factory
+) : HintHandler.HintMonitor {
// TODO(#247): Add support for populating the list of skill IDs to review at the end of the
// training session.
// TODO(#248): Add support for the assessment ending prematurely due to learner demonstrating
// sufficient proficiency.
private val progress = QuestionAssessmentProgress()
+ private lateinit var hintHandler: HintHandler
private val progressLock = ReentrantLock()
- @Inject internal lateinit var scoreCalculatorFactory: QuestionAssessmentCalculation.Factory
+
+ @Inject
+ internal lateinit var scoreCalculatorFactory: QuestionAssessmentCalculation.Factory
private val currentQuestionDataProvider: NestedTransformedDataProvider =
createCurrentQuestionDataProvider(createEmptyQuestionsListDataProvider())
@@ -69,6 +73,7 @@ class QuestionAssessmentProgressController @Inject constructor(
"Cannot start a new training session until the previous one is completed."
}
+ hintHandler = hintHandlerFactory.create(this)
progress.advancePlayStageTo(TrainStage.LOADING_TRAINING_SESSION)
currentQuestionDataProvider.setBaseDataProvider(
questionsListDataProvider,
@@ -90,6 +95,10 @@ class QuestionAssessmentProgressController @Inject constructor(
}
}
+ override fun onHelpIndexChanged() {
+ asyncDataSubscriptionManager.notifyChangeAsync(CREATE_CURRENT_QUESTION_DATA_PROVIDER_ID)
+ }
+
/**
* Submits an answer to the current question and returns how the UI should respond to this answer.
* The returned [LiveData] will only have at most two results posted: a pending result, and then a
@@ -154,16 +163,22 @@ class QuestionAssessmentProgressController @Inject constructor(
// Do not proceed unless the user submitted the correct answer.
if (answeredQuestionOutcome.isCorrectAnswer) {
progress.completeCurrentQuestion()
- if (!progress.isAssessmentCompleted()) {
+ val newState = if (!progress.isAssessmentCompleted()) {
// Only push the next state if the assessment isn't completed.
- progress.stateDeck.pushState(progress.getNextState(), prohibitSameStateName = false)
+ progress.getNextState()
} else {
// Otherwise, push a synthetic state for the end of the session.
- progress.stateDeck.pushState(
- State.getDefaultInstance(),
- prohibitSameStateName = false
- )
+ State.getDefaultInstance()
}
+ progress.stateDeck.pushState(newState, prohibitSameStateName = false)
+ hintHandler.finishState(newState)
+ } else {
+ // Schedule a new hints or solution or show a new hint or solution immediately based on
+ // the current ephemeral state of the training session because a new wrong answer was
+ // submitted.
+ hintHandler.handleWrongAnswerSubmission(
+ computeCurrentEphemeralState().pendingState.wrongAnswerCount
+ )
}
} finally {
// Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck
@@ -182,11 +197,15 @@ class QuestionAssessmentProgressController @Inject constructor(
}
}
- fun submitHintIsRevealed(
- state: State,
- hintIsRevealed: Boolean,
- hintIndex: Int
- ): LiveData> {
+ /**
+ * Notifies the controller that the user wishes to reveal a hint.
+ *
+ * @param hintIndex index of the hint that was revealed in the hint list of the current pending
+ * state
+ * @return a one-time [LiveData] that indicates success/failure of the operation (the actual
+ * payload of the result isn't relevant)
+ */
+ fun submitHintIsRevealed(hintIndex: Int): LiveData> {
try {
progressLock.withLock {
check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) {
@@ -198,16 +217,9 @@ class QuestionAssessmentProgressController @Inject constructor(
check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) {
"Cannot submit an answer while another answer is pending."
}
- lateinit var hint: Hint
try {
- progress.stateDeck.submitHintRevealed(state, hintIsRevealed, hintIndex)
- hint = progress.stateList.computeHintForResult(
- state,
- hintIsRevealed,
- hintIndex
- )
- progress.stateDeck.pushStateForHint(state, hintIndex)
progress.trackHintViewed()
+ hintHandler.viewHint(hintIndex)
} finally {
// Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck
// in an 'always showing hint' situation. This can specifically happen if hint throws an
@@ -215,7 +227,7 @@ class QuestionAssessmentProgressController @Inject constructor(
progress.advancePlayStageTo(TrainStage.VIEWING_STATE)
}
asyncDataSubscriptionManager.notifyChangeAsync(CREATE_CURRENT_QUESTION_DATA_PROVIDER_ID)
- return MutableLiveData(AsyncResult.success(hint))
+ return MutableLiveData(AsyncResult.success(null))
}
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
@@ -223,7 +235,13 @@ class QuestionAssessmentProgressController @Inject constructor(
}
}
- fun submitSolutionIsRevealed(state: State): LiveData> {
+ /**
+ * Notifies the controller that the user has revealed the solution to the current state.
+ *
+ * @return a one-time [LiveData] that indicates success/failure of the operation (the actual
+ * payload of the result isn't relevant)
+ */
+ fun submitSolutionIsRevealed(): LiveData> {
try {
progressLock.withLock {
check(progress.trainStage != TrainStage.NOT_IN_TRAINING_SESSION) {
@@ -235,13 +253,9 @@ class QuestionAssessmentProgressController @Inject constructor(
check(progress.trainStage != TrainStage.SUBMITTING_ANSWER) {
"Cannot submit an answer while another answer is pending."
}
- lateinit var solution: Solution
try {
-
- progress.stateDeck.submitSolutionRevealed(state)
- solution = progress.stateList.computeSolutionForResult(state)
- progress.stateDeck.pushStateForSolution(state)
progress.trackSolutionViewed()
+ hintHandler.viewSolution()
} finally {
// Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck
// in an 'always showing solution' situation. This can specifically happen if solution
@@ -250,7 +264,7 @@ class QuestionAssessmentProgressController @Inject constructor(
}
asyncDataSubscriptionManager.notifyChangeAsync(CREATE_CURRENT_QUESTION_DATA_PROVIDER_ID)
- return MutableLiveData(AsyncResult.success(solution))
+ return MutableLiveData(AsyncResult.success(null))
}
} catch (e: Exception) {
exceptionsController.logNonFatalException(e)
@@ -284,6 +298,9 @@ class QuestionAssessmentProgressController @Inject constructor(
progress.stateDeck.navigateToNextState()
// Track whether the learner has moved to a new card.
if (progress.isViewingMostRecentQuestion()) {
+ // Update the hint state and maybe schedule new help when user moves to the pending top
+ // state.
+ hintHandler.navigateBackToLatestPendingState()
progress.processNavigationToNewQuestion()
}
asyncDataSubscriptionManager.notifyChangeAsync(CREATE_CURRENT_QUESTION_DATA_PROVIDER_ID)
@@ -337,14 +354,21 @@ class QuestionAssessmentProgressController @Inject constructor(
}
}
- private suspend fun retrieveUserAssessmentPerformanceAsync(skillIdList: List):
- AsyncResult {
- progressLock.withLock {
- val scoreCalculator =
- scoreCalculatorFactory.create(skillIdList, progress.questionSessionMetrics)
- return AsyncResult.success(scoreCalculator.computeAll())
- }
+ private fun computeCurrentEphemeralState(): EphemeralState {
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+ return progress.stateDeck.getCurrentEphemeralState(helpIndex)
+ }
+
+ @Suppress("RedundantSuspendModifier")
+ private suspend fun retrieveUserAssessmentPerformanceAsync(
+ skillIdList: List
+ ): AsyncResult {
+ progressLock.withLock {
+ val scoreCalculator =
+ scoreCalculatorFactory.create(skillIdList, progress.questionSessionMetrics)
+ return AsyncResult.success(scoreCalculator.computeAll())
}
+ }
private fun createCurrentQuestionDataProvider(
questionsListDataProvider: DataProvider>
@@ -385,10 +409,9 @@ class QuestionAssessmentProgressController @Inject constructor(
}
private fun retrieveEphemeralQuestionState(questionsList: List): EphemeralQuestion {
- val ephemeralState = progress.stateDeck.getCurrentEphemeralState()
val currentQuestionIndex = progress.getCurrentQuestionIndex()
val ephemeralQuestionBuilder = EphemeralQuestion.newBuilder()
- .setEphemeralState(ephemeralState)
+ .setEphemeralState(computeCurrentEphemeralState())
.setCurrentQuestionIndex(currentQuestionIndex)
.setTotalQuestionCount(progress.getTotalQuestionCount())
.setInitialTotalQuestionCount(progress.getTotalQuestionCount())
@@ -401,6 +424,8 @@ class QuestionAssessmentProgressController @Inject constructor(
private fun initializeAssessment(questionsList: List) {
check(questionsList.isNotEmpty()) { "Cannot start a training session with zero questions." }
progress.initialize(questionsList)
+ // Update hint state to schedule task to show new help.
+ hintHandler.startWatchingForHintsInNewState(progress.stateDeck.getCurrentState())
}
/** Returns a temporary [DataProvider] that always provides an empty list of [Question]s. */
diff --git a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt
index 9bb1fb28bc8..25bfc89898f 100644
--- a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt
+++ b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt
@@ -5,9 +5,8 @@ import org.oppia.android.app.model.CompletedState
import org.oppia.android.app.model.CompletedStateInCheckpoint
import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.ExplorationCheckpoint
-import org.oppia.android.app.model.Hint
+import org.oppia.android.app.model.HelpIndex
import org.oppia.android.app.model.PendingState
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.State
import org.oppia.android.app.model.SubtitledHtml
import org.oppia.android.app.model.UserAnswer
@@ -25,24 +24,14 @@ internal class StateDeck internal constructor(
private var pendingTopState: State = initialState
private val previousStates: MutableList = ArrayList()
private val currentDialogInteractions: MutableList = ArrayList()
- private val hintList: MutableList = ArrayList()
- private lateinit var solution: Solution
private var stateIndex: Int = 0
- // The value -1 indicates that hint has not been revealed yet.
- private var revealedHintIndex: Int = -1
- private var solutionIsRevealed: Boolean = false
/** Resets this deck to a new, specified initial [State]. */
internal fun resetDeck(initialState: State) {
pendingTopState = initialState
previousStates.clear()
currentDialogInteractions.clear()
- hintList.clear()
stateIndex = 0
- // Initialize the variable revealedHintIndex with -1 to indicate that no hint has been
- // revealed yet.
- revealedHintIndex = -1
- solutionIsRevealed = false
}
/** Navigates to the previous State in the deck, or fails if this isn't possible. */
@@ -72,14 +61,22 @@ internal class StateDeck internal constructor(
/** Returns the index of the current selected card of the deck. */
internal fun getTopStateIndex(): Int = stateIndex
+ /** Returns the current [State] being viewed by the learner. */
+ internal fun getCurrentState(): State {
+ return when {
+ isCurrentStateTopOfDeck() -> pendingTopState
+ else -> previousStates[stateIndex].state
+ }
+ }
+
/** Returns the current [EphemeralState] the learner is viewing. */
- internal fun getCurrentEphemeralState(): EphemeralState {
+ internal fun getCurrentEphemeralState(helpIndex: HelpIndex): EphemeralState {
// Note that the terminal state is evaluated first since it can only return true if the current state is the top
// of the deck, and that state is the terminal one. Otherwise the terminal check would never be triggered since
// the second case assumes the top of the deck must be pending.
return when {
isCurrentStateTerminal() -> getCurrentTerminalState()
- stateIndex == previousStates.size -> getCurrentPendingState()
+ isCurrentStateTopOfDeck() -> getCurrentPendingState(helpIndex)
else -> getPreviousState()
}
}
@@ -115,47 +112,7 @@ internal class StateDeck internal constructor(
.setCompletedState(CompletedState.newBuilder().addAllAnswer(currentDialogInteractions))
.build()
currentDialogInteractions.clear()
- hintList.clear()
pendingTopState = state
- // Re-initialize the variable revealedHintIndex with -1 to indicate that no hint has been
- // revealed on the new pendingTopState.
- revealedHintIndex = -1
- solutionIsRevealed = false
- }
-
- internal fun pushStateForHint(state: State, hintIndex: Int): EphemeralState {
- val interactionBuilder = state.interaction.toBuilder().setHint(
- hintIndex,
- hintList.get(0)
- )
- val newState = state.toBuilder().setInteraction(interactionBuilder).build()
- val ephemeralState = EphemeralState.newBuilder()
- .setState(newState)
- .setHasPreviousState(!isCurrentStateInitial())
- .setPendingState(
- PendingState.newBuilder().addAllWrongAnswer(currentDialogInteractions).addAllHint(hintList)
- )
- .build()
- pendingTopState = newState
- hintList.clear()
- // Increment the value of revealHintIndex by 1 every-time a new hint is revealed.
- revealedHintIndex++
- return ephemeralState
- }
-
- internal fun pushStateForSolution(state: State): EphemeralState {
- val interactionBuilder = state.interaction.toBuilder().setSolution(solution)
- val newState = state.toBuilder().setInteraction(interactionBuilder).build()
- val ephemeralState = EphemeralState.newBuilder()
- .setState(newState)
- .setHasPreviousState(!isCurrentStateInitial())
- .setPendingState(
- PendingState.newBuilder().addAllWrongAnswer(currentDialogInteractions).addAllHint(hintList)
- )
- .build()
- pendingTopState = newState
- solutionIsRevealed = true
- return ephemeralState
}
/**
@@ -172,22 +129,6 @@ internal class StateDeck internal constructor(
.build()
}
- internal fun submitHintRevealed(state: State, hintIsRevealed: Boolean, hintIndex: Int) {
- hintList += Hint.newBuilder()
- .setHintIsRevealed(hintIsRevealed)
- .setHintContent(state.interaction.getHint(hintIndex).hintContent)
- .build()
- }
-
- internal fun submitSolutionRevealed(state: State) {
- solution = Solution.newBuilder()
- .setSolutionIsRevealed(true)
- .setAnswerIsExclusive(state.interaction.solution.answerIsExclusive)
- .setCorrectAnswer(state.interaction.solution.correctAnswer)
- .setExplanation(state.interaction.solution.explanation)
- .build()
- }
-
/**
* Returns an [ExplorationCheckpoint] which contains all the latest values of variables of the
* [StateDeck] that are used in light weight checkpointing.
@@ -195,7 +136,8 @@ internal class StateDeck internal constructor(
internal fun createExplorationCheckpoint(
explorationVersion: Int,
explorationTitle: String,
- timestamp: Long
+ timestamp: Long,
+ helpIndex: HelpIndex
): ExplorationCheckpoint {
return ExplorationCheckpoint.newBuilder().apply {
addAllCompletedStatesInCheckpoint(
@@ -207,22 +149,23 @@ internal class StateDeck internal constructor(
}
)
pendingStateName = pendingTopState.name
- hintIndex = revealedHintIndex
addAllPendingUserAnswers(currentDialogInteractions)
- this.solutionIsRevealed = this@StateDeck.solutionIsRevealed
this.stateIndex = this@StateDeck.stateIndex
this.explorationVersion = explorationVersion
this.explorationTitle = explorationTitle
timestampOfFirstCheckpoint = timestamp
+ this.helpIndex = helpIndex
}.build()
}
- private fun getCurrentPendingState(): EphemeralState {
+ private fun getCurrentPendingState(helpIndex: HelpIndex): EphemeralState {
return EphemeralState.newBuilder()
.setState(pendingTopState)
.setHasPreviousState(!isCurrentStateInitial())
.setPendingState(
- PendingState.newBuilder().addAllWrongAnswer(currentDialogInteractions).addAllHint(hintList)
+ PendingState.newBuilder()
+ .addAllWrongAnswer(currentDialogInteractions)
+ .setHelpIndex(helpIndex)
)
.build()
}
diff --git a/domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt b/domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt
index 0e41e54efd6..deb47cea13a 100644
--- a/domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt
+++ b/domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt
@@ -1,9 +1,7 @@
package org.oppia.android.domain.state
import org.oppia.android.app.model.AnswerOutcome
-import org.oppia.android.app.model.Hint
import org.oppia.android.app.model.Outcome
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.State
/**
@@ -39,28 +37,4 @@ internal class StateGraph internal constructor(
}
return answerOutcomeBuilder.build()
}
-
- /** Returns an [Hint] based on the current state and revealed [Hint] from the learner's answer. */
- internal fun computeHintForResult(
- currentState: State,
- hintIsRevealed: Boolean,
- hintIndex: Int
- ): Hint {
- return Hint.newBuilder()
- .setHintIsRevealed(hintIsRevealed)
- .setHintContent(currentState.interaction.getHint(hintIndex).hintContent)
- .setState(currentState)
- .build()
- }
-
- /** Returns an [Solution] based on the current state and revealed [Solution] from the learner's answer. */
- internal fun computeSolutionForResult(
- currentState: State
- ): Solution {
- return Solution.newBuilder()
- .setSolutionIsRevealed(true)
- .setAnswerIsExclusive(currentState.interaction.solution.answerIsExclusive)
- .setCorrectAnswer(currentState.interaction.solution.correctAnswer)
- .setExplanation(currentState.interaction.solution.explanation).build()
- }
}
diff --git a/domain/src/main/java/org/oppia/android/domain/state/StateList.kt b/domain/src/main/java/org/oppia/android/domain/state/StateList.kt
index 4db24a9cee6..57459e6d727 100644
--- a/domain/src/main/java/org/oppia/android/domain/state/StateList.kt
+++ b/domain/src/main/java/org/oppia/android/domain/state/StateList.kt
@@ -1,10 +1,8 @@
package org.oppia.android.domain.state
import org.oppia.android.app.model.AnsweredQuestionOutcome
-import org.oppia.android.app.model.Hint
import org.oppia.android.app.model.Outcome
import org.oppia.android.app.model.Question
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.State
/**
@@ -36,28 +34,4 @@ internal class StateList(
.setIsCorrectAnswer(outcome.labelledAsCorrect)
.build()
}
-
- /** Returns an [Hint] based on the current state and revealed [Hint] from the learner's answer. */
- internal fun computeHintForResult(
- currentState: State,
- hintIsRevealed: Boolean,
- hintIndex: Int
- ): Hint {
- return Hint.newBuilder()
- .setHintIsRevealed(hintIsRevealed)
- .setHintContent(currentState.interaction.getHint(hintIndex).hintContent)
- .setState(currentState)
- .build()
- }
-
- /** Returns an [Solution] based on the current state and revealed [Solution] from the learner's answer. */
- internal fun computeSolutionForResult(
- currentState: State
- ): Solution {
- return Solution.newBuilder()
- .setSolutionIsRevealed(true)
- .setAnswerIsExclusive(currentState.interaction.solution.answerIsExclusive)
- .setCorrectAnswer(currentState.interaction.solution.correctAnswer)
- .setExplanation(currentState.interaction.solution.explanation).build()
- }
}
diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt
index 639f617f6d3..42e073c1647 100644
--- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt
@@ -35,6 +35,8 @@ import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModu
import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageDatabaseSize
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.topic.FRACTIONS_EXPLORATION_ID_0
import org.oppia.android.domain.topic.FRACTIONS_EXPLORATION_ID_1
@@ -343,7 +345,8 @@ class ExplorationDataControllerTest {
DragDropSortInputModule::class, InteractionsModule::class, TestLogReportingModule::class,
ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class,
RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
- TestExplorationStorageModule::class, NetworkConnectionUtilDebugModule::class
+ TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class,
+ HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt
index 09ef21767fd..6279493d8b7 100644
--- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat
import dagger.BindsInstance
import dagger.Component
import dagger.Module
@@ -33,14 +34,13 @@ import org.oppia.android.app.model.EphemeralState.StateTypeCase.PENDING_STATE
import org.oppia.android.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE
import org.oppia.android.app.model.ExplorationCheckpoint
import org.oppia.android.app.model.Fraction
-import org.oppia.android.app.model.Hint
+import org.oppia.android.app.model.HelpIndex
import org.oppia.android.app.model.InteractionObject
import org.oppia.android.app.model.ListOfSetsOfTranslatableHtmlContentIds
import org.oppia.android.app.model.Point2d
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.RatioExpression
import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.TranslatableHtmlContentId
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.domain.classify.InteractionsModule
@@ -56,6 +56,10 @@ import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageDatabaseSize
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.hintsandsolution.isHintRevealed
+import org.oppia.android.domain.hintsandsolution.isSolutionRevealed
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2
import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_4
@@ -88,6 +92,7 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import java.io.FileNotFoundException
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -148,10 +153,10 @@ class ExplorationProgressControllerTest {
lateinit var mockAsyncAnswerOutcomeObserver: Observer>
@Mock
- lateinit var mockAsyncHintObserver: Observer>
+ lateinit var mockAsyncHintObserver: Observer>
@Mock
- lateinit var mockAsyncSolutionObserver: Observer>
+ lateinit var mockAsyncSolutionObserver: Observer>
@Captor
lateinit var currentStateResultCaptor: ArgumentCaptor>
@@ -1046,7 +1051,7 @@ class ExplorationProgressControllerTest {
}
@Test
- fun testRevealHint_forWrongAnswer_showHint_returnHintIsRevealed() {
+ fun testRevealHint_forWrongAnswers_showHint_returnHintIsRevealed() {
subscribeToCurrentStateToAllowExplorationToLoad()
playExploration(
profileId.internalId,
@@ -1056,7 +1061,10 @@ class ExplorationProgressControllerTest {
shouldSavePartialProgress = false
)
navigateToPrototypeFractionInputState()
+ // Submit 2 wrong answers to trigger a hint becoming available.
submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+ verifyOperationSucceeds(explorationProgressController.submitHintIsRevealed(hintIndex = 0))
// Verify that the current state updates. It should stay pending, on submission of wrong answer.
verify(
@@ -1066,32 +1074,14 @@ class ExplorationProgressControllerTest {
assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
val currentState = currentStateResultCaptor.value.getOrThrow()
- val result = explorationProgressController.submitHintIsRevealed(
- state = currentState.state,
- hintIsRevealed = true,
- hintIndex = 0,
- )
- result.observeForever(mockAsyncHintObserver)
- testCoroutineDispatchers.runCurrent()
-
- assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE)
-
val hintAndSolution = currentState.state.interaction.getHint(0)
+ assertThat(currentState.stateTypeCase).isEqualTo(PENDING_STATE)
assertThat(hintAndSolution.hintContent.html).contains("Remember that two halves")
-
- // Verify that the current state updates. Hint revealed is true.
- verify(
- mockCurrentStateLiveDataObserver,
- atLeastOnce()
- ).onChanged(currentStateResultCaptor.capture())
- assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
- val updatedState = currentStateResultCaptor.value.getOrThrow()
-
- assertThat(updatedState.state.interaction.getHint(0).hintIsRevealed).isTrue()
+ assertThat(currentState.isHintRevealed(0)).isTrue()
}
@Test
- fun testRevealSolution_forWrongAnswer_showSolution_returnSolutionIsRevealed() {
+ fun testRevealSolution_triggeredSolution_showSolution_returnSolutionIsRevealed() {
subscribeToCurrentStateToAllowExplorationToLoad()
playExploration(
profileId.internalId,
@@ -1101,7 +1091,13 @@ class ExplorationProgressControllerTest {
shouldSavePartialProgress = false
)
navigateToPrototypeFractionInputState()
+ // Submit 2 wrong answers to trigger the hint.
+ submitWrongAnswerForPrototypeState2()
submitWrongAnswerForPrototypeState2()
+ // Reveal the hint, then submit another wrong answer to trigger the solution.
+ verifyOperationSucceeds(explorationProgressController.submitHintIsRevealed(hintIndex = 0))
+ submitWrongAnswerForPrototypeState2()
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
// Verify that the current state updates. It should stay pending, on submission of wrong answer.
verify(
@@ -1111,7 +1107,7 @@ class ExplorationProgressControllerTest {
assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
val currentState = currentStateResultCaptor.value.getOrThrow()
- val result = explorationProgressController.submitSolutionIsRevealed(currentState.state)
+ val result = explorationProgressController.submitSolutionIsRevealed()
result.observeForever(mockAsyncSolutionObserver)
testCoroutineDispatchers.runCurrent()
@@ -1125,7 +1121,221 @@ class ExplorationProgressControllerTest {
assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
val updatedState = currentStateResultCaptor.value.getOrThrow()
- assertThat(updatedState.state.interaction.solution.solutionIsRevealed).isTrue()
+ assertThat(updatedState.isSolutionRevealed()).isTrue()
+ }
+
+ @Test
+ fun testHintsAndSolution_noHintVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+
+ // Verify that the helpIndex.IndexTypeCase is equal to INDEX_TYPE_NOT_SET because no hint
+ // is visible yet.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.INDEXTYPE_NOT_SET)
+ }
+
+ @Test
+ fun testHintsAndSolution_wait60Seconds_unrevealedHintIsVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ // Make the first hint visible by submitting two wrong answers.
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(60))
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the helpIndex.IndexTypeCase is equal AVAILABLE_NEXT_HINT_HINT_INDEX because a new
+ // unrevealed hint is visible.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isFalse()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX)
+ assertThat(currentState.pendingState.helpIndex.nextAvailableHintIndex).isEqualTo(0)
+ }
+
+ @Test
+ fun testHintsAndSolution_submitTwoWrongAnswers_unrevealedHintIsVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ // Make the first hint visible by submitting two wrong answers.
+ submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+
+ // Verify that the helpIndex.IndexTypeCase is equal AVAILABLE_NEXT_HINT_HINT_INDEX because a new
+ // unrevealed hint is visible.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isFalse()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX)
+ assertThat(currentState.pendingState.helpIndex.nextAvailableHintIndex).isEqualTo(0)
+ }
+
+ @Test
+ fun testHintsAndSolution_revealedHintIsVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+
+ val result = explorationProgressController.submitHintIsRevealed(hintIndex = 0)
+ result.observeForever(mockAsyncHintObserver)
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the helpIndex.IndexTypeCase is equal LATEST_REVEALED_HINT_INDEX because a new
+ // revealed hint is visible.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isTrue()
+ assertThat(currentState.isSolutionRevealed()).isFalse()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX)
+ assertThat(currentState.pendingState.helpIndex.latestRevealedHintIndex).isEqualTo(0)
+ }
+
+ @Test
+ fun testHintsAndSolution_allHintsVisible_wait30Seconds_solutionVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+
+ val result = explorationProgressController.submitHintIsRevealed(hintIndex = 0)
+ result.observeForever(mockAsyncHintObserver)
+ testCoroutineDispatchers.runCurrent()
+
+ // The solution should be visible after 30 seconds of the last hint being reveled.
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30))
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the helpIndex.IndexTypeCase is equal SHOW_SOLUTION because unrevealed solution is
+ // visible.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isTrue()
+ assertThat(currentState.isSolutionRevealed()).isFalse()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION)
+ }
+
+ @Test
+ fun testHintAndSol_hintsVisible_submitWrongAns_wait10Second_solVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+
+ val result = explorationProgressController.submitHintIsRevealed(hintIndex = 0)
+ result.observeForever(mockAsyncHintObserver)
+ testCoroutineDispatchers.runCurrent()
+
+ submitWrongAnswerForPrototypeState2()
+ // The solution should be visible after 10 seconds becuase one wrong answer was submitted.
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the helpIndex.IndexTypeCase is equal SHOW_SOLUTION because unrevealed solution is
+ // visible.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isTrue()
+ assertThat(currentState.isSolutionRevealed()).isFalse()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION)
+ }
+
+ @Test
+ fun testHintsAndSolution_revealedSolutionIsVisible_checkHelpIndexIsCorrect() {
+ subscribeToCurrentStateToAllowExplorationToLoad()
+ playExploration(
+ profileId.internalId,
+ TEST_TOPIC_ID_0,
+ TEST_STORY_ID_0,
+ TEST_EXPLORATION_ID_2,
+ shouldSavePartialProgress = true
+ )
+ playThroughPrototypeState1AndMoveToNextState()
+ submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+
+ val hintResult = explorationProgressController.submitHintIsRevealed(hintIndex = 0)
+ hintResult.observeForever(mockAsyncHintObserver)
+ testCoroutineDispatchers.runCurrent()
+
+ // The solution should be visible after 30 seconds of the last hint being reveled.
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30))
+ testCoroutineDispatchers.runCurrent()
+
+ val solutionResult = explorationProgressController.submitSolutionIsRevealed()
+ solutionResult.observeForever(mockAsyncSolutionObserver)
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the helpIndex.IndexTypeCase is equal EVERYTHING_IS_REVEALED because a new the
+ // solution has been revealed.
+ verify(mockCurrentStateLiveDataObserver, atLeastOnce())
+ .onChanged(currentStateResultCaptor.capture())
+ assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
+ val currentState = currentStateResultCaptor.value.getOrThrow()
+ assertThat(currentState.isHintRevealed(0)).isTrue()
+ assertThat(currentState.isSolutionRevealed()).isTrue()
+ assertThat(currentState.pendingState.helpIndex.indexTypeCase)
+ .isEqualTo(HelpIndex.IndexTypeCase.EVERYTHING_REVEALED)
}
@Test
@@ -2028,6 +2238,8 @@ class ExplorationProgressControllerTest {
shouldSavePartialProgress = true
)
navigateToPrototypeFractionInputState()
+ // Submit 2 wrong answers to trigger the hint.
+ submitWrongAnswerForPrototypeState2()
submitWrongAnswerForPrototypeState2()
verify(
@@ -2035,20 +2247,15 @@ class ExplorationProgressControllerTest {
atLeastOnce()
).onChanged(currentStateResultCaptor.capture())
assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
- val currentState = currentStateResultCaptor.value.getOrThrow()
- val result = explorationProgressController.submitHintIsRevealed(
- state = currentState.state,
- hintIsRevealed = true,
- hintIndex = 0,
- )
- result.observeForever(mockAsyncHintObserver)
- testCoroutineDispatchers.runCurrent()
+ verifyOperationSucceeds(explorationProgressController.submitHintIsRevealed(hintIndex = 0))
- verifyCheckpointHasCorrectHintIndex(
+ verifyCheckpointHasCorrectHelpIndex(
profileId,
TEST_EXPLORATION_ID_2,
- indexOfRevealedHint = 0
+ helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
)
}
@@ -2063,23 +2270,28 @@ class ExplorationProgressControllerTest {
shouldSavePartialProgress = true
)
navigateToPrototypeFractionInputState()
+ // Submit 2 wrong answers to trigger the hint.
submitWrongAnswerForPrototypeState2()
+ submitWrongAnswerForPrototypeState2()
+ // Reveal the hint, then submit another wrong answer to trigger the solution.
+ verifyOperationSucceeds(explorationProgressController.submitHintIsRevealed(hintIndex = 0))
+ submitWrongAnswerForPrototypeState2()
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
verify(
mockCurrentStateLiveDataObserver,
atLeastOnce()
).onChanged(currentStateResultCaptor.capture())
assertThat(currentStateResultCaptor.value.isSuccess()).isTrue()
- val currentState = currentStateResultCaptor.value.getOrThrow()
- val result = explorationProgressController.submitSolutionIsRevealed(currentState.state)
- result.observeForever(mockAsyncSolutionObserver)
- testCoroutineDispatchers.runCurrent()
+ verifyOperationSucceeds(explorationProgressController.submitSolutionIsRevealed())
- verifyCheckpointHasCorrectValueOfIsSolutionRevealed(
+ verifyCheckpointHasCorrectHelpIndex(
profileId,
TEST_EXPLORATION_ID_2,
- isSolutionRevealed = true
+ helpIndex = HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
)
}
@@ -2545,6 +2757,13 @@ class ExplorationProgressControllerTest {
return UserAnswer.newBuilder().setAnswer(answer).setPlainAnswer(answer.toAnswerString()).build()
}
+ private fun EphemeralState.isHintRevealed(hintIndex: Int): Boolean {
+ return pendingState.helpIndex.isHintRevealed(hintIndex, state.interaction.hintList)
+ }
+
+ private fun EphemeralState.isSolutionRevealed(): Boolean =
+ pendingState.helpIndex.isSolutionRevealed()
+
private fun verifyCheckpointHasCorrectPendingStateName(
profileId: ProfileId,
explorationId: String,
@@ -2614,33 +2833,10 @@ class ExplorationProgressControllerTest {
.isEqualTo(stateIndex)
}
- private fun verifyCheckpointHasCorrectHintIndex(
- profileId: ProfileId,
- explorationId: String,
- indexOfRevealedHint: Int
- ) {
- testCoroutineDispatchers.runCurrent()
- reset(mockExplorationCheckpointObserver)
- val explorationCheckpointLiveData =
- explorationCheckpointController.retrieveExplorationCheckpoint(
- profileId,
- explorationId
- ).toLiveData()
- explorationCheckpointLiveData.observeForever(mockExplorationCheckpointObserver)
- testCoroutineDispatchers.runCurrent()
-
- verify(mockExplorationCheckpointObserver, atLeastOnce())
- .onChanged(explorationCheckpointCaptor.capture())
- assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue()
-
- assertThat(explorationCheckpointCaptor.value.getOrThrow().hintIndex)
- .isEqualTo(indexOfRevealedHint)
- }
-
- private fun verifyCheckpointHasCorrectValueOfIsSolutionRevealed(
+ private fun verifyCheckpointHasCorrectHelpIndex(
profileId: ProfileId,
explorationId: String,
- isSolutionRevealed: Boolean
+ helpIndex: HelpIndex
) {
testCoroutineDispatchers.runCurrent()
reset(mockExplorationCheckpointObserver)
@@ -2656,8 +2852,7 @@ class ExplorationProgressControllerTest {
.onChanged(explorationCheckpointCaptor.capture())
assertThat(explorationCheckpointCaptor.value.isSuccess()).isTrue()
- assertThat(explorationCheckpointCaptor.value.getOrThrow().solutionIsRevealed)
- .isEqualTo(isSolutionRevealed)
+ assertThat(explorationCheckpointCaptor.value.getOrThrow().helpIndex).isEqualTo(helpIndex)
}
/**
@@ -2757,7 +2952,8 @@ class ExplorationProgressControllerTest {
DragDropSortInputModule::class, InteractionsModule::class, TestLogReportingModule::class,
ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class,
RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
- TestExplorationStorageModule::class, NetworkConnectionUtilDebugModule::class
+ TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class,
+ HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensionsTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensionsTest.kt
new file mode 100644
index 00000000000..a18f846f451
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HelpIndexExtensionsTest.kt
@@ -0,0 +1,311 @@
+package org.oppia.android.domain.hintsandsolution
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.HelpIndex
+import org.oppia.android.app.model.Hint
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+
+/** Tests for [HelpIndex] extensions. */
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(manifest = Config.NONE)
+class HelpIndexExtensionsTest {
+ @Suppress("PrivatePropertyName")
+ private val HINT_LIST_OF_SIZE_2 = listOf(Hint.getDefaultInstance(), Hint.getDefaultInstance())
+
+ @Suppress("PrivatePropertyName")
+ private val HINT_LIST_OF_SIZE_3 = listOf(
+ Hint.getDefaultInstance(), Hint.getDefaultInstance(), Hint.getDefaultInstance()
+ )
+
+ @Test
+ fun testIsHintRevealed_defaultHelpIndex_returnsFalse() {
+ val helpIndex = HelpIndex.getDefaultInstance()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, hintList = listOf())
+
+ // An unknown or initial HelpIndex state means no hints have yet been viewed.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex0_index0_hintListSize3_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_3)
+
+ // This hint is available, but hasn't yet been viewed.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex1_index0_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_3)
+
+ // The next hint being available implies the previous must have been seen.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex1_index1_hintListSize3_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_3)
+
+ // This hint is available to view, but hasn't been yet.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex2_index0_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 2
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_3)
+
+ // Two hints from now is available, so this one must have been viewed.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex2_index1_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 2
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_3)
+
+ // The next hint is available, so this one must have been viewed.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_availableHintIndex2_index2_hintListSize3_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 2
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 2, HINT_LIST_OF_SIZE_3)
+
+ // This hint is available to view, but hasn't yet been viewed.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_lastRevealedHintIndex0_index0_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_3)
+
+ // The revealed index matches the one being checked.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_lastRevealedHintIndex0_index1_hintListSize3_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_3)
+
+ // This hint hasn't yet been revealed, but the previous one has.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_lastRevealedHintIndex1_index0_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_3)
+
+ // The next hint has been revealed.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_lastRevealedHintIndex1_index1_hintListSize3_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_3)
+
+ // This hint has been revealed.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_showSolution_index0_hintListSize2_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_2)
+
+ // A viewable solution means all previous hints must have been seen.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_showSolution_index1_hintListSize2_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_2)
+
+ // A viewable solution means all previous hints must have been seen.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_showSolution_index0_hintListSize0_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, hintList = listOf())
+
+ // Despite the solution being visible, no hints means that no hints could have been viewed.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsHintRevealed_everythingRevealed_index0_hintListSize2_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, HINT_LIST_OF_SIZE_2)
+
+ // Everything has been revealed including all past hints.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_everythingRevealed_index1_hintListSize2_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 1, HINT_LIST_OF_SIZE_2)
+
+ // Everything has been revealed including all past hints.
+ assertThat(hintIsRevealed).isTrue()
+ }
+
+ @Test
+ fun testIsHintRevealed_everythingRevealed_index0_hintListSize0_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ val hintIsRevealed = helpIndex.isHintRevealed(hintIndex = 0, hintList = listOf())
+
+ // Despite everything being visible, no hints means that no hints could have been viewed.
+ assertThat(hintIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_defaultHelpIndex_returnsFalse() {
+ val helpIndex = HelpIndex.getDefaultInstance()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // The default state indicates nothing has been viewed yet, including the solution.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_availableHint0_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // If a hint is available to view, the solution could not yet have been revealed.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_availableHint1_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // If a hint is available to view, the solution could not yet have been revealed.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_viewedHint0_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // If a hint was viewed, the solution might eventually be available to view but it evidently
+ // hasn't yet been revealed.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_viewedHint1_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // If a hint was viewed, the solution might eventually be available to view but it evidently
+ // hasn't yet been revealed.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_showSolution_returnsFalse() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // The solution is now available to view, but it reportedly hasn't yet been revealed.
+ assertThat(solutionIsRevealed).isFalse()
+ }
+
+ @Test
+ fun testIsSolutionRevealed_everythingIsRevealed_returnsTrue() {
+ val helpIndex = HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+
+ val solutionIsRevealed = helpIndex.isSolutionRevealed()
+
+ // If everything has been revealed, that ensures the solution has also been revealed.
+ assertThat(solutionIsRevealed).isTrue()
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerImplTest.kt
new file mode 100644
index 00000000000..7eeba4b44ff
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerImplTest.kt
@@ -0,0 +1,1533 @@
+package org.oppia.android.domain.hintsandsolution
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.oppia.android.app.model.Exploration
+import org.oppia.android.app.model.HelpIndex
+import org.oppia.android.app.model.State
+import org.oppia.android.domain.exploration.ExplorationRetriever
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.assertThrows
+import org.oppia.android.testing.environment.TestEnvironmentConfig
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.util.caching.LoadLessonProtosFromAssets
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.logging.LoggerModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [HintHandlerImpl]. */
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = HintHandlerImplTest.TestApplication::class)
+class HintHandlerImplTest {
+ @Rule
+ @JvmField
+ val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock
+ lateinit var mockHintMonitor: HintHandler.HintMonitor
+
+ @Inject
+ lateinit var hintHandlerImplFactory: HintHandlerImpl.FactoryImpl
+
+ @Inject
+ lateinit var explorationRetriever: ExplorationRetriever
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ private lateinit var hintHandler: HintHandler
+ private val expWithNoHintsOrSolution by lazy {
+ explorationRetriever.loadExploration("test_single_interactive_state_exp_no_hints_no_solution")
+ }
+ private val expWithOneHintAndNoSolution by lazy {
+ explorationRetriever.loadExploration(
+ "test_single_interactive_state_exp_with_one_hint_and_no_solution"
+ )
+ }
+ private val expWithOneHintAndSolution by lazy {
+ explorationRetriever.loadExploration(
+ "test_single_interactive_state_exp_with_one_hint_and_solution"
+ )
+ }
+ private val expWithNoHintsAndOneSolution by lazy {
+ explorationRetriever.loadExploration("test_single_interactive_state_exp_with_only_solution")
+ }
+ private val expWithHintsAndSolution by lazy {
+ explorationRetriever.loadExploration(
+ "test_single_interactive_state_exp_with_hints_and_solution"
+ )
+ }
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ // Use the direct HintHandler factory to avoid testing the module setup.
+ hintHandler = hintHandlerImplFactory.create(mockHintMonitor)
+ }
+
+ /* Tests for startWatchingForHintsInNewState */
+
+ @Test
+ fun testStartWatchingForHints_stateWithoutHints_callsMonitor() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithoutHints_helpIndexIsEmpty() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithoutHints_wait60Seconds_monitorNotCalledAgain() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithoutHints_wait60Seconds_helpIndexIsEmpty() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ waitFor60Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_callsMonitor() {
+ val state = expWithHintsAndSolution.getInitialState()
+
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_helpIndexIsEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_wait10Seconds_doesNotCallMonitorAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_wait30Seconds_doesNotCallMonitorAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_wait60Seconds_callsMonitorAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // Verify that the monitor is called again (since there's a hint now available).
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testStartWatchingForHints_stateWithHints_wait60Seconds_firstHintIsAvailable() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ waitFor60Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ /* Tests for finishState */
+
+ @Test
+ fun testFinishState_defaultState_callsMonitor() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ // Simulate the default instance case (which can occur specifically for questions).
+ hintHandler.finishState(State.getDefaultInstance())
+
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testFinishState_defaultState_helpIndexIsEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ // Simulate the default instance case (which can occur specifically for questions).
+ hintHandler.finishState(State.getDefaultInstance())
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testFinishState_defaultState_wait60Seconds_monitorNotCalledAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.finishState(State.getDefaultInstance())
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testFinishState_defaultState_wait60Seconds_helpIndexStaysEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.finishState(State.getDefaultInstance())
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testFinishState_newStateWithHints_helpIndexIsEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ // Note that this is slightly suspect: normally, a state would be sourced from an independent
+ // question or from the same exploration. This tactic is taken to simplify the data structure
+ // requirements for the test, and because it should be more or less functionally equivalent.
+ hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState())
+
+ // The help index should be reset.
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testFinishState_newStateWithHints_wait60Seconds_callsMonitorAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState())
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // The index should be called again now that there's a new index.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testFinishState_previousStateFullyRevealed_newStateWithHints_wait60Seconds_indexHasNewHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealEverythingInMultiHintState()
+ hintHandler.finishState(expWithOneHintAndNoSolution.getInitialState())
+
+ waitFor60Seconds()
+
+ // A new hint index should be revealed despite the entire previous state being completed (since
+ // the handler has been reset).
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testFinishState_newStateWithoutHints_wait60Seconds_doesNotCallMonitorAgain() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealEverythingInMultiHintState()
+ hintHandler.finishState(expWithNoHintsOrSolution.getInitialState())
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // Since the new state doesn't have any hints, the index will not change.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ /* Tests for handleWrongAnswerSubmission */
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_helpIndexStaysEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_wait10seconds_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_wait30seconds_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_wait60seconds_monitorCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // A hint should now be available, so the monitor will be notified.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_stateWithHints_wait60seconds_helpIndexHasAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ waitFor60Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_twice_stateWithHints_monitorCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ // Simulate two answers being submitted subsequently.
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ // Submitting two wrong answers subsequently should immediately result in a hint being available.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_twice_stateWithHints_helpIndexHasAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ // Simulate two answers being submitted subsequently.
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_twice_stateWithoutHints_monitorNotCalled() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ // Simulate two answers being submitted subsequently.
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ // No notification should happen since the state doesn't have any hints.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testWrongAnswerSubmission_twice_stateWithoutHints_helpIndexIsEmpty() {
+ val state = expWithNoHintsOrSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ // Simulate two answers being submitted subsequently.
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ // No hint is available since the state has no hints.
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualToDefaultInstance()
+ }
+
+ /* Tests for viewHint */
+
+ @Test
+ fun testViewHint_noHintAvailable_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewHint(hintIndex = 0)
+ }
+
+ // No hint is available to reveal.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal hint")
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_callsMonitor() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ reset(mockHintMonitor)
+
+ hintHandler.viewHint(hintIndex = 0)
+
+ // Viewing the hint should trigger a change in the help index.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_helpIndexUpdatedToShowHintShown() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ reset(mockHintMonitor)
+
+ hintHandler.viewHint(hintIndex = 0)
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_multiHintState_wait10Seconds_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ hintHandler.viewHint(hintIndex = 0)
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_multiHintState_wait30Seconds_monitorCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ hintHandler.viewHint(hintIndex = 0)
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ // 30 seconds is long enough to trigger a second hint to be available.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_multiHintState_wait30Seconds_helpIndexHasNewAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ hintHandler.viewHint(hintIndex = 0)
+
+ waitFor30Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_multiHintState_allHintsRevealed_indexShowsLastRevealedHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewHint_multiHintState_allHintsRevealed_triggerSolution_indexShowsSolution() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+
+ triggerSolution()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_oneHintState_withSolution_wait10Sec_monitorNotCalled() {
+ val state = expWithOneHintAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_oneHintState_withSolution_wait30Sec_monitorCalled() {
+ val state = expWithOneHintAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ // The solution should now be available.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_oneHintState_withSolution_wait30Sec_indexShowsSolution() {
+ val state = expWithOneHintAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+
+ waitFor30Seconds()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_oneHintState_noSolution_wait10Sec_monitorNotCalled() {
+ val state = expWithOneHintAndNoSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewHint_hintAvailable_oneHintState_noSolution_wait30Sec_monitorNotCalled() {
+ val state = expWithOneHintAndNoSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ // The index is still unchanged since there's nothing left to see.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewHint_latestHintViewed_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewHint(hintIndex = 0)
+ }
+
+ // No hint is available to reveal since it's already been revealed.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal hint")
+ }
+
+ @Test
+ fun testViewHint_solutionAvailable_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewHint(hintIndex = 0)
+ }
+
+ // No hint is available to reveal since all hints have been revealed.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal hint")
+ }
+
+ @Test
+ fun testViewHint_everythingRevealed_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerAndRevealSolution()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewHint(hintIndex = 0)
+ }
+
+ // No hint is available to reveal since everything has been revealed.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal hint")
+ }
+
+ /* Tests for viewSolution */
+
+ @Test
+ fun testViewSolution_nothingAvailable_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewSolution()
+ }
+
+ // The solution is not yet available to be seen (no hints have been viewed).
+ assertThat(exception).hasMessageThat().contains("Cannot reveal solution")
+ }
+
+ @Test
+ fun testViewSolution_hintAvailable_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewSolution()
+ }
+
+ // The solution is not yet available to be seen (one hint is available, but hasn't been viewed).
+ assertThat(exception).hasMessageThat().contains("Cannot reveal solution")
+ }
+
+ @Test
+ fun testViewSolution_hintViewed_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewSolution()
+ }
+
+ // The solution is not yet available to be seen (one hint was viewed, but the solution isn't
+ // available yet).
+ assertThat(exception).hasMessageThat().contains("Cannot reveal solution")
+ }
+
+ @Test
+ fun testViewSolution_allHintsViewed_solutionNotTriggered_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewSolution()
+ }
+
+ // The solution is not yet available to be seen since the user hasn't triggered the solution to
+ // actually show up.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal solution")
+ }
+
+ @Test
+ fun testViewSolution_solutionAvailable_callsMonitor() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerSolution()
+ reset(mockHintMonitor)
+
+ hintHandler.viewSolution()
+
+ // The help index should change when the solution is revealed.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testViewSolution_solutionAvailable_helpIndexUpdatedToShowEverything() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerSolution()
+
+ hintHandler.viewSolution()
+
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testViewSolution_solutionAvailable_wait10Sec_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerSolution()
+ hintHandler.viewSolution()
+ reset(mockHintMonitor)
+
+ waitFor10Seconds()
+
+ // There's nothing left to be revealed.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewSolution_solutionAvailable_wait30Sec_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerSolution()
+ hintHandler.viewSolution()
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ // There's nothing left to be revealed.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewSolution_solutionAvailable_wait60Sec_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerSolution()
+ hintHandler.viewSolution()
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // There's nothing left to be revealed.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testViewSolution_everythingViewed_throwsException() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerAndRevealSolution()
+
+ val exception = assertThrows(IllegalStateException::class) {
+ hintHandler.viewSolution()
+ }
+
+ // The solution has already been revealed.
+ assertThat(exception).hasMessageThat().contains("Cannot reveal solution")
+ }
+
+ /* Tests for navigateToPreviousState */
+
+ @Test
+ fun testNavigateToPreviousState_pendingHint_wait60Sec_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ hintHandler.navigateToPreviousState()
+ waitFor60Seconds()
+
+ // The monitor should not be called since the user navigated away from the pending state.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testNavigateToPreviousState_multipleTimes_pendingHint_wait60Sec_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ reset(mockHintMonitor)
+
+ // Simulate navigating back three states.
+ hintHandler.navigateToPreviousState()
+ hintHandler.navigateToPreviousState()
+ hintHandler.navigateToPreviousState()
+ waitFor60Seconds()
+
+ // The monitor should not be called since the pending state isn't visible.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ /* Tests for navigateBackToLatestPendingState */
+
+ @Test
+ fun testNavigateBackToLatestPendingState_fromPreviousState_pendingHint_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.navigateToPreviousState()
+ reset(mockHintMonitor)
+
+ hintHandler.navigateBackToLatestPendingState()
+
+ // The monitor should not be called immediately after returning to the pending state.
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ @Test
+ fun testNavigateBackToLatestPendingState_fromPreviousState_pendingHint_wait60Sec_monitorCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.navigateToPreviousState()
+ hintHandler.navigateBackToLatestPendingState()
+ reset(mockHintMonitor)
+
+ waitFor60Seconds()
+
+ // The hint should not be available since the user has waited for the counter to finish.
+ verify(mockHintMonitor).onHelpIndexChanged()
+ }
+
+ @Test
+ fun testNavigateBackToLatestPendingState_fromPreviousState_waitRemainingTime_monitorNotCalled() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor30Seconds()
+ hintHandler.navigateToPreviousState()
+ hintHandler.navigateBackToLatestPendingState()
+ reset(mockHintMonitor)
+
+ waitFor30Seconds()
+
+ // Waiting half the necessary time is insufficient to show the hint (since the timer is not
+ // resumed, it's reset after returning the pending state).
+ verifyNoMoreInteractions(mockHintMonitor)
+ }
+
+ /*
+ * Tests for getCurrentHelpIndex (more detailed state machine tests; some may be redundant
+ * with earlier tests). It's suggested to reference the state machine diagram laid out in
+ * HintHandler's class KDoc when inspecting the following tests.
+ */
+
+ @Test
+ fun testGetCurrentHelpIndex_initialState_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_wait10Sec_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_wait30Sec_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_wait60Sec_hasAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor60Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_oneWrongAnswer_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_oneWrongAnswer_wait10Sec_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_oneWrongAnswer_wait30Sec_isEmpty() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_oneWrongAnswer_wait60Sec_hasAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor60Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_twoWrongAnswers_hasAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_withAvailableHint_anotherWrongAnswer_hasSameAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerFirstHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_hasShownHintIndex() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_wait10Sec_hasShownHintIndex() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_wait30Sec_hasNewAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_oneWrongAnswer_hasShownHintIndex() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_oneWrongAnswer_wait10Sec_hasNewAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_twoWrongAnswers_hasShownHintIndex() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ // Multiple wrong answers do not force a hint to be shown except for the first hint.
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 0
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_viewAvailableHint_twoWrongAnswers_wait10Sec_hasNewAvailableHint() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ nextAvailableHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_noSolution_everythingRevealed() {
+ val state = expWithOneHintAndNoSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ triggerAndRevealFirstHint()
+
+ // All hints have been viewed for this state, so nothing remains.
+ assertThat(hintHandler.getCurrentHelpIndex()).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_lastIndexViewed() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_wait10Sec_lastIndexViewed() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_wait30Sec_canShowSolution() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_wait30Sec_revealSolution_everythingRevealed() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ waitFor30Seconds()
+ hintHandler.viewSolution()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_oneWrongAnswer_lastIndexViewed() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_oneWrongAnswer_wait10Sec_canShowSolution() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_twoWrongAnswers_lastIndexViewed() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ // Multiple subsequent wrong answers only affects the first hint.
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ latestRevealedHintIndex = 1
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_allHintsViewed_twoWrongAnswers_wait10Sec_canShowSolution() {
+ val state = expWithHintsAndSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_wait10Sec_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_wait30Sec_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_wait60Sec_canShowSolution() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor60Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait10Sec_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor10Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait30Sec_isEmpty() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor30Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualToDefaultInstance()
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_oneWrongAnswer_wait60Sec_canShowSolution() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ waitFor60Seconds()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_twoWrongAnswers_canShowSolution() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 1)
+ hintHandler.handleWrongAnswerSubmission(wrongAnswerCount = 2)
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ showSolution = true
+ }.build()
+ )
+ }
+
+ @Test
+ fun testGetCurrentHelpIndex_onlySolution_triggeredAndRevealed_everythingIsRevealed() {
+ val state = expWithNoHintsAndOneSolution.getInitialState()
+ hintHandler.startWatchingForHintsInNewState(state)
+ waitFor60Seconds()
+ hintHandler.viewSolution()
+
+ val helpIndex = hintHandler.getCurrentHelpIndex()
+
+ assertThat(helpIndex).isEqualTo(
+ HelpIndex.newBuilder().apply {
+ everythingRevealed = true
+ }.build()
+ )
+ }
+
+ private fun Exploration.getInitialState(): State = statesMap.getValue(initStateName)
+
+ private fun triggerFirstHint() = waitFor60Seconds()
+
+ private fun triggerSecondHint() = waitFor30Seconds()
+
+ private fun triggerSolution() = waitFor30Seconds()
+
+ private fun triggerAndRevealFirstHint() {
+ triggerFirstHint()
+ hintHandler.viewHint(hintIndex = 0)
+ }
+
+ private fun triggerAndRevealSecondHint() {
+ triggerSecondHint()
+ hintHandler.viewHint(hintIndex = 1)
+ }
+
+ private fun triggerAndRevealSolution() {
+ triggerSolution()
+ hintHandler.viewSolution()
+ }
+
+ private fun triggerAndRevealEverythingInMultiHintState() {
+ triggerAndRevealFirstHint()
+ triggerAndRevealSecondHint()
+ triggerAndRevealSolution()
+ }
+
+ private fun waitFor10Seconds() = waitFor(seconds = 10)
+
+ private fun waitFor30Seconds() = waitFor(seconds = 30)
+
+ private fun waitFor60Seconds() = waitFor(seconds = 60)
+
+ private fun waitFor(seconds: Long) {
+ // There's a weird quirk at the moment where the initial coroutine doesn't start without a
+ // runCurrent(). This seems a bit like a bug within the dispatchers; it should probably flush
+ // current tasks before advancing time.
+ // TODO(#3700): Fix this behavior (once fixed, 'runCurrent' should be able to be removed below).
+ testCoroutineDispatchers.runCurrent()
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(seconds))
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Module
+ class TestModule {
+ @Provides
+ fun provideContext(application: Application): Context = application
+
+ @Provides
+ @LoadLessonProtosFromAssets
+ fun provideLoadLessonProtosFromAssets(testEnvironmentConfig: TestEnvironmentConfig): Boolean =
+ testEnvironmentConfig.isUsingBazel()
+ }
+
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, HintsAndSolutionProdModule::class, HintsAndSolutionConfigModule::class,
+ TestLogReportingModule::class, TestDispatcherModule::class, RobolectricModule::class,
+ LoggerModule::class,
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(hintHandlerImplTest: HintHandlerImplTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerHintHandlerImplTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(hintHandlerImplTest: HintHandlerImplTest) {
+ component.inject(hintHandlerImplTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt
new file mode 100644
index 00000000000..0aa9edf7138
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionProdModuleTest.kt
@@ -0,0 +1,91 @@
+package org.oppia.android.domain.hintsandsolution
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import dagger.Binds
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [HintsAndSolutionProdModule]. */
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = HintsAndSolutionProdModuleTest.TestApplication::class)
+class HintsAndSolutionProdModuleTest {
+ @Inject
+ lateinit var hintHandlerFactory: HintHandler.Factory
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testHintHandlerFactoryInjection_constructNewHandler_providesFactoryForProdImplHandler() {
+ val hintHandler = hintHandlerFactory.create(object : HintHandler.HintMonitor {
+ override fun onHelpIndexChanged() {}
+ })
+
+ assertThat(hintHandler).isInstanceOf(HintHandlerImpl::class.java)
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Module
+ interface TestModule {
+ @Binds
+ fun provideContext(application: Application): Context
+ }
+
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, HintsAndSolutionProdModule::class, HintsAndSolutionConfigModule::class,
+ TestLogReportingModule::class, TestDispatcherModule::class, RobolectricModule::class,
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(HintsAndSolutionProdModuleTest: HintsAndSolutionProdModuleTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerHintsAndSolutionProdModuleTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(HintsAndSolutionProdModuleTest: HintsAndSolutionProdModuleTest) {
+ component.inject(HintsAndSolutionProdModuleTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt
index bf7595ed7ac..015d60584d4 100644
--- a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt
@@ -2,6 +2,7 @@ package org.oppia.android.domain.question
import android.app.Application
import android.content.Context
+import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -18,18 +19,18 @@ import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.oppia.android.app.model.AnsweredQuestionOutcome
import org.oppia.android.app.model.EphemeralQuestion
+import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.EphemeralState.StateTypeCase.COMPLETED_STATE
import org.oppia.android.app.model.EphemeralState.StateTypeCase.PENDING_STATE
import org.oppia.android.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE
import org.oppia.android.app.model.FractionGrade
-import org.oppia.android.app.model.Hint
import org.oppia.android.app.model.InteractionObject
-import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.app.model.UserAssessmentPerformance
import org.oppia.android.domain.classify.InteractionsModule
@@ -43,6 +44,10 @@ import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRu
import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.hintsandsolution.isHintRevealed
+import org.oppia.android.domain.hintsandsolution.isSolutionRevealed
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.topic.TEST_SKILL_ID_0
import org.oppia.android.domain.topic.TEST_SKILL_ID_1
@@ -65,9 +70,12 @@ import org.oppia.android.util.logging.LogLevel
import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
+private const val TOLERANCE = 1e-5
+
/** Tests for [QuestionAssessmentProgressController]. */
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
@@ -104,9 +112,6 @@ class QuestionAssessmentProgressControllerTest {
lateinit var mockScoreAndMasteryLiveDataObserver:
Observer>
- @Mock
- lateinit var mockAsyncResultLiveDataObserver: Observer>
-
@Mock
lateinit var mockAsyncNullableResultLiveDataObserver: Observer>
@@ -114,10 +119,7 @@ class QuestionAssessmentProgressControllerTest {
lateinit var mockAsyncAnswerOutcomeObserver: Observer>
@Mock
- lateinit var mockAsyncHintObserver: Observer>
-
- @Mock
- lateinit var mockAsyncSolutionObserver: Observer>
+ lateinit var mockAsyncResultLiveDataObserver: Observer>
@Captor
lateinit var currentQuestionResultCaptor: ArgumentCaptor>
@@ -663,8 +665,7 @@ class QuestionAssessmentProgressControllerTest {
assertThat(currentQuestion.ephemeralState.stateTypeCase).isEqualTo(COMPLETED_STATE)
val completedState = currentQuestion.ephemeralState.completedState
assertThat(completedState.answerCount).isEqualTo(1)
- assertThat(completedState.getAnswer(0).userAnswer.answer.real)
- .isWithin(1e-5).of(5.0)
+ assertThat(completedState.getAnswer(0).userAnswer.answer.real).isWithin(TOLERANCE).of(5.0)
assertThat(completedState.getAnswer(0).feedback.html).contains("That's correct!")
}
@@ -691,8 +692,7 @@ class QuestionAssessmentProgressControllerTest {
assertThat(currentQuestion.ephemeralState.stateTypeCase).isEqualTo(PENDING_STATE)
val pendingState = currentQuestion.ephemeralState.pendingState
assertThat(pendingState.wrongAnswerCount).isEqualTo(1)
- assertThat(pendingState.getWrongAnswer(0).userAnswer.answer.real)
- .isWithin(1e-5).of(4.0)
+ assertThat(pendingState.getWrongAnswer(0).userAnswer.answer.real).isWithin(TOLERANCE).of(4.0)
assertThat(pendingState.getWrongAnswer(0).feedback.html).isEmpty()
}
@@ -918,6 +918,7 @@ class QuestionAssessmentProgressControllerTest {
startTrainingSession(TEST_SKILL_ID_LIST_01)
submitTextInputAnswerAndMoveToNextQuestion("1/4") // question 0
submitMultipleChoiceAnswerAndMoveToNextQuestion(2) // question 1
+ submitMultipleChoiceAnswerAndMoveToNextQuestion(2) // question 1 (again--second wrong answer)
// Verify that we're on the second-to-last state of the second session.
verify(mockCurrentQuestionLiveDataObserver, atLeastOnce()).onChanged(
@@ -940,18 +941,14 @@ class QuestionAssessmentProgressControllerTest {
assertThat(ephemeralQuestion.ephemeralState.stateTypeCase).isEqualTo(PENDING_STATE)
assertThat(ephemeralQuestion.ephemeralState.pendingState.wrongAnswerCount)
- .isEqualTo(1)
+ .isEqualTo(2)
val hintAndSolution = ephemeralQuestion.ephemeralState.state.interaction.getHint(0)
assertThat(hintAndSolution.hintContent.html).contains("Hint text will appear here")
- val result = questionAssessmentProgressController.submitHintIsRevealed(
- ephemeralQuestion.ephemeralState.state,
- /* hintIsRevealed= */ true,
- /* hintIndex= */ 0
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
)
- result.observeForever(mockAsyncHintObserver)
- testCoroutineDispatchers.runCurrent()
// Verify that the current state updates. Hint revealed is true.
verify(
@@ -960,8 +957,7 @@ class QuestionAssessmentProgressControllerTest {
).onChanged(currentQuestionResultCaptor.capture())
assertThat(currentQuestionResultCaptor.value.isSuccess()).isTrue()
val updatedState = currentQuestionResultCaptor.value.getOrThrow()
- assertThat(updatedState.ephemeralState.state.interaction.getHint(0).hintIsRevealed)
- .isTrue()
+ assertThat(updatedState.ephemeralState.isHintRevealed(0)).isTrue()
}
@Test
@@ -974,6 +970,12 @@ class QuestionAssessmentProgressControllerTest {
startTrainingSession(TEST_SKILL_ID_LIST_01)
submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer)
+ submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer)
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
+ )
+ submitTextInputAnswerAndMoveToNextQuestion("1/3") // question 0 (wrong answer)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
verify(mockCurrentQuestionLiveDataObserver, atLeastOnce()).onChanged(
currentQuestionResultCaptor.capture()
@@ -981,16 +983,12 @@ class QuestionAssessmentProgressControllerTest {
assertThat(currentQuestionResultCaptor.value.isSuccess()).isTrue()
val currentQuestion = currentQuestionResultCaptor.value.getOrThrow()
assertThat(currentQuestion.ephemeralState.stateTypeCase).isEqualTo(PENDING_STATE)
- assertThat(currentQuestion.ephemeralState.pendingState.wrongAnswerCount).isEqualTo(1)
+ assertThat(currentQuestion.ephemeralState.pendingState.wrongAnswerCount).isEqualTo(3)
val hintAndSolution = currentQuestion.ephemeralState.state.interaction.solution
assertThat(hintAndSolution.correctAnswer.correctAnswer).contains("1/4")
- val result = questionAssessmentProgressController.submitSolutionIsRevealed(
- currentQuestion.ephemeralState.state
- )
- result.observeForever(mockAsyncSolutionObserver)
- testCoroutineDispatchers.runCurrent()
+ verifyOperationSucceeds(questionAssessmentProgressController.submitSolutionIsRevealed())
// Verify that the current state updates. Hint revealed is true.
verify(
@@ -999,7 +997,7 @@ class QuestionAssessmentProgressControllerTest {
).onChanged(currentQuestionResultCaptor.capture())
assertThat(currentQuestionResultCaptor.value.isSuccess()).isTrue()
val updatedState = currentQuestionResultCaptor.value.getOrThrow()
- assertThat(updatedState.ephemeralState.state.interaction.solution.solutionIsRevealed).isTrue()
+ assertThat(updatedState.ephemeralState.isSolutionRevealed()).isTrue()
}
@Test
@@ -1011,6 +1009,10 @@ class QuestionAssessmentProgressControllerTest {
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
+ viewHintForQuestion2()
+ submitIncorrectAnswerForQuestion2(4.0)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion2()
submitCorrectAnswerForQuestion2()
@@ -1040,7 +1042,10 @@ class QuestionAssessmentProgressControllerTest {
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
viewHintForQuestion2()
+ submitIncorrectAnswerForQuestion2(4.0)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion2()
submitCorrectAnswerForQuestion2()
@@ -1137,22 +1142,29 @@ class QuestionAssessmentProgressControllerTest {
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
viewHintForQuestion2()
submitCorrectAnswerForQuestion2()
// Question 3
// Submit question 3 wrong answer
submitIncorrectAnswerForQuestion3("3/4")
+ submitIncorrectAnswerForQuestion3("3/4")
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
+ )
+ submitIncorrectAnswerForQuestion3("3/4")
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion3()
submitCorrectAnswerForQuestion3()
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
val totalScore = FractionGrade.newBuilder().apply {
- pointsReceived = 1.5
+ pointsReceived = 1.4
totalPointsAvailable = 3.0
}.build()
val skill0Score = FractionGrade.newBuilder().apply {
- pointsReceived = 1.5
+ pointsReceived = 1.4
totalPointsAvailable = 2.0
}.build()
val skill1Score = FractionGrade.newBuilder().apply {
@@ -1175,20 +1187,39 @@ class QuestionAssessmentProgressControllerTest {
startTrainingSession(TEST_SKILL_ID_LIST_01)
// Question 1
- // Submit question 1 wrong answer
+ // Submit question 1 wrong answer (a few extra wrong answers are added to reduce points).
+ submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
submitIncorrectAnswerForQuestion1(2)
- viewSolutionForQuestion1()
+ submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
+ viewHintForQuestion1(index = 0)
+ submitIncorrectAnswerForQuestion1(2)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
+ viewHintForQuestion1(index = 1)
submitCorrectAnswerForQuestion1()
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
+ viewHintForQuestion2()
+ submitIncorrectAnswerForQuestion2(4.0)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion2()
submitCorrectAnswerForQuestion2()
// Question 3
// Submit question 3 wrong answer
submitIncorrectAnswerForQuestion3("3/4")
+ submitIncorrectAnswerForQuestion3("3/4")
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
+ )
+ submitIncorrectAnswerForQuestion3("3/4")
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion3()
submitCorrectAnswerForQuestion3()
@@ -1214,7 +1245,7 @@ class QuestionAssessmentProgressControllerTest {
}
@Test
- fun hintViewed_for2QuestionsWithWrongAnswer_returnScore2Point6Outof3() {
+ fun hintViewed_for2QuestionsWithWrongAnswer_returnScore2Point4Outof3() {
setUpTestApplicationWithSeed(questionSeed = 0)
subscribeToCurrentQuestionToAllowSessionToLoad()
// This will generate question 1 (skill 0), question 2 (skill 0), and question 3 (skill 1)
@@ -1223,12 +1254,14 @@ class QuestionAssessmentProgressControllerTest {
// Question 1
// Submit question 1 wrong answer
submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
viewHintForQuestion1(0)
submitCorrectAnswerForQuestion1()
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
viewHintForQuestion2()
submitCorrectAnswerForQuestion2()
@@ -1237,11 +1270,11 @@ class QuestionAssessmentProgressControllerTest {
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
val totalScore = FractionGrade.newBuilder().apply {
- pointsReceived = 2.6
+ pointsReceived = 2.4
totalPointsAvailable = 3.0
}.build()
val skill0Score = FractionGrade.newBuilder().apply {
- pointsReceived = 1.6
+ pointsReceived = 1.4
totalPointsAvailable = 2.0
}.build()
val skill1Score = FractionGrade.newBuilder().apply {
@@ -1257,7 +1290,7 @@ class QuestionAssessmentProgressControllerTest {
}
@Test
- fun multipleHintsViewed_forQuestionsWithWrongAnswer_returnScore2Point7Outof3() {
+ fun multipleHintsViewed_forQuestionsWithWrongAnswer_returnScore2Point5Outof3() {
setUpTestApplicationWithSeed(questionSeed = 0)
subscribeToCurrentQuestionToAllowSessionToLoad()
// This will generate question 1 (skill 0), question 2 (skill 0), and question 3 (skill 1)
@@ -1266,7 +1299,10 @@ class QuestionAssessmentProgressControllerTest {
// Question 1
// Submit question 1 wrong answer
submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
viewHintForQuestion1(0)
+ submitIncorrectAnswerForQuestion1(2)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewHintForQuestion1(1)
submitCorrectAnswerForQuestion1()
@@ -1278,11 +1314,11 @@ class QuestionAssessmentProgressControllerTest {
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
val totalScore = FractionGrade.newBuilder().apply {
- pointsReceived = 2.7
+ pointsReceived = 2.5
totalPointsAvailable = 3.0
}.build()
val skill0Score = FractionGrade.newBuilder().apply {
- pointsReceived = 1.7
+ pointsReceived = 1.5
totalPointsAvailable = 2.0
}.build()
val skill1Score = FractionGrade.newBuilder().apply {
@@ -1307,29 +1343,43 @@ class QuestionAssessmentProgressControllerTest {
// Question 1
// Submit question 1 wrong answer
submitIncorrectAnswerForQuestion1(2)
- viewSolutionForQuestion1()
+ submitIncorrectAnswerForQuestion1(2)
+ viewHintForQuestion1(index = 0)
+ submitIncorrectAnswerForQuestion1(2)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
+ viewHintForQuestion1(index = 1)
submitCorrectAnswerForQuestion1()
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
+ viewHintForQuestion2()
+ submitIncorrectAnswerForQuestion2(4.0)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion2()
submitCorrectAnswerForQuestion2()
// Question 3
// Submit question 3 wrong answer
submitIncorrectAnswerForQuestion3("3/4")
+ submitIncorrectAnswerForQuestion3("3/4")
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
+ )
+ submitIncorrectAnswerForQuestion3("3/4")
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion3()
submitCorrectAnswerForQuestion3()
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
- val skill0Mastery = -0.2
+ val skill0Mastery = -0.19
val skill1Mastery = -0.1
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
@@ -1353,9 +1403,9 @@ class QuestionAssessmentProgressControllerTest {
val skill1Mastery = 0.1
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
@@ -1374,23 +1424,30 @@ class QuestionAssessmentProgressControllerTest {
// Question 2
// Submit question 2 wrong answer
submitIncorrectAnswerForQuestion2(4.0)
+ submitIncorrectAnswerForQuestion2(4.0)
viewHintForQuestion2()
submitCorrectAnswerForQuestion2()
// Question 3
// Submit question 3 wrong answer
submitIncorrectAnswerForQuestion3("3/4")
+ submitIncorrectAnswerForQuestion3("3/4")
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
+ )
+ submitIncorrectAnswerForQuestion3("3/4")
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewSolutionForQuestion3()
submitCorrectAnswerForQuestion3()
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
- val skill0Mastery = 0.03
+ val skill0Mastery = -0.02
val skill1Mastery = -0.1
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
@@ -1420,13 +1477,13 @@ class QuestionAssessmentProgressControllerTest {
val skill1Mastery = -0.1
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
- fun multipleHintsViewed_forQuestionWithWrongAnswer_returnMastery0Point11ForLinkedSkill() {
+ fun multipleHintsViewed_forQuestionWithWrongAnswer_returnMastery0Point01ForLinkedSkill() {
setUpTestApplicationWithSeed(questionSeed = 0)
subscribeToCurrentQuestionToAllowSessionToLoad()
// This will generate question 1 (skill 0), question 2 (skill 0), and question 3 (skill 1)
@@ -1435,7 +1492,10 @@ class QuestionAssessmentProgressControllerTest {
// Question 1
// Submit question 1 wrong answer
submitIncorrectAnswerForQuestion1(2)
+ submitIncorrectAnswerForQuestion1(2)
viewHintForQuestion1(0)
+ submitIncorrectAnswerForQuestion1(2)
+ testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10))
viewHintForQuestion1(1)
submitCorrectAnswerForQuestion1()
@@ -1446,13 +1506,13 @@ class QuestionAssessmentProgressControllerTest {
submitCorrectAnswerForQuestion3()
val userAssessmentPerformance = getExpectedGrade(TEST_SKILL_ID_LIST_01)
- val skill0Mastery = 0.11
+ val skill0Mastery = 0.01
val skill1Mastery = 0.1
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
@@ -1479,9 +1539,9 @@ class QuestionAssessmentProgressControllerTest {
val skill1Mastery = 0.0
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
@Test
@@ -1508,9 +1568,9 @@ class QuestionAssessmentProgressControllerTest {
val skill1Mastery = 0.0
assertThat(userAssessmentPerformance.masteryPerSkillMappingCount).isEqualTo(2)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_0))
- .isEqualTo(skill0Mastery)
+ .isWithin(TOLERANCE).of(skill0Mastery)
assertThat(userAssessmentPerformance.getMasteryPerSkillMappingOrThrow(TEST_SKILL_ID_1))
- .isEqualTo(skill1Mastery)
+ .isWithin(TOLERANCE).of(skill1Mastery)
}
private fun setUpTestApplicationWithSeed(questionSeed: Long) {
@@ -1613,7 +1673,6 @@ class QuestionAssessmentProgressControllerTest {
}
private fun submitIncorrectAnswerForQuestion0(answer: String) {
- assertThat(answer).isNotEqualTo("1/2")
submitTextInputAnswerAndMoveToNextQuestion(answer)
verify(
mockCurrentQuestionLiveDataObserver,
@@ -1626,7 +1685,6 @@ class QuestionAssessmentProgressControllerTest {
}
private fun submitIncorrectAnswerForQuestion1(answer: Int) {
- assertThat(answer).isNotEqualTo(1)
submitMultipleChoiceAnswerAndMoveToNextQuestion(answer)
verify(
mockCurrentQuestionLiveDataObserver,
@@ -1642,15 +1700,8 @@ class QuestionAssessmentProgressControllerTest {
} else if (index == 1) {
assertThat(hint.hintContent.html).contains("Second hint text will appear here
")
}
- questionAssessmentProgressController.submitHintIsRevealed(
- ephemeralQuestion.ephemeralState.state, true, index
- )
- }
-
- private fun viewSolutionForQuestion1() {
- val ephemeralQuestion = currentQuestionResultCaptor.value.getOrThrow()
- questionAssessmentProgressController.submitSolutionIsRevealed(
- ephemeralQuestion.ephemeralState.state
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = index)
)
}
@@ -1659,7 +1710,6 @@ class QuestionAssessmentProgressControllerTest {
}
private fun submitIncorrectAnswerForQuestion2(answer: Double) {
- assertThat(answer).isNotEqualTo(3.0)
submitNumericInputAnswerAndMoveToNextQuestion(answer)
verify(
mockCurrentQuestionLiveDataObserver,
@@ -1671,8 +1721,8 @@ class QuestionAssessmentProgressControllerTest {
val ephemeralQuestion = currentQuestionResultCaptor.value.getOrThrow()
val hint = ephemeralQuestion.ephemeralState.state.interaction.getHint(0)
assertThat(hint.hintContent.html).contains("Hint text will appear here
")
- questionAssessmentProgressController.submitHintIsRevealed(
- ephemeralQuestion.ephemeralState.state, true, 0
+ verifyOperationSucceeds(
+ questionAssessmentProgressController.submitHintIsRevealed(hintIndex = 0)
)
}
@@ -1680,9 +1730,7 @@ class QuestionAssessmentProgressControllerTest {
val ephemeralQuestion = currentQuestionResultCaptor.value.getOrThrow()
val solution = ephemeralQuestion.ephemeralState.state.interaction.solution
assertThat(solution.correctAnswer.correctAnswer).isEqualTo("3.0")
- questionAssessmentProgressController.submitSolutionIsRevealed(
- ephemeralQuestion.ephemeralState.state
- )
+ verifyOperationSucceeds(questionAssessmentProgressController.submitSolutionIsRevealed())
}
private fun submitCorrectAnswerForQuestion3() {
@@ -1690,7 +1738,6 @@ class QuestionAssessmentProgressControllerTest {
}
private fun submitIncorrectAnswerForQuestion3(answer: String) {
- assertThat(answer).isNotEqualTo("1/2")
submitTextInputAnswerAndMoveToNextQuestion(answer)
verify(
mockCurrentQuestionLiveDataObserver,
@@ -1702,9 +1749,7 @@ class QuestionAssessmentProgressControllerTest {
val ephemeralQuestion = currentQuestionResultCaptor.value.getOrThrow()
val solution = ephemeralQuestion.ephemeralState.state.interaction.solution
assertThat(solution.correctAnswer.correctAnswer).isEqualTo("1/2")
- questionAssessmentProgressController.submitSolutionIsRevealed(
- ephemeralQuestion.ephemeralState.state
- )
+ verifyOperationSucceeds(questionAssessmentProgressController.submitSolutionIsRevealed())
}
private fun submitCorrectAnswerForQuestion4() {
@@ -1715,6 +1760,13 @@ class QuestionAssessmentProgressControllerTest {
submitNumericInputAnswerAndMoveToNextQuestion(5.0)
}
+ private fun EphemeralState.isHintRevealed(hintIndex: Int): Boolean {
+ return pendingState.helpIndex.isHintRevealed(hintIndex, state.interaction.hintList)
+ }
+
+ private fun EphemeralState.isSolutionRevealed(): Boolean =
+ pendingState.helpIndex.isSolutionRevealed()
+
private fun getExpectedGrade(skillIdList: List): UserAssessmentPerformance {
subscribeToScoreAndMasteryCalculations(skillIdList)
testCoroutineDispatchers.runCurrent()
@@ -1725,6 +1777,25 @@ class QuestionAssessmentProgressControllerTest {
return performanceCalculationCaptor.value.getOrThrow()
}
+ /**
+ * Verifies that the specified live data provides at least one successful operation. This will
+ * change test-wide mock state, and synchronizes background execution.
+ */
+ private fun verifyOperationSucceeds(liveData: LiveData>) {
+ reset(mockAsyncResultLiveDataObserver)
+ liveData.observeForever(mockAsyncResultLiveDataObserver)
+ testCoroutineDispatchers.runCurrent()
+ verify(mockAsyncResultLiveDataObserver).onChanged(asyncResultCaptor.capture())
+ asyncResultCaptor.value.apply {
+ // This bit of conditional logic is used to add better error reporting when failures occur.
+ if (isFailure()) {
+ throw AssertionError("Operation failed", getErrorOrNull())
+ }
+ assertThat(isSuccess()).isTrue()
+ }
+ reset(mockAsyncResultLiveDataObserver)
+ }
+
// TODO(#89): Move this to a common test application component.
@Module
class TestModule {
@@ -1810,7 +1881,8 @@ class QuestionAssessmentProgressControllerTest {
InteractionsModule::class, DragDropSortInputModule::class, TestLogReportingModule::class,
ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class,
RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
- CachingTestModule::class, NetworkConnectionUtilDebugModule::class
+ CachingTestModule::class, HintsAndSolutionConfigModule::class,
+ HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt
index 150f28fc65a..e243d66b9e7 100644
--- a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt
@@ -32,6 +32,8 @@ import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRu
import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.topic.TEST_QUESTION_ID_0
import org.oppia.android.domain.topic.TEST_QUESTION_ID_1
@@ -333,6 +335,7 @@ class QuestionTrainingControllerTest {
TestQuestionModule::class, TestLogReportingModule::class, ImageClickInputModule::class,
LogStorageModule::class, TestDispatcherModule::class, RatioInputModule::class,
RobolectricModule::class, FakeOppiaClockModule::class, CachingTestModule::class,
+ HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
NetworkConnectionUtilDebugModule::class
]
)
diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto
index cc911ded54f..41ec39f1843 100644
--- a/model/src/main/proto/exploration.proto
+++ b/model/src/main/proto/exploration.proto
@@ -54,14 +54,17 @@ enum ObjectType {
message State {
// The name of the State.
string name = 1;
+
// Mapping from content_id to a VoiceoverMapping
map recorded_voiceovers = 2;
SubtitledHtml content = 3;
+
// Mapping from content_id to a TranslationMapping
map written_translations = 4;
repeated ParamChange param_changes = 5;
string classifier_model_id = 6;
Interaction interaction = 7;
+
// Boolean indicating whether the creator wants to ask for answer details
// from the learner about why they picked a particular answer while
// playing the exploration.
@@ -71,6 +74,7 @@ message State {
// Structure for customization args for ParamChange objects.
message ParamChangeCustomizationArgs {
bool parse_with_jinja = 1;
+
// If the param change has only a single value, we will populate just
// one element in this list
repeated string list_of_values = 3;
@@ -85,6 +89,7 @@ message Interaction {
reserved 4;
repeated Hint hint = 5;
Outcome default_outcome = 6;
+
// Mapping from the name of a customization arg to the default interaction
// object of the customization arg
map customization_args = 7;
@@ -138,6 +143,7 @@ message AnswerGroup {
message Misconception {
// Skill ID parsed from the misconception.
string skill_id = 1;
+
// Misconception ID parsed from the misconception.
string misconception_id = 2;
}
@@ -154,12 +160,16 @@ message Solution {
string interaction_id = 1;
// Flag that is true if correct_answer is the only correct answer of the question.
bool answer_is_exclusive = 2;
+
// correct_answer of the State.
CorrectAnswer correct_answer = 3;
+
// Core explanation to the correct answer.
SubtitledHtml explanation = 4;
- // To check if the solution was revealed or not by the learner.
- bool solution_is_revealed = 5;
+
+ // Whether the solution was revealed (deprecated). This information is now available through the
+ // HelpIndex message passed with EphemeralState.
+ reserved 5;
}
// Structure for a Correct answer in Solution
@@ -177,8 +187,11 @@ message CorrectAnswer {
message Hint {
// Hint data.
SubtitledHtml hint_content = 1;
- // To check if the hint was revealed or not
- bool hint_is_revealed = 2;
+
+ // Whether the hint was revealed (deprecated). This information is now available through
+ // the HelpIndex message passed through EphemeralState.
+ reserved 2;
+
// The current state that is display to the user.
State state = 3;
}
@@ -243,12 +256,19 @@ message PendingState {
// A list of previous wrong answers that led back to this state, and Oppia's responses. These responses are in the
// order the learner submitted them.
repeated AnswerAndResponse wrong_answer = 1;
- // A list of hints.
- repeated Hint hint = 2;
+
+ // A list of hints (deprecated). To get the list of hints, retrieve them from the State message
+ // stored within the top-level EphemeralState & cross reference it with the HelpIndex provided
+ // below to get information about the current state of hints/solution.
+ reserved 2;
+
+ // The help index for the current exploration.
+ HelpIndex help_index = 3;
}
// Corresponds to an exploration state that the learner has previous completed.
message CompletedState {
+ // The list of answers and responses that were used to finish this state. These answers are in the order that the
// The list of answers and responses that were used to finish this state. These answers are in the order that the
// learner submitted them, so the last answer is guaranteed to be the correct answer.
repeated AnswerAndResponse answer = 1;
@@ -258,6 +278,7 @@ message CompletedState {
message UserAnswer {
// The submitted answer.
InteractionObject answer = 1;
+
// A user-friendly textual representation of the answer.
oneof textual_answer {
// An HTML textual representation of the answer.
@@ -267,6 +288,7 @@ message UserAnswer {
// A list of HTML textual representation of the answer.
ListOfSetsOfHtmlStrings list_of_html_answers = 4;
}
+
// An accessible version of the corresponding plain answer. It will be used to read out the
// answer when there is a screen-reader enabled. Also when this is value is not initialized and
// if the screen-reader is enabled in that case the plain answer/html answer would be read out.
@@ -314,18 +336,35 @@ message AnswerOutcome {
// A structure corresponding to the index of a hint or solution in a state. This structure is set up
// to properly account for variable numbers of hints, for cases when only a solution or no solution
-// exists, and for when there are no hints or solutions.
+// exists, and for when there are no hints or solutions. This structure represents the entire state
+// needed to determine which hints can be shown, have been seen, whether the solution can be shown,
+// and whether the solution has been seen.
message HelpIndex {
+ // Deprecated hint index. This is now available via the two hint index fields below (which provide
+ // additional context on whether the hint has been revealed).
+ reserved 1;
+
// This type is uninitialized in cases when no index is currently available (either because a hint
// or solution hasn't yet been triggered, or because there are none to trigger).
oneof index_type {
- // Indicates this help index corresponds to the index of a hint within the hint list of a state.
- int32 hint_index = 1;
-
// Indicates this help index corresponds to the solution of a state. The boolean value here has
// no importance and is always 'true'.
bool show_solution = 2;
+ // Indicates this help index corresponds to the index of the next available hint within the hint
+ // list of a state (meaning the index corresponding to this value is the next hint that may be
+ // revealed by the user). This state assumes all prior hints (if any) have already been
+ // revealed.
+ int32 next_available_hint_index = 4;
+
+ // Indicates this help index corresponds to the index of the last revealed hint within the hint
+ // list of a state (meaning the index corresponding to this value is the latest hint that's been
+ // revealed by the user). This state assumes all prior hints (if any) have also already been
+ // revealed. Note that this could correspond to the last hint available to view and, in that
+ // case, the solution should not yet be available to reveal until it is also unlocked (at which
+ // point the state show_solution should be used, instead).
+ int32 latest_revealed_hint_index = 5;
+
// Indicates that everything available has been revealed. Note that this is different than the
// case when there are no hints or solutions to trigger, even though the resulting behavior may
// be different. This case specifically indicates that all hints and the solution, if present,
diff --git a/model/src/main/proto/exploration_checkpoint.proto b/model/src/main/proto/exploration_checkpoint.proto
index 6756fac3428..a01dc87cea5 100644
--- a/model/src/main/proto/exploration_checkpoint.proto
+++ b/model/src/main/proto/exploration_checkpoint.proto
@@ -30,13 +30,13 @@ message ExplorationCheckpoint {
// The index of the current selected state from the deck of states.
int32 state_index = 4;
- // Stores -1 if no hint for the pending top state has been revealed otherwise it stores the index
- // of the hint that the learner has revealed.
- int32 hint_index = 5;
+ // The last revealed hint index (deprecated). This has been functionally replaced by the HelpIndex
+ // provided below.
+ reserved 5;
- // Stores true if the solution to the pending top state has been revealed by the learner otherwise
- // it stores false.
- bool solution_is_revealed = 6;
+ // Whether the solution was revealed (deprecated). This has been functionally replaced by the
+ // HelpIndex provided below.
+ reserved 6;
// The title of the exploration whose checkpoint is saved.
string exploration_title = 7;
@@ -46,6 +46,9 @@ message ExplorationCheckpoint {
// The timestamp in milliseconds of when the checkpoint was saved for the first time.
int64 timestamp_of_first_checkpoint = 9;
+
+ // The saved help index for the exploration.
+ HelpIndex help_index = 10;
}
// Corresponds to exploration states that have been completed.
diff --git a/scripts/assets/kdoc_validity_exemptions.textproto b/scripts/assets/kdoc_validity_exemptions.textproto
index 7ea1f740ff5..3c271b9dfd1 100644
--- a/scripts/assets/kdoc_validity_exemptions.textproto
+++ b/scripts/assets/kdoc_validity_exemptions.textproto
@@ -376,7 +376,6 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/classify/rule
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction/ContinueModule.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt"
-exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationStorageModule.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/LogStorageModule.kt"
@@ -387,7 +386,6 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/Ques
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgressController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionConstantsProvider.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingConstantsProvider.kt"
-exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt"
diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto
index 930b68c7f9f..0721b775210 100644
--- a/scripts/assets/test_file_exemptions.textproto
+++ b/scripts/assets/test_file_exemptions.textproto
@@ -245,11 +245,6 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/StateP
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerErrorOrAvailabilityCheckReceiver.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt"
-exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt"
-exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt"
-exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt"
-exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt"
-exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContentViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueNavigationButtonViewModel.kt"
@@ -552,6 +547,12 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/E
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointState.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/feedbackreporting/FeedbackReportingModule.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowAdditionalHintsMillis.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/DelayShowInitialHintMillis.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintsAndSolutionConfigModule.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/hintsandsolution/HintHandler.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/ExpirationMetaDataRetriever.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/ExpirationMetaDataRetrieverImpl.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/ExpirationMetaDataRetrieverModule.kt"
diff --git a/testing/BUILD.bazel b/testing/BUILD.bazel
index b89328a0e90..97438b9bbe4 100644
--- a/testing/BUILD.bazel
+++ b/testing/BUILD.bazel
@@ -88,7 +88,9 @@ TEST_DEPS = [
"//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-test",
"//third_party:org_mockito_mockito-core",
"//third_party:robolectric_android-all",
- "//utility/src/main/java/org/oppia/android/util/logging:logger_module",
+ "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
]
# Qualified file paths for test classes that have been migrated over to their own packages &
@@ -116,4 +118,10 @@ kt_android_library(
],
)
+filegroup(
+ name = "test_manifest",
+ srcs = ["src/test/AndroidManifest.xml"],
+ visibility = ["//:oppia_testing_visibility"],
+)
+
dagger_rules()
diff --git a/testing/src/test/BUILD.bazel b/testing/src/test/BUILD.bazel
deleted file mode 100644
index 96475bc8ae3..00000000000
--- a/testing/src/test/BUILD.bazel
+++ /dev/null
@@ -1,10 +0,0 @@
-# TODO(#1532): Rename file to 'BUILD' post-Gradle.
-"""
-Top-level testing module files & targets.
-"""
-
-filegroup(
- name = "test_manifest",
- srcs = ["AndroidManifest.xml"],
- visibility = ["//:oppia_testing_visibility"],
-)
diff --git a/testing/src/test/java/org/oppia/android/testing/networking/BUILD.bazel b/testing/src/test/java/org/oppia/android/testing/networking/BUILD.bazel
index 747bd68be5e..99403b6dee1 100644
--- a/testing/src/test/java/org/oppia/android/testing/networking/BUILD.bazel
+++ b/testing/src/test/java/org/oppia/android/testing/networking/BUILD.bazel
@@ -10,7 +10,7 @@ oppia_android_test(
srcs = ["NetworkConnectionTestUtilTest.kt"],
custom_package = "org.oppia.android.testing.networking",
test_class = "org.oppia.android.testing.networking.NetworkConnectionTestUtilTest",
- test_manifest = "//testing/src/test:test_manifest",
+ test_manifest = "//testing:test_manifest",
deps = [
":dagger",
"//testing/src/main/java/org/oppia/android/testing/networking:network_connection_test_util",
diff --git a/testing/src/test/java/org/oppia/android/testing/threading/BUILD.bazel b/testing/src/test/java/org/oppia/android/testing/threading/BUILD.bazel
index e26fe1b626e..08c073d5e32 100644
--- a/testing/src/test/java/org/oppia/android/testing/threading/BUILD.bazel
+++ b/testing/src/test/java/org/oppia/android/testing/threading/BUILD.bazel
@@ -24,7 +24,7 @@ oppia_android_test(
srcs = ["CoroutineExecutorServiceTest.kt"],
custom_package = "org.oppia.android.data.testing",
test_class = "org.oppia.android.testing.threading.CoroutineExecutorServiceTest",
- test_manifest = "//testing/src/test:test_manifest",
+ test_manifest = "//testing:test_manifest",
deps = [
":dagger",
"//testing",
@@ -41,7 +41,7 @@ oppia_android_test(
srcs = ["TestCoroutineDispatcherEspressoImplTest.kt"],
custom_package = "org.oppia.android.data.testing",
test_class = "org.oppia.android.testing.threading.TestCoroutineDispatcherEspressoImplTest",
- test_manifest = "//testing/src/test:test_manifest",
+ test_manifest = "//testing:test_manifest",
deps = [
":dagger",
":test_coroutine_dispatcher_test_base",
@@ -57,7 +57,7 @@ oppia_android_test(
srcs = ["TestCoroutineDispatcherRobolectricImplTest.kt"],
custom_package = "org.oppia.android.data.testing",
test_class = "org.oppia.android.testing.threading.TestCoroutineDispatcherRobolectricImplTest",
- test_manifest = "//testing/src/test:test_manifest",
+ test_manifest = "//testing:test_manifest",
deps = [
":dagger",
":test_coroutine_dispatcher_test_base",
@@ -73,7 +73,7 @@ oppia_android_test(
srcs = ["TestCoroutineDispatcherTest.kt"],
custom_package = "org.oppia.android.data.testing",
test_class = "org.oppia.android.testing.threading.TestCoroutineDispatcherTest",
- test_manifest = "//testing/src/test:test_manifest",
+ test_manifest = "//testing:test_manifest",
deps = [
":dagger",
"//testing",
diff --git a/third_party/maven_install.json b/third_party/maven_install.json
index 03eef8ecc76..3886ce482d9 100644
--- a/third_party/maven_install.json
+++ b/third_party/maven_install.json
@@ -1,8 +1,8 @@
{
"dependency_tree": {
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
- "__INPUT_ARTIFACTS_HASH": 171679321,
- "__RESOLVED_ARTIFACTS_HASH": -2135931254,
+ "__INPUT_ARTIFACTS_HASH": -1770608701,
+ "__RESOLVED_ARTIFACTS_HASH": -348454682,
"conflict_resolution": {
"androidx.appcompat:appcompat:1.0.2": "androidx.appcompat:appcompat:1.2.0",
"androidx.core:core:1.0.1": "androidx.core:core:1.3.0",
@@ -6290,6 +6290,66 @@
"sha256": "f44739e95d21ca352aff947086d3176e8c61cf91ccbc100cf335d0964de44fe0",
"url": "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar"
},
+ {
+ "coord": "com.google.truth.extensions:truth-liteproto-extension:0.43",
+ "dependencies": [
+ "com.google.guava:guava:28.1-android",
+ "com.google.auto.value:auto-value-annotations:1.6.5",
+ "junit:junit:4.12",
+ "org.hamcrest:hamcrest-core:1.3",
+ "com.google.truth:truth:0.43",
+ "com.googlecode.java-diff-utils:diffutils:1.3.0",
+ "com.google.errorprone:error_prone_annotations:2.3.2",
+ "org.checkerframework:checker-compat-qual:2.5.5"
+ ],
+ "directDependencies": [
+ "com.google.guava:guava:28.1-android",
+ "com.google.auto.value:auto-value-annotations:1.6.5",
+ "com.google.truth:truth:0.43",
+ "com.google.errorprone:error_prone_annotations:2.3.2",
+ "org.checkerframework:checker-compat-qual:2.5.5"
+ ],
+ "file": "v1/https/repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar",
+ "mirror_urls": [
+ "https://maven.google.com/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar",
+ "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar",
+ "https://maven.fabric.io/public/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar",
+ "https://maven.google.com/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar",
+ "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar"
+ ],
+ "sha256": "30cc23160edb203e82cccfc429839f056fb79c426b9e0234575f929561cc8e71",
+ "url": "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43.jar"
+ },
+ {
+ "coord": "com.google.truth.extensions:truth-liteproto-extension:jar:sources:0.43",
+ "dependencies": [
+ "com.googlecode.java-diff-utils:diffutils:jar:sources:1.3.0",
+ "org.hamcrest:hamcrest-core:jar:sources:1.3",
+ "com.google.auto.value:auto-value-annotations:jar:sources:1.6.5",
+ "com.google.truth:truth:jar:sources:0.43",
+ "org.checkerframework:checker-compat-qual:jar:sources:2.5.5",
+ "com.google.guava:guava:jar:sources:28.1-android",
+ "junit:junit:jar:sources:4.12",
+ "com.google.errorprone:error_prone_annotations:jar:sources:2.3.2"
+ ],
+ "directDependencies": [
+ "com.google.auto.value:auto-value-annotations:jar:sources:1.6.5",
+ "com.google.truth:truth:jar:sources:0.43",
+ "org.checkerframework:checker-compat-qual:jar:sources:2.5.5",
+ "com.google.guava:guava:jar:sources:28.1-android",
+ "com.google.errorprone:error_prone_annotations:jar:sources:2.3.2"
+ ],
+ "file": "v1/https/repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar",
+ "mirror_urls": [
+ "https://maven.google.com/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar",
+ "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar",
+ "https://maven.fabric.io/public/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar",
+ "https://maven.google.com/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar",
+ "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar"
+ ],
+ "sha256": "c98e475ab4b321d60407ed5ebfa3a2107c23c8bf89c61c66d17d9a72e04fa8e7",
+ "url": "https://repo1.maven.org/maven2/com/google/truth/extensions/truth-liteproto-extension/0.43/truth-liteproto-extension-0.43-sources.jar"
+ },
{
"coord": "com.google.truth:truth:0.43",
"dependencies": [
diff --git a/third_party/versions.bzl b/third_party/versions.bzl
index 54c923de498..2d94cf461a0 100644
--- a/third_party/versions.bzl
+++ b/third_party/versions.bzl
@@ -91,6 +91,7 @@ MAVEN_TEST_DEPENDENCY_VERSIONS = {
"androidx.work:work-testing": "2.4.0",
"com.github.bumptech.glide:mocks": "4.11.0",
"com.google.protobuf:protobuf-java": "3.17.3",
+ "com.google.truth.extensions:truth-liteproto-extension": "0.43",
"com.google.truth:truth": "0.43",
"com.squareup.okhttp3:mockwebserver": "4.1.0",
"com.squareup.retrofit2:retrofit-mock": "2.5.0",