diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f968783f9..eeb779dd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - name: Run Backend Tests run: sudo docker-compose --env-file .env-github-actions run server bash -c "coverage run manage.py test" - name: Generate Backend Coverage Report (Inline) - run: sudo docker-compose --env-file .env-github-actions run server bash -c "coverage report" + run: sudo docker-compose --env-file .env-github-actions run server bash -c "coverage report --show-missing" # Generate coverage badge (only for main and develop branches) - name: Generate Backend Coverage Report (XML) and Badge diff --git a/backend/experiment/tests/test_actions_form.py b/backend/experiment/tests/test_actions_form.py new file mode 100644 index 000000000..37dcf9345 --- /dev/null +++ b/backend/experiment/tests/test_actions_form.py @@ -0,0 +1,331 @@ +from django.test import TestCase + +from experiment.actions.form import BooleanQuestion, ChoiceQuestion, Form, NumberQuestion, TextQuestion, DropdownQuestion, AutoCompleteQuestion, RadiosQuestion, ButtonArrayQuestion, RangeQuestion, LikertQuestion, LikertQuestionIcon + +class FormTest(TestCase): + def setUp(self): + self.questions = [NumberQuestion(key='test_key', min_value=1, max_value=10)] + self.form = Form(form=self.questions, submit_label='Submit', skip_label='Skip', is_skippable=True) + + def test_initialization(self): + self.assertEqual(len(self.form.form), 1) + self.assertEqual(self.form.submit_label, 'Submit') + self.assertEqual(self.form.skip_label, 'Skip') + self.assertTrue(self.form.is_skippable) + + def test_action_method(self): + action_result = self.form.action() + self.assertIn('form', action_result) + self.assertEqual(len(action_result['form']), 1) + self.assertIn('submit_label', action_result) + self.assertIn('skip_label', action_result) + self.assertIn('is_skippable', action_result) + +class NumberQuestionTest(TestCase): + def setUp(self): + self.number_question = NumberQuestion( + key='test_key', + min_value=1, + max_value=10, + input_type='number' + ) + + def test_initialization(self): + self.assertEqual(self.number_question.key, 'test_key') + self.assertEqual(self.number_question.min_value, 1) + self.assertEqual(self.number_question.max_value, 10) + self.assertEqual(self.number_question.input_type, 'number') + + def test_action_method(self): + action_result = self.number_question.action() + self.assertIn('key', action_result) + self.assertIn('min_value', action_result) + self.assertIn('max_value', action_result) + self.assertEqual(action_result['min_value'], 1) + self.assertEqual(action_result['max_value'], 10) + +class TextQuestionTest(TestCase): + def setUp(self): + self.text_question = TextQuestion( + key='test_key', + max_length=100, + input_type='text' + ) + + def test_initialization(self): + self.assertEqual(self.text_question.key, 'test_key') + self.assertEqual(self.text_question.max_length, 100) + self.assertEqual(self.text_question.input_type, 'text') + + def test_action_method(self): + action_result = self.text_question.action() + self.assertIn('key', action_result) + self.assertIn('max_length', action_result) + self.assertEqual(action_result['max_length'], 100) + +class BooleanQuestionTest(TestCase): + def setUp(self): + self.boolean_question = BooleanQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + } + ) + + def test_initialization(self): + self.assertEqual(self.boolean_question.key, 'test_key') + self.assertEqual(self.boolean_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.boolean_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + +class ChoiceQuestionTest(TestCase): + def setUp(self): + self.choice_question = ChoiceQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + min_values=1 + ) + + def test_initialization(self): + self.assertEqual(self.choice_question.key, 'test_key') + self.assertEqual(self.choice_question.choices, {'no': 'No', 'yes': 'Yes'}) + self.assertEqual(self.choice_question.min_values, 1) + + def test_action_method(self): + action_result = self.choice_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + self.assertIn('min_values', action_result) + self.assertEqual(action_result['min_values'], 1) + +class DropdownQuestionTest(TestCase): + def setUp(self): + self.dropdown_question = DropdownQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.dropdown_question.key, 'test_key') + self.assertEqual(self.dropdown_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.dropdown_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + +class AutoCompleteQuestionTest(TestCase): + def setUp(self): + self.autocomplete_question = AutoCompleteQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.autocomplete_question.key, 'test_key') + self.assertEqual(self.autocomplete_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.autocomplete_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class RadiosQuestionTest(TestCase): + def setUp(self): + self.radios_question = RadiosQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.radios_question.key, 'test_key') + self.assertEqual(self.radios_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.radios_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class ButtonArrayQuestionTest(TestCase): + def setUp(self): + self.buttonarray_question = ButtonArrayQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.buttonarray_question.key, 'test_key') + self.assertEqual(self.buttonarray_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.buttonarray_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + +class RangeQuestionTest(TestCase): + def setUp(self): + self.range_question = RangeQuestion( + key='test_key', + min_value=1, + max_value=10, + ) + + def test_initialization(self): + self.assertEqual(self.range_question.key, 'test_key') + self.assertEqual(self.range_question.min_value, 1) + self.assertEqual(self.range_question.max_value, 10) + + def test_action_method(self): + action_result = self.range_question.action() + self.assertIn('key', action_result) + self.assertIn('min_value', action_result) + self.assertIn('max_value', action_result) + self.assertEqual(action_result['min_value'], 1) + self.assertEqual(action_result['max_value'], 10) + +class LikertQuestionCustomChoicesTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + +class LikertQuestionSevenScaleStepsTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + scale_steps=7, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, { + 1: "Completely Disagree", + 2: "Strongly Disagree", + 3: "Disagree", + 4: "Neither Agree nor Disagree", + 5: "Agree", + 6: "Strongly Agree", + 7: "Completely Agree", + }) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: "Completely Disagree", + 2: "Strongly Disagree", + 3: "Disagree", + 4: "Neither Agree nor Disagree", + 5: "Agree", + 6: "Strongly Agree", + 7: "Completely Agree", + }) + +class LikertQuestionFiveScaleStepsTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + scale_steps=5, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, { + 1: "Strongly Disagree", + 2: "Disagree", + 3: "Neither Agree nor Disagree", + 4: "Agree", + 5: "Strongly Agree", + }) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: "Strongly Disagree", + 2: "Disagree", + 3: "Neither Agree nor Disagree", + 4: "Agree", + 5: "Strongly Agree", + }) + + +class LikertQuestionIconTest(TestCase): + def setUp(self): + self.likert_question_icon = LikertQuestionIcon( + key='test_key', + scale_steps=7, + likert_view="ICON_RANGE", + ) + + def test_initialization(self): + self.assertEqual(self.likert_question_icon.key, 'test_key') + self.assertEqual(self.likert_question_icon.view, 'ICON_RANGE') + self.assertEqual(self.likert_question_icon.choices, { + 1: 'fa-face-grin-hearts', + 2: 'fa-face-grin', + 3: 'fa-face-smile', + 4: 'fa-face-meh', + 5: 'fa-face-frown', + 6: 'fa-face-frown-open', + 7: 'fa-face-angry', + }) + + def test_action_method(self): + action_result = self.likert_question_icon.action() + self.assertIn('key', action_result) + self.assertIn('view', action_result) + self.assertEqual(action_result['view'], 'ICON_RANGE') + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: 'fa-face-grin-hearts', + 2: 'fa-face-grin', + 3: 'fa-face-smile', + 4: 'fa-face-meh', + 5: 'fa-face-frown', + 6: 'fa-face-frown-open', + 7: 'fa-face-angry', + }) diff --git a/backend/experiment/tests/test_actions_html.py b/backend/experiment/tests/test_actions_html.py new file mode 100644 index 000000000..213450a06 --- /dev/null +++ b/backend/experiment/tests/test_actions_html.py @@ -0,0 +1,10 @@ +from django.test import TestCase + +from experiment.actions.html import HTML + +class HTMLTest(TestCase): + + def test_initialization(self): + test_html_body = "
Test Body
", + heading="Test Heading", + button_label="Test Label", + button_link="http://example.com" + ) + self.assertEqual(info.body, "Test Body
") + self.assertEqual(info.heading, "Test Heading") + self.assertEqual(info.button_label, "Test Label") + self.assertEqual(info.button_link, "http://example.com") + + def test_initialization_only_body(self): + info = Info(body="Only Body
") + self.assertEqual(info.body, "Only Body
") + self.assertEqual(info.heading, "") + self.assertIsNone(info.button_label) + self.assertIsNone(info.button_link) + + def test_initialization_default_values(self): + info = Info(body="Body
", heading="Heading") + self.assertEqual(info.body, "Body
") + self.assertEqual(info.heading, "Heading") + self.assertIsNone(info.button_label) + self.assertIsNone(info.button_link) + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_actions_playlist.py b/backend/experiment/tests/test_actions_playlist.py new file mode 100644 index 000000000..b0c50c310 --- /dev/null +++ b/backend/experiment/tests/test_actions_playlist.py @@ -0,0 +1,36 @@ +import unittest +from unittest.mock import MagicMock + +from experiment.actions.playlist import Playlist + +class TestPlaylist(unittest.TestCase): + + def setUp(self): + self.mock_playlists = [ + MagicMock(id=1, name='Playlist 1'), + MagicMock(id=2, name='Playlist 2') + ] + + self.mock_playlists[0].name = 'Playlist 1' + self.mock_playlists[1].name = 'Playlist 2' + + def test_initialization_with_playlists(self): + playlist_action = Playlist(playlists=self.mock_playlists) + self.assertEqual(len(playlist_action.playlists), 2) + self.assertEqual(playlist_action.playlists[0]['id'], 1) + self.assertEqual(playlist_action.playlists[0]['name'], 'Playlist 1') + + def test_initialization_with_empty_list(self): + playlist_action = Playlist(playlists=[]) + self.assertEqual(playlist_action.playlists, []) + + def test_playlists_structure(self): + playlist_action = Playlist(playlists=self.mock_playlists) + for playlist in playlist_action.playlists: + self.assertIn('id', playlist) + self.assertIn('name', playlist) + self.assertIsInstance(playlist['id'], int) + self.assertIsInstance(playlist['name'], str) + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_actions_score.py b/backend/experiment/tests/test_actions_score.py new file mode 100644 index 000000000..9c33e57d6 --- /dev/null +++ b/backend/experiment/tests/test_actions_score.py @@ -0,0 +1,76 @@ +import unittest +from unittest.mock import Mock + +from experiment.actions.score import Score + +class TestScore(unittest.TestCase): + + def setUp(self): + self.mock_session = Mock() + self.mock_session.last_score.return_value = 10 + self.mock_session.last_song.return_value = "Test Song" + self.mock_session.total_score.return_value = 50 + self.mock_session.rounds_passed.return_value = 2 + self.mock_session.experiment.rounds = 5 + + def test_initialization_full_parameters(self): + score = Score( + session=self.mock_session, + title="Test Title", + score=100, + score_message=lambda x: f"Score is {x}", + config={'show_section': True, 'show_total_score': True}, + icon="icon-test", + timer=5, + feedback="Test Feedback" + ) + self.assertEqual(score.title, "Test Title") + self.assertEqual(score.score, 100) + self.assertEqual(score.score_message(score.score), "Score is 100") + self.assertEqual(score.feedback, "Test Feedback") + self.assertEqual(score.config, {'show_section': True, 'show_total_score': True}) + self.assertEqual(score.icon, "icon-test") + self.assertEqual(score.texts, { + 'score': 'Total Score', + 'next': 'Next', + 'listen_explainer': 'You listened to:' + }) + self.assertEqual(score.timer, 5) + + def test_initialization_minimal_parameters(self): + score = Score(session=self.mock_session) + self.assertIsNone(score.title) + self.assertEqual(score.score, 10) + self.assertEqual(score.score_message, score.default_score_message) + self.assertIsNone(score.feedback) + self.assertEqual(score.config, {'show_section': False, 'show_total_score': False}) + self.assertIsNone(score.icon) + self.assertEqual(score.texts, { + 'score': 'Total Score', + 'next': 'Next', + 'listen_explainer': 'You listened to:' + }) + self.assertIsNone(score.timer) + + def test_action_serialization(self): + score = Score(session=self.mock_session, config={'show_section': True, 'show_total_score': True}) + action = score.action() + self.assertIn('view', action) + self.assertIn('last_song', action) + self.assertIn('total_score', action) + self.assertIn('score', action) + self.assertIn('score_message', action) + self.assertIn('texts', action) + self.assertIn('feedback', action) + self.assertIn('icon', action) + self.assertIn('timer', action) + + def test_default_score_message(self): + score = Score(session=self.mock_session) + self.assertIn(score.default_score_message(10), ["Correct"]) # Positive + self.assertIn(score.default_score_message(0), ["No points"]) # Zero + self.assertIn(score.default_score_message(-5), ["Incorrect"]) # Negative + self.assertIn(score.default_score_message(None), ["No points"]) # None + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_forms.py b/backend/experiment/tests/test_forms.py new file mode 100644 index 000000000..b79e4b053 --- /dev/null +++ b/backend/experiment/tests/test_forms.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +from experiment.forms import ExperimentForm, ExportForm, TemplateForm, EXPERIMENT_RULES, QUESTIONS_CHOICES, SESSION_CHOICES, RESULT_CHOICES, EXPORT_OPTIONS, TEMPLATE_CHOICES + +class ExperimentFormTest(TestCase): + def test_form_fields(self): + form = ExperimentForm() + self.assertIn('name', form.fields) + self.assertIn('slug', form.fields) + self.assertIn('active', form.fields) + self.assertIn('rules', form.fields) + self.assertIn('questions', form.fields) + self.assertIn('rounds', form.fields) + self.assertIn('bonus_points', form.fields) + self.assertIn('playlists', form.fields) + self.assertIn('experiment_series', form.fields) + + def test_rules_field_choices(self): + form = ExperimentForm() + expected_choices = [(i, EXPERIMENT_RULES[i].__name__) for i in EXPERIMENT_RULES] + expected_choices.append(("", "---------")) + self.assertEqual(form.fields['rules'].choices, sorted(expected_choices)) + +class ExportFormTest(TestCase): + def test_form_fields(self): + form = ExportForm() + self.assertIn('export_session_fields', form.fields) + self.assertIn('export_result_fields', form.fields) + self.assertIn('export_options', form.fields) + + def test_field_choices(self): + form = ExportForm() + self.assertEqual(form.fields['export_session_fields'].choices, SESSION_CHOICES) + self.assertEqual(form.fields['export_result_fields'].choices, RESULT_CHOICES) + self.assertEqual(form.fields['export_options'].choices, EXPORT_OPTIONS) + +class TemplateFormTest(TestCase): + def test_form_fields(self): + form = TemplateForm() + self.assertIn('select_template', form.fields) + + def test_template_choices(self): + form = TemplateForm() + self.assertEqual(form.fields['select_template'].choices, TEMPLATE_CHOICES) diff --git a/scripts/test-back-coverage b/scripts/test-back-coverage new file mode 100755 index 000000000..67a0101e0 --- /dev/null +++ b/scripts/test-back-coverage @@ -0,0 +1,3 @@ +#!/bin/bash +docker-compose run --rm server bash -c "coverage run manage.py test && coverage report --show-missing" +