diff --git a/.github/workflows/db-backup-template.yml b/.github/workflows/db-backup-template.yml new file mode 100644 index 000000000..c3b57df90 --- /dev/null +++ b/.github/workflows/db-backup-template.yml @@ -0,0 +1,25 @@ +name: Database Backup Template + +on: + workflow_call: + inputs: + runner: + required: true + type: string + +jobs: + backup: + runs-on: ${{ inputs.runner }} + steps: + + - name: Set Date + id: date + run: echo "DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV + + - name: Backup Database + run: | + podman exec muscle_db_1 sh -c "pg_dump -Fc > /backups/${{ env.DATE }}.dump" + + - name: Remove Old Backups (older than 7 days) + run: | + podman exec muscle_db_1 sh -c "find /backups -type f -name '*.dump' -mtime +7 -exec rm {} \;" \ No newline at end of file diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index 337abea2e..d25a4d61b 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -12,6 +12,7 @@ on: types: [created] jobs: + deploy-test: name: Deploy to test environment environment: Test @@ -79,8 +80,10 @@ jobs: cp .env frontend/.env - name: Build Podman images run: podman-compose -f docker-compose-deploy.yml build + - name: Shut down running containers + run: podman compose -f docker-compose-deploy.yml down - name: Deploy Podman images - run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + run: podman-compose -f docker-compose-deploy.yml up -d - name: Notify Sentry of new release run: | curl -X POST "https://sentry.io/api/0/organizations/uva-aml/releases/" \ @@ -169,8 +172,10 @@ jobs: cp .env frontend/.env - name: Build Podman images run: podman-compose -f docker-compose-deploy.yml build + - name: Shut down running containers + run: podman compose -f docker-compose-deploy.yml down - name: Deploy Podman images - run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + run: podman-compose -f docker-compose-deploy.yml up -d - name: Notify Sentry of new release run: | curl -X POST "https://sentry.io/api/0/organizations/uva-aml/releases/" \ @@ -209,7 +214,7 @@ jobs: AML_LOCATION_PROVIDER: ${{ vars.AML_LOCATION_PROVIDER }} AML_SUBPATH: ${{ vars.AML_SUBPATH }} DJANGO_SETTINGS_MODULE: ${{ vars.DJANGO_SETTINGS_MODULE }} - SENTRY_ENVIRONMENT: "acceptance" + SENTRY_ENVIRONMENT: "production" SQL_DATABASE: ${{ vars.SQL_DATABASE }} SQL_HOST: ${{ vars.SQL_HOST }} SQL_PORT: ${{ vars.SQL_PORT }} @@ -256,8 +261,10 @@ jobs: cp .env frontend/.env - name: Build Podman images run: podman-compose -f docker-compose-deploy.yml build + - name: Shut down running containers + run: podman compose -f docker-compose-deploy.yml down - name: Deploy Podman images - run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + run: podman-compose -f docker-compose-deploy.yml up -d - name: Notify Sentry of new release run: | curl -X POST "https://sentry.io/api/0/organizations/uva-aml/releases/" \ @@ -275,4 +282,19 @@ jobs: - name: Prune old images run: podman image prune -a -f - name: Check Podman images - run: podman-compose -f docker-compose-deploy.yml ps \ No newline at end of file + run: podman-compose -f docker-compose-deploy.yml ps + + e2e-acceptance: + name: E2E tests on acceptance environment + runs-on: ACC + # temporarily true to test e2e tests + if: github.ref == 'refs/heads/main' || github.ref == 'refs/tags/*' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + + env: + BASE_URL: "https://acc.amsterdammusiclab.nl" + + steps: + + - uses: actions/checkout@v4 + - name: Run E2E tests + run: cd e2e && bash run-tests diff --git a/.github/workflows/schedule-db-backup.yml b/.github/workflows/schedule-db-backup.yml new file mode 100644 index 000000000..9447e53f2 --- /dev/null +++ b/.github/workflows/schedule-db-backup.yml @@ -0,0 +1,13 @@ +name: Schedule Database Backup + +on: + schedule: + - cron: '0 0 * * *' # Runs every night at midnight UTC + workflow_dispatch: # Allows manual triggering + +jobs: + + backup-production: + uses: ./.github/workflows/db-backup-template.yml + with: + runner: PRD \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8d5784fc1..7c17938dc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ yarn.lock tests/*.ini tests/*.log tests/__pycache__ + +e2e/screenshots \ No newline at end of file diff --git a/backend/admin_interface/__init__.py b/backend/admin_interface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/admin_interface/admin.py b/backend/admin_interface/admin.py new file mode 100644 index 000000000..e5039b099 --- /dev/null +++ b/backend/admin_interface/admin.py @@ -0,0 +1,105 @@ +from django.contrib import admin +from django.utils.html import format_html +from .models import AdminInterfaceConfiguration, AdminInterfaceThemeConfiguration +from .forms import AdminInterfaceConfigurationForm, AdminInterfaceThemeConfigurationForm + + +class AdminInterfaceThemeConfigurationInline(admin.StackedInline): + model = AdminInterfaceThemeConfiguration + form = AdminInterfaceThemeConfigurationForm + extra = 0 + fields = ( + # Color scheme + + # - Official Django colors + + # -- Main colors + 'color_primary', + 'color_secondary', + 'color_accent', + 'color_primary_fg', + + # -- Body + 'color_body_fg', + 'color_body_bg', + 'color_body_quiet_color', + 'color_body_loud_color', + + # -- Header + 'color_header_color', + + # -- Breadcumbs + 'color_breadcrumbs_fg', + + # -- Link + 'color_link_fg', + 'color_link_hover_color', + 'color_link_selected_fg', + + # -- Borders + 'color_hairline_color', + 'color_border_color', + + # -- Error + 'color_error_fg', + + # -- Message + 'color_message_success_bg', + 'color_message_warning_bg', + 'color_message_error_bg', + + # -- Darkened + 'color_darkened_bg', + + # -- Selected + 'color_selected_bg', + 'color_selected_row', + + # -- Button + 'color_button_fg', + 'color_button_bg', + 'color_button_hover_bg', + 'color_default_button_bg', + 'color_default_button_hover_bg', + 'color_close_button_bg', + 'color_close_button_hover_bg', + 'color_delete_button_bg', + 'color_delete_button_hover_bg', + + # Custom colors + 'color_default_bg', + 'color_default_fg', + 'color_success_bg', + 'color_success_fg', + 'color_warning_bg', + 'color_warning_fg', + 'color_error_bg', + ) + + +class AdminInterfaceConfigurationAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'theme_overview', 'active',) + + form = AdminInterfaceConfigurationForm + inlines = [AdminInterfaceThemeConfigurationInline] + + def theme_overview(self, obj): + theme = obj.theme if hasattr(obj, 'theme') else None + + if not theme: + return "No theme assigned" + + fields = AdminInterfaceThemeConfigurationInline.fields + color_fields = [f for f in fields if f.startswith('color_')] + color_overview = ''.join( + f'' + for f in color_fields + ) + + return format_html(f'
{description}
' + + # Return answer info view + info = Info( + body=body, + heading="Wat is een spectrogram?", + button_label="Volgende", + ) + return info + + def get_score(self, session, rounds_passed): + # Feedback + last_result = session.last_result() + feedback = "" + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if rounds_passed == 1: + appendix = "Op het volgende scherm kun je de drie geluiden beluisteren." + if last_result.score == self.SCORE_CORRECT: + feedback = "Goedzo! Op plaatje C zie je inderdaad de stem van een mens. " + appendix + else: + feedback = "Helaas! Je antwoord was onjuist. Op plaatje C zag je de stem van een mens. " + appendix + elif rounds_passed == 2: + if last_result.score == self.SCORE_CORRECT: + feedback = "Goedzo! Geluid A is inderdaad de Franse baby." + else: + feedback = "Helaas! Geluid A is de Franse baby." + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_round1_question(self): + return "Welk plaatje denk jij dat hoort bij de stem van een mens?" + + def get_round2_question(self): + return "Hierboven zie je twee spectrogrammen van babyhuiltjes. Eentje is een Duitse baby en eentje is een Franse baby. De talen Frans en Duits klinken heel anders. Kun jij bedenken welke van deze baby’s de Franse baby is?" + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session, session.rounds_passed()) + + # Final + final_text = "Goed gedaan!" if session.final_score >= 2 * \ + self.SCORE_CORRECT else "Best lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + debrief_message = "Had jij dat gedacht, dat Franse en Duitse baby's anders huilen? Waarom zouden ze dat doen denk je? Bekijk de filmpjes om dit uit te vinden!" + body = render_to_string( + join('info', 'toontjehogerkids', 'debrief.html'), + {'debrief': debrief_message, 'vid1': 'https://www.youtube.com/embed/q7L_vwB7eIo?si=mRVJKE2urKT-Xxft', + 'vid2': 'https://www.youtube.com/embed/4eKcwGB6xmc?si=ogeEhtyEFa9WxP9i'}) + info = Info( + body=body, + heading="Het eerste luisteren", + button_label="Terug naar ToontjeHogerKids", + button_link="/collection/thkids" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_3_plink.py b/backend/experiment/rules/toontjehogerkids_3_plink.py new file mode 100644 index 000000000..fa5a4d9d4 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_3_plink.py @@ -0,0 +1,158 @@ +import logging +from os.path import join +from django.template.loader import render_to_string + +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Explainer, Step, Score, Final, Info, Trial +from experiment.actions.playback import PlayButton +from experiment.actions.form import AutoCompleteQuestion, Form +from .toontjehoger_3_plink import ToontjeHoger3Plink + +from experiment.utils import non_breaking_spaces + +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids3Plink(ToontjeHoger3Plink): + ID = 'TOONTJE_HOGER_KIDS_3_PLINK' + TITLE = "" + SCORE_MAIN_CORRECT = 10 + SCORE_MAIN_WRONG = 0 + SCORE_EXTRA_1_CORRECT = 4 + SCORE_EXTRA_2_CORRECT = 4 + SCORE_EXTRA_WRONG = 0 + + def validate_era_and_mood(self, sections): + return [] + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + explainer = Explainer( + instruction="Muziekherkenning", + steps=[ + Step("Je hoort zo een heel kort stukje van {} liedjes.".format( + experiment.rounds)), + Step("Herken je de liedjes? Kies dan steeds de juiste artiest en titel!"), + Step( + "Weet je het niet zeker? Doe dan maar een gok.") + ], + step_numbers=True, + button_label="Start" + ) + + return [ + explainer + ] + + def get_last_result(self, session): + ''' get the last score, based on question (plink) + ''' + last_result = session.result_set.last() + + if not last_result: + logger.error("No last result") + return "" + + return last_result + + def get_score_view(self, session): + last_result = self.get_last_result(session) + section = last_result.section + score = last_result.score + + if last_result.expected_response == last_result.given_response: + feedback = "Goedzo! Je hoorde inderdaad {} van {}.".format( + non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist)) + else: + feedback = "Helaas! Je hoorde {} van {}.".format(non_breaking_spaces( + section.song.name), non_breaking_spaces(section.song.artist)) + + config = {'show_total_score': True} + round_number = session.get_relevant_results(['plink']).count() - 1 + score_title = "Ronde %(number)d / %(total)d" %\ + {'number': round_number+1, 'total': session.experiment.rounds} + return Score(session, config=config, feedback=feedback, score=score, title=score_title) + + def get_plink_round(self, session, present_score=False): + next_round = [] + if present_score: + next_round.append(self.get_score_view(session)) + # Get all song sections + all_sections = session.all_sections() + choices = {} + for section in all_sections: + label = section.song_label() + choices[section.pk] = label + + # Get section to recognize + section = session.section_from_unused_song() + if section is None: + raise Exception("Error: could not find section") + + expected_response = section.pk + + question1 = AutoCompleteQuestion( + key='plink', + choices=choices, + question='Kies de artiest en de titel van het nummer', + result_id=prepare_result( + 'plink', + session, + section=section, + expected_response=expected_response + ) + ) + next_round.append(Trial( + playback=PlayButton( + sections=[section] + ), + feedback_form=Form( + [question1], + submit_label='Volgende' + ) + )) + return next_round + + def calculate_score(self, result, data): + """ + Calculate score, based on the data field + """ + return self.SCORE_MAIN_CORRECT if result.expected_response == result.given_response else self.SCORE_MAIN_WRONG + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score_view(session) + + # Final + final_text = "Goed gedaan!" if session.final_score >= 4 * \ + self.SCORE_MAIN_CORRECT else "Best lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + debrief_message = "Hoe snel denk je dat je een popliedje kunt herkennen? Binnen tien seconden?\ + Binnen twee seconden? Of nog minder? Kijk de filmpjes voor het antwoord!" + body = render_to_string( + join('info', 'toontjehogerkids', 'debrief.html'), + {'debrief': debrief_message, 'vid1': 'https://www.youtube.com/embed/A6pkgeABn5s?si=LYYoTn7GM-RlH1-1', + 'vid2': 'https://www.youtube.com/embed/7LRoZ27g1rY?si=7mT5GxkHy1Fjgx_1'}) + info = Info( + body=body, + heading="Muziekherkenning", + button_label="Terug naar ToontjeHogerKids", + button_link="/collection/thkids" + ) + + return [score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_4_absolute.py b/backend/experiment/rules/toontjehogerkids_4_absolute.py new file mode 100644 index 000000000..82c56b7dc --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_4_absolute.py @@ -0,0 +1,71 @@ +from os.path import join +from django.template.loader import render_to_string +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Explainer, Step, Final, Info +from .toontjehoger_4_absolute import ToontjeHoger4Absolute + + +class ToontjeHogerKids4Absolute(ToontjeHoger4Absolute): + ID = 'TOONTJE_HOGER_KIDS_4_ABSOLUTE' + PLAYLIST_ITEMS = 12 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Absoluut gehoor", + steps=[ + Step( + "Je hoort straks stukjes muziek van televisie of filmpjes."), + Step( + "Het zijn er steeds twee: Eentje is het origineel, de andere hebben we een beetje hoger of lager gemaakt."), + Step("Welke klinkt precies zoals jij 'm kent? Welke is het origineel?"), + ], + step_numbers=True, + button_label="Start" + ) + + return [ + explainer, + ] + + def get_trial_question(self): + return "Welke van deze twee stukjes muziek klinkt precies zo hoog of laag als jij 'm kent?" + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Best lastig!" + if session.final_score >= session.experiment.rounds * 0.5 * self.SCORE_CORRECT: + final_text = "Goed gedaan!" + + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + debrief_message = "Lukte het jou om het juiste antwoord te kiezen? Dan heb je goed onthouden hoe hoog of laag die muziekjes normaal altijd klinken! Sommige mensen noemen dit absoluut gehoor. \ + Is dat eigenlijk bijzonder? Kijk de filmpjes om daar achter te komen!" + body = render_to_string( + join('info', 'toontjehogerkids', 'debrief.html'), + {'debrief': debrief_message, 'vid1': 'https://www.youtube.com/embed/0wpT-wjI-0M?si=CALvWqid4SjabL9S', + 'vid2': 'https://www.youtube.com/embed/LQnl1OP3q_Q?si=yTDVPnR7BAeBqWph'}) + info = Info( + body=body, + heading="Absoluut gehoor", + button_label="Terug naar ToontjeHogerKids", + button_link="/collection/thkids" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_5_tempo.py b/backend/experiment/rules/toontjehogerkids_5_tempo.py new file mode 100644 index 000000000..5fc050ea0 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_5_tempo.py @@ -0,0 +1,135 @@ +import logging +import random +from os.path import join +from django.template.loader import render_to_string +from .toontjehoger_1_mozart import toontjehoger_ranks +from .toontjehoger_5_tempo import ToontjeHoger5Tempo +from experiment.actions import Explainer, Step, Score, Final, Info +from experiment.utils import non_breaking_spaces + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids5Tempo(ToontjeHoger5Tempo): + ID = 'TOONTJE_HOGER_KIDS_5_TEMPO' + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Maatgevoel", + steps=[ + Step( + "Je krijgt zo steeds twee keer een stukje muziek te horen met piepjes erin."), + Step( + "Bij de ene versie zijn de piepjes in de maat, bij de andere niet in de maat. "), + Step( + "Kan jij horen waar de piepjes in de maat van de muziek zijn?"), + ], + step_numbers=True, + button_label="Start" + ) + + return [ + explainer + ] + + def get_random_section_pair(self, session, genre): + """ + - session: current Session + - genre: unused + + return a section from an unused song, in both its original and changed variant + """ + + section_original = session.section_from_unused_song( + filter_by={'group': "or"}) + + if not section_original: + raise Exception( + "Error: could not find original section: {}".format(tag_original)) + + section_changed = self.get_section_changed( + session=session, song=section_original.song) + + sections = [section_original, section_changed] + random.shuffle(sections) + return sections + + def get_section_changed(self, session, song): + section_changed = session.playlist.section_set.get( + song__name=song.name, song__artist=song.artist, group='ch' + ) + if not section_changed: + raise Exception( + "Error: could not find changed section: {}".format(song)) + return section_changed + + def get_trial_question(self): + return "Kan jij horen waar de piepjes in de maat van de muziek zijn?" + + def get_score(self, session): + # Feedback + last_result = session.last_result() + feedback = "" + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if last_result.score == self.SCORE_CORRECT: + feedback = "Goedzo! Het was inderdaad antwoord {}!".format( + last_result.expected_response.upper()) + else: + feedback = "Helaas! Het juiste antwoord was {}.".format( + last_result.expected_response.upper()) + + # Create feedback message + # - Track names are always the same + feedback += " Je hoorde '{}' van {}.".format( + last_result.section.song.name, last_result.section.song.artist) + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Best lastig!" + if session.final_score >= session.experiment.rounds * 0.5 * self.SCORE_CORRECT: + final_text = "Goed gedaan!" + + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + debrief_message = "Dit is een test die maatgevoel meet. Onderzoekers hebben laten zien dat de meeste mensen goed maatgevoel hebben. Maar als je nou niet zo goed kan dansen, heb jij dan toch niet zo'n goed maatgevoel? En kan je dit leren? Bekijk de filmpjes voor het antwoord!" + body = render_to_string( + join('info', 'toontjehogerkids', 'debrief.html'), + {'debrief': debrief_message, 'vid1': 'https://www.youtube.com/embed/NXaevlxA3KY?si=Zg2XqBVEoZlcdXBs', + 'vid2': 'https://www.youtube.com/embed/GRXSDXF0GXk?si=XzgZJypMBpZF6pOo'}) + info = Info( + body=body, + heading="Timing en tempo", + button_label="Terug naar ToontjeHogerKids", + button_link="/collection/thkids" + ) + + return [*score, final, info] + + def validate_tags(self, tags): + # No validation needed for TH5 Kids + return [] diff --git a/backend/experiment/rules/toontjehogerkids_6_relative.py b/backend/experiment/rules/toontjehogerkids_6_relative.py new file mode 100644 index 000000000..62c1d1889 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_6_relative.py @@ -0,0 +1,87 @@ +import logging +from django.template.loader import render_to_string +from os.path import join +from .toontjehoger_1_mozart import toontjehoger_ranks +from .toontjehoger_6_relative import ToontjeHoger6Relative +from experiment.actions import Explainer, Step, Score, Final, Info + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids6Relative(ToontjeHoger6Relative): + ID = 'TOONTJE_HOGER_KIDS_6_RELATIVE' + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Relatief Gehoor", + steps=[ + Step("In dit testje kun je jouw relatief gehoor testen!"), + # Empty step adds some spacing between steps to improve readability + Step(""), + Step( + "Je hoort straks twee liedjes, de een wat hoger dan de andere.", number=1), + Step("Luister goed, want je kunt ze maar één keer afspelen!", number=2), + Step( + "De toonhoogte is dus anders. Klinkt het toch als hetzelfde liedje?", number=3), + ], + button_label="Start" + ) + + return [ + explainer, + ] + + def get_score(self, session): + # Feedback + last_result = session.last_result() + + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if last_result.score == self.SCORE_CORRECT: + feedback = "Dat klopt! De liedjes zijn inderdaad verschillend." + else: + feedback = "Helaas! De liedjes zijn toch echt verschillend." + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Goed gedaan!" if session.final_score >= 2 * \ + self.SCORE_CORRECT else "Best lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + debrief_message = "Als je de eerste noten van 'Lang zal ze leven' hoort, herken je het meteen! Hoe kan het dat je dat liedje herkent, zelfs als het veel hoger of langer gezongen wordt? Dit noemen we relatief gehoor. Kijk de filmpjes om uit te vinden hoe dit werkt!" + body = render_to_string( + join('info', 'toontjehogerkids', 'debrief.html'), + {'debrief': debrief_message, 'vid1': 'https://www.youtube.com/embed/MYapIh4zqEM?si=2UKN327IbR_H7FSC', + 'vid2': 'https://www.youtube.com/embed/GRXSDXF0GXk?si=3vvNqRKLWdlMpBs3'}) + info = Info( + body=body, + heading="Relatief gehoor", + button_label="Terug naar ToontjeHogerKids", + button_link="/collection/thkids" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/visual_matching_pairs.py b/backend/experiment/rules/visual_matching_pairs.py index da9c4d69d..493f5c596 100644 --- a/backend/experiment/rules/visual_matching_pairs.py +++ b/backend/experiment/rules/visual_matching_pairs.py @@ -6,8 +6,8 @@ from .base import Base from experiment.actions import Consent, Explainer, Final, Playlist, Step, Trial from experiment.actions.playback import VisualMatchingPairs -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key from result.utils import prepare_result from section.models import Section @@ -19,12 +19,18 @@ class VisualMatchingPairsGame(Base): contact_email = 'aml.tunetwins@gmail.com' def __init__(self): - self.questions = [ - question_by_key('dgf_gender_identity'), - question_by_key('dgf_generation'), - question_by_key('dgf_musical_experience', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_origin'), - question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) + self.question_series = [ + { + "name": "Demographics", + "keys": [ + 'dgf_gender_identity', + 'dgf_generation', + 'dgf_musical_experience', + 'dgf_country_of_origin', + 'dgf_education_matching_pairs', + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 4bbe2d529..e74b90afe 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -3,6 +3,7 @@ from django_markup.markup import formatter from experiment.actions.consent import Consent +from image.serializers import serialize_image from participant.models import Participant from session.models import Session from theme.serializers import serialize_theme @@ -23,7 +24,10 @@ def serialize_experiment_collection( serialized = { 'slug': experiment_collection.slug, 'name': experiment_collection.name, - 'description': experiment_collection.description, + 'description': formatter( + experiment_collection.description, + filter_name='markdown' + ), } if experiment_collection.consent: @@ -31,11 +35,14 @@ def serialize_experiment_collection( if experiment_collection.theme_config: serialized['theme'] = serialize_theme( - experiment_collection.theme_config) + experiment_collection.theme_config + ) if experiment_collection.about_content: serialized['aboutContent'] = formatter( - experiment_collection.about_content, filter_name='markdown') + experiment_collection.about_content, + filter_name='markdown' + ) return serialized @@ -50,12 +57,15 @@ def serialize_experiment_collection_group(group: ExperimentCollectionGroup, part next_experiment = get_upcoming_experiment( grouped_experiments, participant, group.dashboard) + total_score = get_total_score(grouped_experiments, participant) + if not next_experiment: return None return { 'dashboard': [serialize_experiment(experiment.experiment, participant) for experiment in grouped_experiments] if group.dashboard else [], - 'next_experiment': next_experiment + 'nextExperiment': next_experiment, + 'totalScore': total_score } @@ -63,10 +73,8 @@ def serialize_experiment(experiment_object: Experiment, participant: Participant return { 'slug': experiment_object.slug, 'name': experiment_object.name, - 'started_session_count': get_started_session_count(experiment_object, participant), - 'finished_session_count': get_finished_session_count(experiment_object, participant), 'description': experiment_object.description, - 'image': experiment_object.image.file.url if experiment_object.image else '', + 'image': serialize_image(experiment_object.image) if experiment_object.image else None, } @@ -93,3 +101,13 @@ def get_finished_session_count(experiment, participant): count = Session.objects.filter( experiment=experiment, participant=participant, finished_at__isnull=False).count() return count + + +def get_total_score(grouped_experiments, participant): + '''Calculate total score of all experiments on the dashboard''' + total_score = 0 + for grouped_experiment in grouped_experiments: + sessions = Session.objects.filter(experiment=grouped_experiment.experiment, participant=participant) + for session in sessions: + total_score += session.final_score + return total_score diff --git a/backend/experiment/static/experiment_admin.css b/backend/experiment/static/experiment_admin.css index 4ef1f7109..442071db1 100644 --- a/backend/experiment/static/experiment_admin.css +++ b/backend/experiment/static/experiment_admin.css @@ -1,24 +1,3 @@ -.buttons-row { - margin: 0px 0px 0px 10px; -} - -.buttons-row .button { - padding: 2px 5px; - margin: 0px 0px 0px 5px; -} - -.buttons-column { - margin: 10px 0px 0px 0px; -} - -.buttons-column button, .loader { - display: block; - padding: 2px 5px; - margin: 5px 0px 0px 0px; -} - -form .button-show-hide { - width: 45px; - padding: 2px 5px; - margin: 0px 10px 0px 0px; +#id_message { + color: var(--error-fg); } diff --git a/backend/experiment/static/experiment_admin.js b/backend/experiment/static/experiment_admin.js index ae03575fd..23cbca6ae 100644 --- a/backend/experiment/static/experiment_admin.js +++ b/backend/experiment/static/experiment_admin.js @@ -1,150 +1,45 @@ document.addEventListener("DOMContentLoaded", (event) => { - let fieldLabel = document.querySelector(".field-questions > div > label") - let groups = document.querySelectorAll(".field-questions > div > ul > li") - let questions = document.querySelectorAll("#id_questions label") - let formInputs = document.querySelectorAll("button, fieldset, optgroup, option, select, textarea, input") - - let buttons = [ - {"text": "Show all", "eventListener": showAll(true)}, - {"text": "Hide all", "eventListener": showAll(false)}, - {"text": "Select all", "eventListener": selectAll(true)}, - {"text": "Unselect all", "eventListener": selectAll(false)}, - {"text": "Rules' defaults", "eventListener": setDefaultQuestions} - ] - - let buttonsColumn = document.createElement("div") - buttonsColumn.className = "buttons-column" - fieldLabel.append(buttonsColumn) - - buttons.forEach( (button) => { - let btn = createButton() - btn.innerText = button.text - btn.addEventListener("click", button.eventListener) - buttonsColumn.append(btn) - }) - - let loader = document.createElement("div") - loader.className = "loader" - loader.style.display = "none" - loader.innerText = "Loading..." - buttonsColumn.append(loader) - - groups.forEach( (group) => { - - group.style.fontWeight = "bold" - - let buttonsRow = document.createElement("span") - buttonsRow.className = "buttons-row" - - let btn = createButton() - btn.innerText = "Select group" - btn.addEventListener("click", selectGroup(group, true)) - buttonsRow.append(btn) - - btn = createButton() - btn.innerText = "Unselect group" - btn.addEventListener("click", selectGroup(group, false)) - buttonsRow.append(btn) - - buttonsRow.style.display = "none" - group.querySelector("ul").style.display = "none" - group.insertBefore(buttonsRow, group.childNodes[1]) - - btn = createButton() - btn.innerText = "Show" - btn.className += " button-show-hide" - btn.addEventListener("click", toggleShowHide) - group.insertBefore(btn, group.childNodes[0]) - - }) - - function createButton(){ - let btn = document.createElement("button") - btn.className = "button" - btn.type = "button" - return btn - } - - function showAll(show) { - return () => groups.forEach(group => showGroup(group, show)) - } - - function showGroup(group, show) { - let questionList = group.querySelector("ul") - questionList.style.display = show ? "" : "none" - - let showHideButton = group.querySelector(".button-show-hide") - showHideButton.innerText = show ? "Hide" : "Show" - - let selectButtonRow = group.querySelector(".buttons-row") - selectButtonRow.style.display = show ? "" : "none" - } - - function selectGroup(group, checked) { - let checkbxs = group.querySelectorAll("input") - return () => checkbxs.forEach(c => c.checked = checked) - } - - function selectAll(checked){ - return () => groups.forEach(group => { - selectGroup(group, checked)() - showGroup(group, true) - }) - } - - function toggleShowHide() { - - let group = this.parentElement - let questionList = group.querySelector("ul") - - if (questionList.style.display == "" || questionList.style.display == "block") { - showGroup(group, false) - } else if (questionList.style.display == "none") { - showGroup(group, true) - } - } - - // Question text presented in experiment admin must include question key in parenthesis, e.g. (dgf_country_of_origin) - async function setDefaultQuestions() { - - // Selected Rules - let rules = document.getElementById("id_rules").value - - let defaultQuestions = [] - - if (rules) { - - formInputs.forEach( c => c.setAttribute("disabled","")) - loader.style.display = "block" - - //Get default question list - let url=`/experiment/default_questions/${rules}/` - let response = await fetch(url) - - if (response.ok) { - let json_data = await response.json() - defaultQuestions = json_data['default_questions'] - } - - formInputs.forEach( c => c.removeAttribute("disabled")) - loader.style.display = "none" - } - - // Uncheck all questions - selectAll(false)() - - // Check questions present in the default questions list - for (const question of questions) { - for (const defaultQuestion of defaultQuestions) { - if (question.textContent.includes(`(${defaultQuestion})`) > 0) { - question.querySelector("input").checked = true - } - } - } - } - + // Get experiment id from URL + match = window.location.href.match(/\/experiment\/experiment\/(.+)\/change/) + experiment_id = match && match[1] + + let buttonAddDefaultQuestions = document.createElement("input") + buttonAddDefaultQuestions.type = "button" + buttonAddDefaultQuestions.value = "Add rules' defaults and save" + buttonAddDefaultQuestions.addEventListener("click", addDefaultQuestions) + + let message = document.createElement("span") + message.id = "id_message" + message.className = "form-row" + + document.querySelector('#questionseries_set-group').append(buttonAddDefaultQuestions, message) + + let selectRules = document.querySelector("#id_rules") + selectRules.onchange = toggleButton + toggleButton() + + function toggleButton(e) { + + // Check if we are on a Change Experiment (not Add Experiment) and if selection for Experiment rules has not changed + if ( experiment_id && (selectRules[selectRules.selectedIndex] === selectRules.querySelector("option[selected]")) ) { + buttonAddDefaultQuestions.disabled = false + message.innerText = "" + } else { + buttonAddDefaultQuestions.disabled = true + message.innerText = "Save Experiment first" + } + } + + async function addDefaultQuestions() { + + const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + let response = await fetch(`/experiment/add_default_question_series/${experiment_id}/`, + {method:"POST", mode: 'same-origin',headers: {'X-CSRFToken': csrftoken}}) + + if (response.ok) { + location.reload() + } + } }) - - diff --git a/backend/experiment/static/questionseries_admin.js b/backend/experiment/static/questionseries_admin.js new file mode 100644 index 000000000..9cab6b8b0 --- /dev/null +++ b/backend/experiment/static/questionseries_admin.js @@ -0,0 +1,50 @@ + +document.addEventListener("DOMContentLoaded", (event) => { + + async function getQuestionGroups(){ + + let response = await fetch(`/question/question_groups/`) + + if (response.ok) { + return await response.json() + } + } + + getQuestionGroups().then( (questionGroups) => { + + let buttonAddQuestionGroup = document.createElement("input") + buttonAddQuestionGroup.type = "button" + buttonAddQuestionGroup.value = "Add all questions in group" + buttonAddQuestionGroup.addEventListener("click", addQuestionGroup) + + let selectQuestionGroup = document.createElement("select") + + Object.keys(questionGroups).sort().forEach( (group) => { + option = document.createElement("option") + option.innerText = group + selectQuestionGroup.append(option) + }) + + document.querySelector('#questioninseries_set-group').append(buttonAddQuestionGroup, selectQuestionGroup) + + function addQuestionGroup() { + + // "Add another Question in series" is already created by Django + let addQuestionAnchor = document.querySelector(".add-row a") + + questionGroups[selectQuestionGroup.value].forEach ( (questionKey) => { + + totalFormsInput = document.querySelector("#id_questioninseries_set-TOTAL_FORMS") + totalFormsBefore = Number(totalFormsInput.value) + addQuestionAnchor.click() + totalForms = Number(totalFormsInput.value) + + if (totalForms == totalFormsBefore + 1) { + questionSelect = document.querySelector(`#id_questioninseries_set-${totalForms-1}-question`) + questionSelect.querySelector(`option[value=${questionKey}]`).selected = true + document.querySelector(`#id_questioninseries_set-${totalForms-1}-index`).value = totalForms + } + }) + } + }) +}) diff --git a/backend/experiment/templates/info/toontjehogerkids/debrief.html b/backend/experiment/templates/info/toontjehogerkids/debrief.html new file mode 100644 index 000000000..8571e85a3 --- /dev/null +++ b/backend/experiment/templates/info/toontjehogerkids/debrief.html @@ -0,0 +1,10 @@ ++ + {{debrief}} + +
+ + + + + \ No newline at end of file diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index be9351801..b89cf3ca9 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -14,7 +14,7 @@ # Expected field count per model -EXPECTED_EXPERIMENT_FIELDS = 16 +EXPECTED_EXPERIMENT_FIELDS = 15 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 diff --git a/backend/experiment/tests/test_forms.py b/backend/experiment/tests/test_forms.py index 1b3d266e2..e76b5998e 100644 --- a/backend/experiment/tests/test_forms.py +++ b/backend/experiment/tests/test_forms.py @@ -10,7 +10,6 @@ def test_form_fields(self): 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) diff --git a/backend/experiment/tests/test_model_functions.py b/backend/experiment/tests/test_model_functions.py index 498909e36..6eb39f2ce 100644 --- a/backend/experiment/tests/test_model_functions.py +++ b/backend/experiment/tests/test_model_functions.py @@ -10,9 +10,9 @@ def setUpTestData(cls): def test_separate_rules_instance(self): rules1 = self.experiment.get_rules() rules2 = self.experiment.get_rules() - keys1 = [q.key for q in rules1.questions] - keys2 = [q.key for q in rules2.questions] - assert keys1 != keys2 + keys1 = rules1.question_series[0]['keys'] + rules1.question_series[1]['keys'] + keys2 = rules2.question_series[0]['keys'] + rules2.question_series[1]['keys'] + assert keys1 == keys2 class TestModelExperimentCollection(TestCase): diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index 01fe17582..305dbe353 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -1,9 +1,11 @@ +from django.conf import settings from django.test import TestCase from django.utils import timezone from image.models import Image from experiment.serializers import ( serialize_experiment, + serialize_experiment_collection_group ) from experiment.models import ( Experiment, @@ -11,8 +13,10 @@ ExperimentCollectionGroup, GroupedExperiment, ) +from experiment.rules.hooked import Hooked from participant.models import Participant from session.models import Session +from theme.models import ThemeConfig, FooterConfig, HeaderConfig class TestExperimentCollectionViews(TestCase): @@ -20,9 +24,11 @@ class TestExperimentCollectionViews(TestCase): @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create() + theme_config = create_theme_config() collection = ExperimentCollection.objects.create( name='Test Series', - slug='test_series' + slug='test_series', + theme_config=theme_config ) introductory_group = ExperimentCollectionGroup.objects.create( name='introduction', @@ -41,7 +47,7 @@ def setUpTestData(cls): order=2 ) cls.experiment2 = Experiment.objects.create( - name='experiment2', slug='experiment2') + name='experiment2', slug='experiment2', theme_config=theme_config) cls.experiment3 = Experiment.objects.create( name='experiment3', slug='experiment3') GroupedExperiment.objects.create( @@ -72,7 +78,7 @@ def test_get_experiment_collection(self): # check that first_experiments is returned correctly response = self.client.get('/experiment/collection/test_series/') self.assertEqual(response.json().get( - 'next_experiment').get('slug'), 'experiment1') + 'nextExperiment').get('slug'), 'experiment1') # create session Session.objects.create( experiment=self.experiment1, @@ -80,7 +86,7 @@ def test_get_experiment_collection(self): finished_at=timezone.now() ) response = self.client.get('/experiment/collection/test_series/') - self.assertIn(response.json().get('next_experiment').get( + self.assertIn(response.json().get('nextExperiment').get( 'slug'), ('experiment2', 'experiment3')) self.assertEqual(response.json().get('dashboard'), []) Session.objects.create( @@ -94,8 +100,14 @@ def test_get_experiment_collection(self): finished_at=timezone.now() ) response = self.client.get('/experiment/collection/test_series/') - self.assertEqual(response.json().get( - 'next_experiment').get('slug'), 'experiment4') + response_json = response.json() + self.assertEqual(response_json.get( + 'nextExperiment').get('slug'), 'experiment4') + self.assertEqual(response_json.get('dashboard'), []) + self.assertEqual(response_json.get('theme').get('name'), 'test_theme') + self.assertEqual(len(response_json['theme']['header']['score']), 3) + self.assertEqual(response_json.get('theme').get('footer').get( + 'disclaimer'), 'Test Disclaimer
') def test_experiment_collection_with_dashboard(self): # if ExperimentCollection has dashboard set True, return list of random experiments @@ -116,6 +128,35 @@ def test_experiment_collection_with_dashboard(self): response = self.client.get('/experiment/collection/test_series/') self.assertEqual(type(response.json().get('dashboard')), list) + def test_experiment_collection_total_score(self): + """ Test calculation of total score for grouped experiment on dashboard """ + session = self.client.session + session['participant_id'] = self.participant.id + session.save() + Session.objects.create( + experiment=self.experiment2, + participant=self.participant, + finished_at=timezone.now(), + final_score=8 + ) + intermediate_group = ExperimentCollectionGroup.objects.get( + name='intermediate' + ) + intermediate_group.dashboard = True + intermediate_group.save() + serialized_coll_1 = serialize_experiment_collection_group(intermediate_group, self.participant) + total_score_1 = serialized_coll_1['totalScore'] + self.assertEqual(total_score_1, 8) + Session.objects.create( + experiment=self.experiment3, + participant=self.participant, + finished_at=timezone.now(), + final_score=8 + ) + serialized_coll_2 = serialize_experiment_collection_group(intermediate_group, self.participant) + total_score_2 = serialized_coll_2['totalScore'] + self.assertEqual(total_score_2, 16) + class ExperimentViewsTest(TestCase): @@ -126,8 +167,11 @@ def test_serialize_experiment(self): name='Test Experiment', description='This is a test experiment', image=Image.objects.create( - file='test-image.jpg' - ) + file='test-image.jpg', + alt='Test', + href='https://www.example.com' + ), + theme_config=create_theme_config() ) participant = Participant.objects.create() Session.objects.bulk_create([ @@ -148,8 +192,64 @@ def test_serialize_experiment(self): serialized_experiment['description'], 'This is a test experiment' ) self.assertEqual( - serialized_experiment['image'], '/upload/test-image.jpg' + serialized_experiment['image'], { + 'file': f'{settings.BASE_URL}/upload/test-image.jpg', 'href': 'https://www.example.com', 'alt': 'Test'} ) + + def test_get_experiment(self): + # Create an experiment + experiment = Experiment.objects.create( + slug='test-experiment', + name='Test Experiment', + description='This is a test experiment', + image=Image.objects.create( + file='test-image.jpg' + ), + rules=Hooked.ID, + theme_config=create_theme_config() + ) + participant = Participant.objects.create() + Session.objects.bulk_create([ + Session(experiment=experiment, participant=participant, finished_at=timezone.now()) for index in range(3) + ]) + + response = self.client.get('/experiment/test-experiment/') + self.assertEqual( - serialized_experiment['finished_session_count'], 3 + response.json()['slug'], 'test-experiment' ) + self.assertEqual( + response.json()['name'], 'Test Experiment' + ) + self.assertEqual( + response.json()['theme']['name'], 'test_theme' + ) + self.assertEqual( + len(response.json()['theme']['header']['score']), 3 + ) + self.assertEqual( + response.json()['theme']['footer']['disclaimer'], 'Test Disclaimer
' + ) + + +def create_theme_config(): + theme_config = ThemeConfig.objects.create( + name='test_theme', + description='Test Theme', + heading_font_url='https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap', + body_font_url='https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap', + logo_image=Image.objects.create(file='test-logo.jpg'), + background_image=Image.objects.create(file='test-background.jpg'), + ) + HeaderConfig.objects.create( + theme=theme_config, + show_score=True + ) + footer_config = FooterConfig.objects.create( + theme=theme_config, + disclaimer='Test Disclaimer', + privacy='Test Privacy', + ) + footer_config.logos.add(Image.objects.create(file='test-logo.jpg')) + + return theme_config diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 09692262d..d6153b18e 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,10 +1,11 @@ from django.urls import path from django.views.generic.base import TemplateView -from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown, validate_experiment_playlist +from .views import get_experiment, get_experiment_collection, post_feedback, render_markdown, add_default_question_series, validate_experiment_playlist app_name = 'experiment' urlpatterns = [ + path('add_default_question_series/Some information
', + 'logos': [ + { + 'file': f'{settings.BASE_URL}{settings.MEDIA_URL}someimage.jpg', + 'href': 'someurl.com', + 'alt': 'Alt text' + }, + { + 'file': f'{settings.BASE_URL}{settings.MEDIA_URL}anotherimage.png', + 'href': 'another.url.com', + 'alt': 'Another alt text' + } + ], + 'privacy': 'Some privacy message
' } self.assertEqual(serialize_footer(self.footer), expected_json) def test_header_serializer(self): - expected_json = { - 'showScore': True, + expected_json = { 'nextExperimentButtonText': 'Next experiment', - 'aboutButtonText': 'About us' + 'aboutButtonText': 'About us', + 'score': { + 'scoreClass': 'gold', + 'scoreLabel': 'Points', + 'noScoreLabel': 'No points yet!' + } } self.assertEqual(serialize_header(self.header), expected_json) @@ -56,8 +78,8 @@ def test_theme_config_serializer(self): 'description': 'Default theme configuration', 'headingFontUrl': 'https://example.com/heading_font', 'bodyFontUrl': 'https://example.com/body_font', - 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', - 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'logoUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}anotherimage.png', 'footer': serialize_footer(self.footer), 'header': serialize_header(self.header), } @@ -87,8 +109,8 @@ def test_theme_serialization_no_footer(self): 'description': 'Default theme configuration', 'headingFontUrl': 'https://example.com/heading_font', 'bodyFontUrl': 'https://example.com/body_font', - 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', - 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'logoUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}anotherimage.png', 'header': serialize_header(self.header), 'footer': None, } @@ -101,8 +123,8 @@ def test_theme_serialization_no_header(self): 'description': 'Default theme configuration', 'headingFontUrl': 'https://example.com/heading_font', 'bodyFontUrl': 'https://example.com/body_font', - 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', - 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'logoUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.BASE_URL}{settings.MEDIA_URL}anotherimage.png', 'header': None, 'footer': serialize_footer(self.footer), } diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 000000000..fd9d10415 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,21 @@ +# Configuration for running Selenium tests in a Docker container with Chrome and Firefox +# with tests written in Python and using the Selenium WebDriver API. + +# Use the official Python image from the Docker Hub +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /usr/src/app + +# Install the Selenium Python package +RUN pip install selenium + +# Install the Chromium browser and the Chrome WebDriver +RUN apt update +RUN apt install -y chromium chromium-driver curl + +# Run the tests +CMD ["python", "tests-selenium.py"] + +# Set ENV DISPLAY to 99 +ENV DISPLAY=:99 \ No newline at end of file diff --git a/e2e/run-tests b/e2e/run-tests new file mode 100755 index 000000000..25db3ace4 --- /dev/null +++ b/e2e/run-tests @@ -0,0 +1,22 @@ + +#!/bin/bash + +# This script builds a Docker image and either runs tests in a container or opens an interactive bash terminal based on the supplied option. + +# Build the docker image +docker build -t selenium-tests . + +# if BASE_URL is not set, throw an error +if [ -z "$BASE_URL" ]; then + echo "BASE_URL is not set. Please set the BASE_URL environment variable to the URL of the application you want to test." + exit 1 +fi + +# Check for '-i' parameter to decide the mode of operation +if [[ "$1" == "-i" ]]; then + # If '-i' is supplied, start the container with an interactive bash shell + docker run --rm -it --network="host" -e BASE_URL=$BASE_URL --name selenium-tests-container -v $(pwd):/usr/src/app --entrypoint bash selenium-tests +else + # If no '-i' is supplied, run the python test script + docker run --rm -it --network="host" -e BASE_URL=$BASE_URL --name selenium-tests-container -v $(pwd):/usr/src/app selenium-tests python tests-selenium.py +fi \ No newline at end of file diff --git a/e2e/tests-selenium.ini b/e2e/tests-selenium.ini new file mode 100644 index 000000000..e3d70d648 --- /dev/null +++ b/e2e/tests-selenium.ini @@ -0,0 +1,14 @@ +[selenium] +browser=Chromium +; Firefox | Chrome | Safari | Edge | Chromium + +headless=yes +; yes | no (headless does not work on Safari) + +[url] +root=http://127.0.0.1:3000 + +[experiment_slugs] +beat_alignment=bat +eurovision=eurovision_2021 +categorization=cat \ No newline at end of file diff --git a/e2e/tests-selenium.py b/e2e/tests-selenium.py new file mode 100644 index 000000000..01e086632 --- /dev/null +++ b/e2e/tests-selenium.py @@ -0,0 +1,458 @@ +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.expected_conditions import presence_of_element_located +from selenium.webdriver.support.select import Select +import random +import time +import unittest +import configparser +import warnings +import os + + +class TestsSelenium(unittest.TestCase): + """ + Install selenium on your system + pip install selenium + + Create tests-selenium.ini file in the same directory as this file describing your setup: + + ``` + [selenium] + browser=Firefox + ; Firefox | Chrome | Safari | Edge + + headless=no + ; yes | no (headless does not work on Safari) + + [url] + root=http://localhost:3000 + ; root url of the server, used as fallback if BASE_URL is not set + + [experiment_slugs] + beat_alignment=bat + eurovision=ev + ``` + + Run tests: + python tests-selenium.py + + To skip individual tests, add `@unittest.skip` before the test + """ + + def setUp(self): + + warnings.simplefilter("ignore", ResourceWarning) + + self.config = configparser.ConfigParser() + self.config.read('tests-selenium.ini') + ini_config_base_url = self.config['url']['root'] + self.base_url = os.getenv('BASE_URL', ini_config_base_url) + + print(f"Running tests on {self.base_url}") + + # Check if config is set + if not self.config.sections(): + raise Exception("Config file not found or empty") + + browser = self.config['selenium']['browser'] + headless = self.config['selenium']['headless'] == "yes" + + if browser == "Firefox": + options = webdriver.FirefoxOptions() + + if headless: + options.add_argument("-headless") + + self.driver = webdriver.Firefox(options=options) + + elif browser == "Chrome": + options = webdriver.ChromeOptions() + options.binary_location = '/usr/bin/chromium' + if headless: + options.add_argument("--headless") + + self.driver = webdriver.Chrome(options=options, executable_path='/usr/bin/chromedriver') + + elif browser == "Chromium": + options = webdriver.ChromeOptions() + options.binary_location = '/usr/bin/chromium' + + if headless: + options.add_argument("--no-sandbox") + options.add_argument("--headless") + + service = Service('/usr/bin/chromedriver') + self.driver = webdriver.Chrome(service=service, options=options) + + elif browser == "Safari": + options = webdriver.safari.options.Options() + self.driver = webdriver.Safari(options=options) + + elif browser == "Edge": + options = webdriver.EdgeOptions() + + if headless: + options.add_argument("--headless=new") + + self.driver = webdriver.Edge(options=options) + + else: + raise Exception("Unknown browser") + + self.driver.set_window_size(1920, 1080) + + def tearDown(self): + self.driver.quit() + + # This is a simple test to check if the e2e setup and the internet connection is working + def test_google(self): + self.driver.get("http://www.google.com") + self.assertIn("Google", self.driver.title) + + print("Google (and thus the internet) is working!") + + def test_beatalignment(self): + + experiment_name = "beat_alignment" + + try: + + experiment_slug = self.config['experiment_slugs'][experiment_name] + self.driver.get(f"{self.base_url}/{experiment_slug}") + + # if page body contains the word "Error", raise an exception + self.check_for_error(experiment_name, experiment_slug) + + # Wait for ok button to appear and click it + WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, '//button[text()="Ok"]'))) \ + + # Explainer + ok_button = self.driver.find_element(By.XPATH, "//button[text()='Ok']") + + if not ok_button: + raise Exception("Ok button not found") + + ok_button.click() + + # Wait for examples to end and click Start + WebDriverWait(self.driver, 45, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, '//button[text()="Start"]'))) \ + .click() + + btn1 = '//label[text()="ALIGNED TO THE BEAT"]' + btn2 = '//label[text()="NOT ALIGNED TO THE BEAT"]' + + print("Starting BAT rounds...") + + current_round = 1 + + while self.driver.find_element(By.TAG_NAME, "h4").text != "END": + # randomly pick a button to click + btn = random.choice([btn1, btn2]) + WebDriverWait(self.driver, 45, poll_frequency=3) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, btn))) \ + + btn_element = self.driver.find_element(By.XPATH, btn) + + # click the button if it exists and is clickable + if btn_element and "disabled" not in btn_element.get_attribute("class"): + print(f"Round {current_round}") + btn_element.click() + current_round += 1 + + # wait 1 second + time.sleep(1) + + print("BAT rounds completed") + + # Check if the final score is displayed + end_heading = self.driver.find_element(By.TAG_NAME, "h4").text == "END" + + if not end_heading: + raise Exception("End heading not found") + + print("Beat Alignment Test completed!") + + except Exception as e: + self.handle_error(e, experiment_name) + + def test_eurovision(self): + + experiment_name = "eurovision" + + try: + + experiment_slug = self.config['experiment_slugs'][experiment_name] + self.driver.get(f"{self.base_url}/{experiment_slug}") + + # if page body contains the word "Error", raise an exception + self.check_for_error(experiment_name, experiment_slug) + + # Check & Agree to Informed Consent + self.agree_to_consent() + + # Explainer + WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, '//button[text()="Let\'s go!"]'))) \ + .click() + + print("Let's go! button clicked") + + h4_text = None + bonus_rounds = False + + while True: + + if h4_text is None: + time.sleep(1) + + h4_text = WebDriverWait(self.driver, 5).until(expected_conditions.presence_of_element_located((By.TAG_NAME,"h4"))).text + + print(f"Round {h4_text} started...") + + if "ROUND " in h4_text: + + for i in range(2): + ans = random.choices(["Yes", "No", "No response"], weights=(40, 40, 20))[0] + + if ans in ("Yes", "No"): + WebDriverWait(self.driver, 6) \ + .until(presence_of_element_located((By.XPATH, '//*[text()="{}"]'.format(ans)))) \ + .click() + + print(f"Round {h4_text} - {ans}") + + if ans in ("No", "No response") or bonus_rounds: + print(f"Round {h4_text} - Continue") + break + + # wait for next page to load + time.sleep(1) + + WebDriverWait(self.driver, 25, poll_frequency=1) \ + .until(presence_of_element_located((By.XPATH, '//*[text()="Next"]'))) \ + .click() + + print(f"Round {h4_text} - Next") + + # wait for next page to load + time.sleep(1) + + elif h4_text == "QUESTIONNAIRE": + + # get .aha__question h3 text + h3_text = self.driver.find_element(By.CSS_SELECTOR, ".aha__question h3").text + print(f"Questionnaire - {h3_text}") + + if self.driver.find_elements(By.CLASS_NAME, "aha__radios"): + self.driver.find_element(By.CSS_SELECTOR, ".radio:nth-child(1)").click() + print("Radio button picked (1)") + + if self.driver.find_elements(By.TAG_NAME, "select"): + select = Select(self.driver.find_element(By.TAG_NAME, 'select')) + select.select_by_value('nl') + print("Select option 'nl' picked") + + if self.driver.find_elements(By.CLASS_NAME, "aha__text-range"): + self.driver.find_element(By.CSS_SELECTOR, ".rangeslider").click() + print("Range slider clicked") + + other_input = self.driver.find_elements(By.CSS_SELECTOR, "input[type='text']") + + if other_input: + other_input[0].send_keys("Trumpet") + print("Text input filled with 'Trumpet'") + + # Click Continue after question answered + WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, '//button[text()="Continue"]'))) \ + .click() + + print("Continue button clicked") + + # wait for next page to load + time.sleep(1) + + elif h4_text == "FINAL SCORE": + break + + elif self.driver.find_element(By.CSS_SELECTOR, "h3").text == "Bonus Rounds": + WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, '//button[text()="Continue"]'))) \ + .click() + + bonus_rounds = True + + print("Bonus Rounds - Continue") + + # wait for next page to load + time.sleep(1) + + else: + raise Exception("Unknown view") + + self.driver.find_element(By.XPATH, '//*[text()="Play again"]') + + print("Eurovision Test completed!") + + except Exception as e: + self.handle_error(e, experiment_name) + + def test_categorization(self): + + experiment_name = "categorization" + + try: + self.driver.delete_all_cookies() + + experiment_slug = self.config['experiment_slugs'][experiment_name] + self.driver.get(f"{self.base_url}/{experiment_slug}") + + # if page body contains the word "Error", raise an exception + self.check_for_error(experiment_name, experiment_slug) + + # wait until h4 element is present and contains "INFORMED CONSENT" text (case-insensitive) + WebDriverWait(self.driver, 5) \ + .until(lambda x: "informed consent" in x.find_element(By.TAG_NAME, "h4").text.lower()) + + # click "I agree" button + i_agree_button = self.driver.find_element(By.XPATH, '//button[text()="I agree"]') + i_agree_button.click() + + # Explainer 1 + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.XPATH, "//button[text()=\"Ok\"]"))) \ + .click() + + # What is your age? + age_input = WebDriverWait(self.driver, 3).until(presence_of_element_located((By.CSS_SELECTOR,"input[type='number']"))) + age_input.send_keys(18) + self.driver.find_element(By.XPATH, '//*[text()="Continue"]').click() + + # What is your gender + WebDriverWait(self.driver, 3).until(presence_of_element_located((By.CSS_SELECTOR,".radio:nth-child(1)"))).click() + self.driver.find_element(By.XPATH, '//*[text()="Continue"]').click() + + # What is your native language + WebDriverWait(self.driver, 3).until(presence_of_element_located((By.TAG_NAME, 'select'))) + select = Select(self.driver.find_element(By.TAG_NAME, 'select')) + select.select_by_value('nl') + + # Wait for the Continue button to appear and click it + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.XPATH, '//*[text()="Continue"]'))) \ + .click() + + # Please select your level of musical experience + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.CSS_SELECTOR, ".radio:nth-child(1)"))).click() + + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.XPATH, '//button[text()="Continue"]'))) \ + .click() + + # Explainer 2 + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.XPATH, "//button[text()=\"Ok\"]"))) \ + .click() + + training_rounds = 20 + testing_rounds = 80 + training = True + for n in (training_rounds, testing_rounds): + + round_type = "training" if n == training_rounds else "testing" + + print(f"Starting {round_type} rounds...") + + for i in range(n): + + # wait .5 second + time.sleep(.5) + + WebDriverWait(self.driver, 5) \ + .until( + presence_of_element_located((By.CSS_SELECTOR, ".aha__play-button")) and + expected_conditions.element_to_be_clickable((By.CSS_SELECTOR, ".aha__play-button"))) \ + .click() + + print('Round', i + 1, "Play button clicked") + + round_heading = WebDriverWait(self.driver, 10) \ + .until(presence_of_element_located((By.TAG_NAME, "h4"))).text + + print(f"{round_type.capitalize()} round {i + 1} - {round_heading}") + + expected_response = self.driver.execute_script('return document.querySelector(".expected-response").textContent') + input_element = self.driver.execute_script(f'return document.querySelector(\'input[value="{expected_response}"]\')') + button_to_click = self.driver.execute_script(f'return document.querySelector(\'input[value="{expected_response}"]\').parentElement') + + # wait for label + input to not be disabled + WebDriverWait(self.driver, 5) \ + .until(lambda x: False if input_element.get_attribute("disabled") else input_element) \ + + WebDriverWait(self.driver, 5) \ + .until(lambda x: False if "disabled" in button_to_click.get_attribute("class") else button_to_click) \ + .click() + + print(f"{round_type.capitalize()} round {i + 1} - Answer {expected_response} clicked") + + # The score is only consistently shown during training rounds + if training: + + print("Waiting for score...") + + # wait for Score to appear + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.CSS_SELECTOR, ".aha__score"))) + + # wait for Score to disappear (next round) + WebDriverWait(self.driver, 5) \ + .until(lambda x: False if x.find_elements(By.CSS_SELECTOR, ".aha__score") else True) + + if training: + WebDriverWait(self.driver, 5) \ + .until(presence_of_element_located((By.XPATH, "//button[text()=\"Ok\"]"))) \ + .click() + training = False + + except Exception as e: + self.handle_error(e, experiment_name) + + def agree_to_consent(self, h4_text='informed consent', button_text='I agree'): + # If consent present, agree + informed_consent_heading = WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(lambda x: h4_text in x.find_element(By.TAG_NAME, "h4").text.lower()) + + if not informed_consent_heading: + raise Exception("Informed consent not found") + + WebDriverWait(self.driver, 5, poll_frequency=1) \ + .until(expected_conditions.element_to_be_clickable((By.XPATH, f'//button[text()="{button_text}"]'))) \ + .click() + + print("I agree button clicked") + + def check_for_error(self, experiment_name, experiment_slug='[no slug provided]'): + if "Error" in self.driver.find_element(By.TAG_NAME, "body").text: + raise Exception(f"Could not load {experiment_name} experiment, please check the server logs and make sure the slug ({experiment_slug}) is correct.") + + def take_screenshot(self, experiment_name, notes=""): + current_time = time.strftime("%Y-%m-%d-%H-%M-%S") + screen_shot_path = f"screenshots/{experiment_name}-{current_time}.png" + print('Capturing screenshot to', screen_shot_path, notes) + self.driver.get_screenshot_as_file(screen_shot_path) + + def handle_error(self, e, experiment_name): + self.take_screenshot(experiment_name, str(e)) + self.fail(e) + + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/.pnp.cjs b/frontend/.pnp.cjs index bd14d39e3..a9f337291 100755 --- a/frontend/.pnp.cjs +++ b/frontend/.pnp.cjs @@ -41,6 +41,7 @@ const RAW_RUNTIME_STATE = ["@testing-library/user-event", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:14.5.1"],\ ["@types/react", "npm:18.2.73"],\ ["@types/react-dom", "npm:18.2.23"],\ + ["@types/react-helmet", "npm:6.1.11"],\ ["@types/react-router-dom", "npm:5.3.3"],\ ["@vitejs/plugin-react", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:4.2.1"],\ ["@vitest/coverage-istanbul", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:1.4.0"],\ @@ -63,6 +64,7 @@ const RAW_RUNTIME_STATE = ["qs", "npm:6.11.2"],\ ["react", "npm:18.2.0"],\ ["react-dom", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:18.2.0"],\ + ["react-helmet", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:6.1.0"],\ ["react-rangeslider", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:2.2.0"],\ ["react-router", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:5.2.0"],\ ["react-router-dom", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:5.2.0"],\ @@ -8493,6 +8495,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@types/react-helmet", [\ + ["npm:6.1.11", {\ + "packageLocation": "../../../.yarn/berry/cache/@types-react-helmet-npm-6.1.11-6d6a281744-10c0.zip/node_modules/@types/react-helmet/",\ + "packageDependencies": [\ + ["@types/react-helmet", "npm:6.1.11"],\ + ["@types/react", "npm:18.2.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/react-router", [\ ["npm:5.1.20", {\ "packageLocation": "../../../.yarn/berry/cache/@types-react-router-npm-5.1.20-620ccce99a-10c0.zip/node_modules/@types/react-router/",\ @@ -9227,6 +9239,7 @@ const RAW_RUNTIME_STATE = ["@testing-library/user-event", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:14.5.1"],\ ["@types/react", "npm:18.2.73"],\ ["@types/react-dom", "npm:18.2.23"],\ + ["@types/react-helmet", "npm:6.1.11"],\ ["@types/react-router-dom", "npm:5.3.3"],\ ["@vitejs/plugin-react", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:4.2.1"],\ ["@vitest/coverage-istanbul", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:1.4.0"],\ @@ -9249,6 +9262,7 @@ const RAW_RUNTIME_STATE = ["qs", "npm:6.11.2"],\ ["react", "npm:18.2.0"],\ ["react-dom", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:18.2.0"],\ + ["react-helmet", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:6.1.0"],\ ["react-rangeslider", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:2.2.0"],\ ["react-router", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:5.2.0"],\ ["react-router-dom", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:5.2.0"],\ @@ -15661,6 +15675,41 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-fast-compare", [\ + ["npm:3.2.2", {\ + "packageLocation": "../../../.yarn/berry/cache/react-fast-compare-npm-3.2.2-45b585a872-10c0.zip/node_modules/react-fast-compare/",\ + "packageDependencies": [\ + ["react-fast-compare", "npm:3.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["react-helmet", [\ + ["npm:6.1.0", {\ + "packageLocation": "../../../.yarn/berry/cache/react-helmet-npm-6.1.0-20fd5447ff-10c0.zip/node_modules/react-helmet/",\ + "packageDependencies": [\ + ["react-helmet", "npm:6.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:6.1.0", {\ + "packageLocation": "./.yarn/__virtual__/react-helmet-virtual-d04ad77d04/4/.yarn/berry/cache/react-helmet-npm-6.1.0-20fd5447ff-10c0.zip/node_modules/react-helmet/",\ + "packageDependencies": [\ + ["react-helmet", "virtual:74792effab46f58ba1849ed2d34bd613b5659d762979dea959819ade1937f6e3f71378429c07ee0ed12a2f529924b755998205bbacdbe5f616b371b307f52d67#npm:6.1.0"],\ + ["@types/react", "npm:18.2.73"],\ + ["object-assign", "npm:4.1.1"],\ + ["prop-types", "npm:15.8.1"],\ + ["react", "npm:18.2.0"],\ + ["react-fast-compare", "npm:3.2.2"],\ + ["react-side-effect", "virtual:d04ad77d04393e5d54850929fceba640eb62bdbe3770921516a63b8c80b14d46bdbfc35100c3fe4fbeaef8ad05baa053b003dd7409cf326935a1c7ea197c8946#npm:2.1.2"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-is", [\ ["npm:16.13.1", {\ "packageLocation": "../../../.yarn/berry/cache/react-is-npm-16.13.1-a9b9382b4f-10c0.zip/node_modules/react-is/",\ @@ -15820,6 +15869,28 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-side-effect", [\ + ["npm:2.1.2", {\ + "packageLocation": "../../../.yarn/berry/cache/react-side-effect-npm-2.1.2-c18e5fd8bd-10c0.zip/node_modules/react-side-effect/",\ + "packageDependencies": [\ + ["react-side-effect", "npm:2.1.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d04ad77d04393e5d54850929fceba640eb62bdbe3770921516a63b8c80b14d46bdbfc35100c3fe4fbeaef8ad05baa053b003dd7409cf326935a1c7ea197c8946#npm:2.1.2", {\ + "packageLocation": "./.yarn/__virtual__/react-side-effect-virtual-d0d6e060be/4/.yarn/berry/cache/react-side-effect-npm-2.1.2-c18e5fd8bd-10c0.zip/node_modules/react-side-effect/",\ + "packageDependencies": [\ + ["react-side-effect", "virtual:d04ad77d04393e5d54850929fceba640eb62bdbe3770921516a63b8c80b14d46bdbfc35100c3fe4fbeaef8ad05baa053b003dd7409cf326935a1c7ea197c8946#npm:2.1.2"],\ + ["@types/react", "npm:18.2.73"],\ + ["react", "npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-transition-group", [\ ["npm:4.4.5", {\ "packageLocation": "../../../.yarn/berry/cache/react-transition-group-npm-4.4.5-98ea4ef96e-10c0.zip/node_modules/react-transition-group/",\ diff --git a/frontend/index.html b/frontend/index.html index 9d64554de..a44afd29a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,22 +9,9 @@ /> - - - - - - - - - - { @@ -56,47 +56,48 @@ const App = () => { } return ( -This is our consent form!
', dashboard: [experimentWithAllProps], next_experiment: experiment1} ); + it('shows consent first if available', async () => { + mock.onGet().replyOnce(200, { consent: 'This is our consent form!
', dashboard: [experimentWithAllProps], nextExperiment: experiment1} ); render({exp.description}
No experiments found
}