diff --git a/.copier-answers.yml b/.copier-answers.yml index 46f2a88..4dc8f46 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -8,8 +8,7 @@ custom_install: true enforce_style: - ruff_lint - ruff_format -failure_notification: -- email +failure_notification: [] include_benchmarks: false include_docs: true include_notebooks: true @@ -19,7 +18,6 @@ project_license: MIT project_name: jump-starter project_organization: lincc-frameworks python_versions: -- '3.9' - '3.10' - '3.11' - '3.12' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 1b824f1..216c020 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -39,37 +39,4 @@ jobs: pip list - name: Run unit tests with pytest run: | - python -m pytest - - name: Send status to author's email - if: ${{ failure() }} && github.event_name != 'workflow_dispatch' }} # Only email if the workflow failed and was not manually started. Customize this as necessary. - uses: dawidd6/action-send-mail@v3 - with: - # Required mail server address if not connection_url: - server_address: smtp.gmail.com - # Server port: (uses TLS by default if server_port is 465) - server_port: 465 - - # Mail server username: - username: ${{secrets.MAIL_USERNAME}} - # Mail server password: - password: ${{secrets.MAIL_PASSWORD}} - # Required recipients' addresses: - to: seanmcgu@andrew.cmu.edu - - # Required mail subject: - subject: Smoke test ${{ job.status }} in ${{github.repository}} - # Required sender full name: - from: GitHub Actions Report - # Optional body: - html_body: | - - - -

Smoke test ${{ job.status }}

-

The smoke test in ${{ github.repository }} has completed with result: ${{ job.status }}

-

- ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} -

- - - \ No newline at end of file + python -m pytest \ No newline at end of file diff --git a/.github/workflows/testing-and-coverage.yml b/.github/workflows/testing-and-coverage.yml index 6f17b07..e7be6b3 100644 --- a/.github/workflows/testing-and-coverage.yml +++ b/.github/workflows/testing-and-coverage.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -41,10 +41,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | sudo apt-get update diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index a75d421..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jump-starter.iml b/.idea/jump-starter.iml deleted file mode 100644 index b3e7cd5..0000000 --- a/.idea/jump-starter.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index fcf10cd..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cd2783b..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/ruff.xml b/.idea/ruff.xml deleted file mode 100644 index d4ff29f..0000000 --- a/.idea/ruff.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 9f1d8b1..2a2ce24 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ -# jump-starter +# JuMP-Starter + +The Jupyter Managed Project Starter allows you to create an interactive jupyter questionnaire to help users +quickly get started with your package. [![Template](https://img.shields.io/badge/Template-LINCC%20Frameworks%20Python%20Project%20Template-brightgreen)](https://lincc-ppt.readthedocs.io/en/latest/) diff --git a/pyproject.toml b/pyproject.toml index 4dd3255..7faefa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,14 @@ classifiers = [ "Programming Language :: Python", ] dynamic = ["version"] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ + "ipywidgets>=8.0", + "pydantic>=2.0", + "pyyaml>=6.0", + "markdown>=3.4", + "pygments>=2.0", + "jinja2>=3.0" ] [project.urls] @@ -30,7 +36,10 @@ dev = [ "pre-commit", # Used to run checks before finalizing a git commit "pytest", "pytest-cov", # Used to report total code coverage + "pytest-mock", # Used to mock objects in tests "ruff", # Used for static linting of files + "types-Markdown", # Type information for markdown + "types-PyYAML", # Type information for pyyaml ] [build-system] @@ -53,7 +62,7 @@ addopts = "--doctest-modules --doctest-glob=*.rst" [tool.ruff] line-length = 110 -target-version = "py39" +target-version = "py310" [tool.ruff.lint] select = [ # pycodestyle diff --git a/src/jump_starter/__init__.py b/src/jump_starter/__init__.py index e69de29..657019d 100644 --- a/src/jump_starter/__init__.py +++ b/src/jump_starter/__init__.py @@ -0,0 +1,3 @@ +from .questionnaire import QuestionnaireWidget + +__all__ = ["QuestionnaireWidget"] diff --git a/src/jump_starter/models.py b/src/jump_starter/models.py new file mode 100644 index 0000000..9bfcd06 --- /dev/null +++ b/src/jump_starter/models.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Union + +from pydantic import BaseModel, Field + + +class Template(BaseModel): + """Represents a template for code replacement in the questionnaire.""" + + replacement: str + code: str + + +class Answer(BaseModel): + """Represents an answer to a question in the questionnaire.""" + + answer: str + tooltip: str = "" + templates: list[Template] = Field(default_factory=list) + followups: list[Union["Question", "Switch"]] = Field(default_factory=list) + commentary: str = "" + + +class Question(BaseModel): + """Represents a question in the questionnaire.""" + + question: str + variable: str | None = None + answers: list[Answer] + + +class Case(BaseModel): + """Represents a case in a switch statement within the questionnaire.""" + + value: int | None = None + questions: list[Union[Question, "Switch"]] + + +class Switch(BaseModel): + """Represents a switch statement in the questionnaire.""" + + switch: str + cases: list[Case] + + +# Rebuild models to support self-referencing types and forward references +Question.model_rebuild() +Answer.model_rebuild() +Case.model_rebuild() +Switch.model_rebuild() + + +class QuestionAnswer(BaseModel): + """Represents a user's answer to a question.""" + + question: str + answer: str + value: int + + +class QuestionAnswers(BaseModel): + """Represents a collection of user answers to questions.""" + + answers: list[QuestionAnswer] = Field(default_factory=list) + timestamp: datetime = Field(default_factory=datetime.now) + + +class Questionnaire(BaseModel): + """Represents a questionnaire with an initial template and a list of questions.""" + + initial_template: str + initial_commentary: str = "" + feedback_url: str | None = None + questions: list[Question | Switch] diff --git a/src/jump_starter/questionnaire.py b/src/jump_starter/questionnaire.py new file mode 100644 index 0000000..6589a59 --- /dev/null +++ b/src/jump_starter/questionnaire.py @@ -0,0 +1,367 @@ +import os +import re +from importlib.resources import files + +import jinja2 +import markdown +import yaml +from IPython.display import display +from ipywidgets import HTML, Button, HBox, Layout, VBox +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import PythonLexer + +from .models import ( + Question, + QuestionAnswer, + QuestionAnswers, + Questionnaire, + Switch, + Template, +) + +VIEWS_PACKAGE_PATH = "jump_starter.views" +OUTPUT_BOX_TEMPLATE_FILE = "output_box.html.jinja" +OUTPUT_BOX_STYLE_FILE = "output_box.css" +QUESTION_BOX_STYLE_FILE = "question_box.css" + + +QUESTION_BOX_LAYOUT = Layout( + padding="12px", + backgroundColor="#f9f9f9", + border="1px solid #ddd", + borderRadius="10px", + width="45%", +) + +OUTPUT_BOX_LAYOUT = Layout( + width="50%", + margin="0 0 0 20px", +) + + +class QuestionnaireWidget: + """A widget to run an interactive questionnaire in a Jupyter notebook.""" + + def __init__( + self, + questionnaire: Questionnaire, + save_directory: str | None = None, + initial_answers: QuestionAnswers | None = None, + ): + self.questions = questionnaire.questions + self.initial_template = questionnaire.initial_template + self.initial_commentary = questionnaire.initial_commentary + self.feedback_url = questionnaire.feedback_url + self.save_directory = save_directory + + self._load_resources() + + self._init_state() + self._init_ui() + + if initial_answers is not None: + self._start_with_answers(initial_answers) + else: + self._render_output_box() + self._render_next_question() + + def _load_resources(self): + question_box_css_file = files(VIEWS_PACKAGE_PATH).joinpath(QUESTION_BOX_STYLE_FILE) + with question_box_css_file.open("r") as f: + self.question_box_css = f.read() + + output_box_css_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_STYLE_FILE) + with output_box_css_file.open("r") as f: + self.output_box_css = f.read() + + template_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_TEMPLATE_FILE) + with template_file.open("r") as f: + template_source = f.read() + self.output_box_template = jinja2.Template(template_source) + + self.question_box_css_html = HTML(f"") + + def _init_state(self): + self.current_question = None + self.questions_stack = [] + self.question_answers = [] + self.variables = {} + self.code_output = self.initial_template + self.commentary = self.initial_commentary + self.save_message = None + self._add_questions_to_stack(self.questions) + + def _init_ui(self): + self.output_container = HTML() + self.question_box = VBox([], layout=QUESTION_BOX_LAYOUT) + # Add a class to the question box for CSS targeting + self.question_box.add_class("question-box-container") + self.output_box = VBox([self.output_container], layout=OUTPUT_BOX_LAYOUT) + + self.ui = HBox([self.question_box, self.output_box]) + + def _start_with_answers(self, question_answers: QuestionAnswers): + # First reset the state + self._init_state() + + for qa in question_answers.answers: + # Get the next question + self.current_question = self._get_next_question() + + # Verify the question matches + if self.current_question is None or self.current_question.question != qa.question: + raise ValueError("Provided answers do not match the question flow.") + + if self.current_question.answers[qa.value].answer != qa.answer: + raise ValueError("Provided answers do not match the question flow.") + + self._handle_answer(qa.value, render=False) + + self._render_output_box() + self._render_next_question() + + def _add_questions_to_stack(self, questions: list[Question | Switch]): + self.questions_stack = questions + self.questions_stack + + def _get_next_question(self) -> Question | None: + if len(self.questions_stack) > 0: + question = self.questions_stack.pop(0) + if isinstance(question, Question): + return question + if isinstance(question, Switch): + switch = question + matching_case = None + + # Find the default case (where value is None) + for case in switch.cases: + if case.value is None: + matching_case = case + break + + variable_value = self.variables.get(switch.switch, None) + if variable_value is not None: + for case in switch.cases: + if case.value == variable_value: + matching_case = case + break + if matching_case is not None: + self._add_questions_to_stack(matching_case.questions) + return self._get_next_question() + return None + + def _render_next_question(self): + self.current_question = self._get_next_question() + # Reset save message when rendering a new question + self.save_message = None + self._render_question_box() + + def _render_question_box(self): + previous_qs = self._generate_previous_questions() + + # Create save button + save_button = Button( + description="Save Answers", + tooltip="Save all answers to a YAML file", + ) + save_button.add_class("save-button") + save_button.on_click(self._save_answers) + + save_components = [save_button] + + if self.save_message: + message_type = self.save_message["type"] + message_text = self.save_message["text"] + + # Set color based on message type + color = "green" if message_type == "success" else "orange" + + # Create message HTML + message_html = HTML(f'
{message_text}
') + save_components = [message_html, save_button] + self.save_message = None + + # Create a container for the save button + save_button_container = VBox( + save_components, + layout=Layout( + width="100%", + padding="0", + ), + ) + save_button_container.add_class("save-button-container") + + if self.current_question is None: + final_message = "
🎉 You're done!
" + if self.feedback_url: + final_message = ( + final_message + + f""" +
+ If you encountered any difficulties or have any suggestions, + + please fill out our feedback form here. + +
+ """ + ) + # Wrap the final message in a container + final_message_container = VBox([HTML(final_message)], layout=Layout(margin="0 0 0 0")) + + self.question_box.children = ( + [self.question_box_css_html] + previous_qs + [final_message_container, save_button_container] + ) + return + + q_label = HTML(f"{self.current_question.question}") + + buttons = [] + for i, answer in enumerate(self.current_question.answers): + button = Button( + description=answer.answer, + tooltip=answer.tooltip, + layout=Layout(width="auto", margin="4px 0"), + ) + + def on_click_handler(btn, index=i): + self._handle_answer(index) + + button.on_click(on_click_handler) + + buttons.append(button) + + # Create a container for the buttons + buttons_container = VBox(buttons, layout=Layout(margin="0")) + + self.question_box.children = ( + [self.question_box_css_html] + previous_qs + [q_label, buttons_container, save_button_container] + ) + + def _generate_previous_questions(self): + items = [] + for i, (q, ans_ind) in enumerate(self.question_answers): + ans = q.answers[ans_ind] + + # The clickable button styled like text + btn = Button( + description=f"{q.question} — {ans.answer}", + tooltip="Click to go back", # native title tooltip as a fallback + layout=Layout(width="auto", margin="2px 0"), + style={"button_color": "transparent", "font_weight": "normal"}, + ) + btn.add_class("prev-btn") + + qas = self._get_question_answers(up_to_index=i) + + def on_click_handler(btn, qas=qas): + self._start_with_answers(qas) + + btn.on_click(on_click_handler) + + # The custom tooltip node (shown on hover via CSS) + tip_html = HTML("
Click to go back
") + + # Wrap button + tooltip in a positioned container + container = HBox([btn, tip_html], layout=Layout(position="relative")) + container.add_class("prev-item") + + items.append(container) + + return items + + def _handle_answer(self, answer_index: int, render: bool = True): + answer = self.current_question.answers[answer_index] + + self._update_template(answer.templates) + self.commentary = answer.commentary + + self._add_questions_to_stack(answer.followups) + self.question_answers.append((self.current_question, answer_index)) + if self.current_question.variable is not None: + self.variables[self.current_question.variable] = answer_index + + if render: + self._render_output_box() + self._render_next_question() + + def _update_template(self, templates: list[Template]): + for t in templates: + pattern = r"\{\{\s*" + re.escape(t.replacement) + r"\s*\}\}" + self.code_output = re.sub(pattern, t.code, self.code_output) + + def _render_output_box(self): + output_code = re.sub(r"\{\{\s*\w+\s*\}\}", "", self.code_output) + commentary_html = markdown.markdown(self.commentary, extensions=["extra"]) + + formatter = HtmlFormatter(style="monokai", noclasses=True) + highlighted_code = highlight(output_code, PythonLexer(), formatter) + + # Use pre-loaded template and CSS + html_content = f"\n" + self.output_box_template.render( + highlighted_code=highlighted_code, + raw_code=output_code, + commentary_html=commentary_html, + ) + + self.output_container.value = html_content + + def _get_question_answers(self, up_to_index=None) -> QuestionAnswers: + """Get a QuestionAnswers model containing all answers, optionally up to the specified index. + + Args: + up_to_index (int, optional): The index up to which to include answers. + If None, includes all answers. Defaults to None. + + Returns: + QuestionAnswers: The collected question answers. + """ + question_answers = QuestionAnswers() + answers_to_process = ( + self.question_answers if up_to_index is None else self.question_answers[:up_to_index] + ) + + for question, answer_index in answers_to_process: + answer = question.answers[answer_index] + question_answer = QuestionAnswer( + question=question.question, answer=answer.answer, value=answer_index + ) + question_answers.answers.append(question_answer) + + return question_answers + + def _save_answers(self, _): + """Save all user answers to a YAML file.""" + # Check if there are any answers to save + if not self.question_answers: + # Set warning message + self.save_message = {"type": "warning", "text": "⚠️ No answers to save yet"} + # Re-render the question box to show the message + self._render_question_box() + return None + + # Create a QuestionAnswers model + question_answers = self._get_question_answers() + + # Create a filename with timestamp to avoid overwriting + timestamp = question_answers.timestamp.strftime("%Y%m%d_%H%M%S") + filename = f"questionnaire_answers_{timestamp}.yaml" + + if self.save_directory is not None: + filename = os.path.join(self.save_directory, filename) + + # Save to file + with open(filename, "w") as f: + yaml.dump(question_answers.model_dump(), f, default_flow_style=False) + + # Set success message + self.save_message = {"type": "success", "text": f"✅ Answers saved to {filename}"} + + # Re-render the question box to show the message + self._render_question_box() + + return filename + + def show(self): + """Display the widget in a Jupyter notebook.""" + display(self.ui) diff --git a/tests/jump_starter/conftest.py b/src/jump_starter/views/__init__.py similarity index 100% rename from tests/jump_starter/conftest.py rename to src/jump_starter/views/__init__.py diff --git a/src/jump_starter/views/output_box.css b/src/jump_starter/views/output_box.css new file mode 100644 index 0000000..59e56dd --- /dev/null +++ b/src/jump_starter/views/output_box.css @@ -0,0 +1,29 @@ +.code-output { + background-color: #272822; + color: #f1f1f1; + padding: 10px; + border-radius: 10px; + border: 1px solid #444; + font-family: monospace; + overflow-x: auto; +} + +.copy_button { + font-size: 12px; + padding: 4px 8px; + background-color: #272822; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.commentary-box { + background-color: #f6f8fa; + color: #333; + padding: 8px 12px; + border-left: 4px solid #0366d6; + border-radius: 6px; + font-size: 14px; + font-family: sans-serif; +} diff --git a/src/jump_starter/views/output_box.html.jinja b/src/jump_starter/views/output_box.html.jinja new file mode 100644 index 0000000..651864a --- /dev/null +++ b/src/jump_starter/views/output_box.html.jinja @@ -0,0 +1,12 @@ +
+ {{highlighted_code | safe}} +
+ +
+
+
+ {{commentary_html | safe}} +
\ No newline at end of file diff --git a/src/jump_starter/views/question_box.css b/src/jump_starter/views/question_box.css new file mode 100644 index 0000000..fa714f7 --- /dev/null +++ b/src/jump_starter/views/question_box.css @@ -0,0 +1,90 @@ +/* Add overflow-x: hidden to the question box to prevent horizontal scrolling */ +.question-box-container { + overflow-x: hidden; + display: flex; + flex-direction: column; + min-height: 300px; /* Ensure there's enough height for content */ +} + +/* Style for the save button container */ +.save-button-container { + margin-top: auto; + text-align: center; + padding: 5px; +} + +/* Style for the save button */ +.save-button { + font-size: 0.9em; + background-color: #444444; + color: white; +} + +.save-message { + margin-top: 10px; + text-align: left; +} + +.prev-item { + position: relative; + overflow: visible; +} +.prev-btn { + width: auto; + text-align: left; + border: none; + background: none; + box-shadow: none; + color: #555; + text-decoration: none; + cursor: pointer; + font-size: 0.9em; + margin: 2px; + padding: 2px 4px; + overflow: hidden; + max-width: 100%; /* Ensure button doesn't exceed container width */ + text-overflow: ellipsis; /* Add ellipsis for text that overflows */ +} + +/* Container for the tooltip */ +.prev-item .tooltip { + position: absolute; + background: #333; + color: #fff; + padding: 6px 8px; + border-radius: 8px; + font-size: 12px; + line-height: 1.3; + box-shadow: 0 4px 12px rgba(0,0,0,.2); + white-space: normal; + min-width: 160px; + max-width: 320px; + opacity: 0; + visibility: hidden; + transition: opacity .12s ease; + pointer-events: none; + z-index: 9999; + + /* Default position to the right of the button */ + left: 100%; + top: 50%; + transform: translateY(-50%) translateX(8px); +} + +/* When hovering over the container, show the tooltip */ +.prev-item:hover .tooltip { + opacity: 1; + visibility: visible; +} + +/* Arrow for the tooltip */ +.prev-item .tooltip::before { + content: ""; + position: absolute; + border-width: 6px; + border-style: solid; + left: -6px; + top: 50%; + transform: translateY(-50%); + border-color: transparent #333 transparent transparent; +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f530507 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,267 @@ +import json +import re +from importlib.resources import files +from pathlib import Path + +import yaml +from ipywidgets import HTML, Button, HBox, VBox +from jump_starter.models import QuestionAnswer, QuestionAnswers, Questionnaire +from jump_starter.questionnaire import ( + OUTPUT_BOX_LAYOUT, + OUTPUT_BOX_STYLE_FILE, + QUESTION_BOX_LAYOUT, + VIEWS_PACKAGE_PATH, +) +from pygments import highlight +from pygments.formatters.html import HtmlFormatter +from pygments.lexers.python import PythonLexer +from pytest import fixture + + +@fixture +def data_dir(): + """Path to the data directory containing the example questionnaire YAML file.""" + return Path(__file__).parent / "data" + + +@fixture +def example_questionnaire_dict(data_dir): + """An example questionnaire dictionary""" + yaml_path = data_dir / "example_questionnaire.yaml" + with yaml_path.open("r") as f: + return yaml.safe_load(f) + + +@fixture +def example_questionnaire(example_questionnaire_dict): + """An example Questionnaire model instance""" + return Questionnaire.model_validate(example_questionnaire_dict) + + +@fixture +def example_questionnaire_with_switch_dict(data_dir): + """An example questionnaire dictionary with a switch question""" + yaml_path = data_dir / "example_questionnaire_switch.yaml" + with yaml_path.open("r") as f: + return yaml.safe_load(f) + + +@fixture +def example_questionnaire_with_switch(example_questionnaire_with_switch_dict): + """An example Questionnaire model instance with a switch question""" + return Questionnaire.model_validate(example_questionnaire_with_switch_dict) + + +@fixture +def questionnaire_with_followup_switch_example_dict(data_dir): + """An example questionnaire dictionary with a switch question""" + yaml_path = data_dir / "example_questionnaire_followup_switch.yaml" + with yaml_path.open("r") as f: + return yaml.safe_load(f) + + +@fixture +def example_questionnaire_with_followup_switch(questionnaire_with_followup_switch_example_dict): + """An example Questionnaire model instance with a switch question""" + return Questionnaire.model_validate(questionnaire_with_followup_switch_example_dict) + + +@fixture +def example_questionnaire_with_feedback(example_questionnaire): + """An example Questionnaire model instance with a feedback URL""" + questionnaire = example_questionnaire.model_copy(deep=True) + questionnaire.feedback_url = "https://example.com/feedback" + return questionnaire + + +@fixture +def example_question_answers(example_questionnaire): + """An example list of question answers for the exmaple questionnaire""" + answer_inds = [0, 1, 0] # indices of answers to select for each question + questions = [ + example_questionnaire.questions[0], + example_questionnaire.questions[0].answers[0].followups[0], + example_questionnaire.questions[0].answers[0].followups[1], + ] + qas = [ + QuestionAnswer(question=q.question, answer=q.answers[i].answer, value=i) + for q, i in zip(questions, answer_inds, strict=False) + ] + return QuestionAnswers(answers=qas) + + +class Helpers: + """Helper functions for testing the QuestionnaireWidget""" + + @staticmethod + def get_answer_button(widget, answer_index): + """Get an answer button from the question box children. + + Args: + widget: The QuestionnaireWidget instance + answer_index: The index of the answer button to get + + Returns: + The answer button widget + """ + css_offset = 1 + prev_questions_offset = len(widget.question_answers) + question_label_offset = 1 + + # Get the buttons container which is at index after the question label + buttons_container_index = css_offset + prev_questions_offset + question_label_offset + buttons_container = widget.question_box.children[buttons_container_index] + + # Return the specific button from the buttons container + return buttons_container.children[answer_index] + + @staticmethod + def get_prev_question_button(widget, question_index): + """Get a previous question button from the question box children. + + Args: + widget: The QuestionnaireWidget instance + question_index: The index of the previous question to get + + Returns: + The previous question button widget + """ + css_offset = 1 + + container = widget.question_box.children[css_offset + question_index] + # The button is the first child of the container + return container.children[0] + + @staticmethod + def assert_widget_ui_matches_state(widget): + """Assert that the widget's UI matches its internal state.""" + assert isinstance(widget.ui, HBox) + assert widget.ui.children == (widget.question_box, widget.output_box) + + assert isinstance(widget.output_box, VBox) + assert widget.output_box.children == (widget.output_container,) + assert widget.output_box.layout == OUTPUT_BOX_LAYOUT + + assert isinstance(widget.output_container, HTML) + + # check output container contains css from output_box css file + css_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_STYLE_FILE) + with css_file.open("r") as f: + css_content = f.read() + + assert css_content in widget.output_container.value + + output_code = re.sub(r"\{\{\s*\w+\s*\}\}", "", widget.code_output) + + html = widget.output_container.value + + # regex to capture the JS argument + match = re.search(r"navigator\.clipboard\.writeText\((.*?)\)", html) + assert match, "No copy button found" + + actual_arg = match.group(1) + expected_arg = json.dumps(output_code) # exactly how Jinja|tojson would encode it + + assert actual_arg.strip() == expected_arg + + formatter = HtmlFormatter(style="monokai", noclasses=True) + highlighted_code = highlight(output_code, PythonLexer(), formatter) + + assert highlighted_code in widget.output_container.value + + assert isinstance(widget.question_box, VBox) + assert widget.question_box.layout == QUESTION_BOX_LAYOUT + + css_snippet_count = 1 + save_button_container_count = 1 + + # If there's a current question, we have: + # - CSS snippet + # - Previous question containers + # - Current question label + # - Buttons container + # - Save button container + if widget.current_question: + expected_children_count = ( + css_snippet_count + len(widget.question_answers) + 1 + 1 + save_button_container_count + ) + # If there's no current question, we have: + # - CSS snippet + # - Previous question containers + # - Final message container + # - Save button container + else: + expected_children_count = ( + css_snippet_count + len(widget.question_answers) + 1 + save_button_container_count + ) + + assert len(widget.question_box.children) == expected_children_count + + # Skip the CSS snippet + css_offset = 1 + + for i in range(len(widget.question_answers)): + # Add the CSS offset to the index + child_index = i + css_offset + assert isinstance(widget.question_box.children[child_index], HBox) + # The button is the first child of the container + btn = widget.question_box.children[child_index].children[0] + question = widget.question_answers[i][0] + assert question.question in btn.description + ans_index = widget.question_answers[i][1] + assert question.answers[ans_index].answer in btn.description + + if widget.current_question is not None: + # Add the CSS offset to the index + qs_ind = len(widget.question_answers) + css_offset + + assert isinstance(widget.question_box.children[qs_ind], HTML) + assert widget.current_question.question in widget.question_box.children[qs_ind].value + + # Get the buttons container which is at index after the question label + buttons_container_index = qs_ind + 1 + buttons_container = widget.question_box.children[buttons_container_index] + assert isinstance(buttons_container, VBox) + + # Check each button in the buttons container + for btn, ans in zip(buttons_container.children, widget.current_question.answers, strict=False): + assert isinstance(btn, Button) + assert btn.description == ans.answer + assert btn.tooltip == ans.tooltip + + else: + # When there's no current question, we have: + # - CSS snippet + # - Previous question containers + # - Final message container + # - Save button container + + # Get the final message container which is at index before the save button container + final_message_index = len(widget.question_box.children) - 2 + final_message_container = widget.question_box.children[final_message_index] + assert isinstance(final_message_container, VBox) + + # The final message is in the HTML child of the container + final_message_html = final_message_container.children[0] + assert isinstance(final_message_html, HTML) + final_message = final_message_html.value + assert "You're done" in final_message + + # Check for feedback URL if present in the questionnaire + if widget.feedback_url: + assert widget.feedback_url in final_message + assert "feedback form" in final_message + + # Check that the last child is the save button container + save_button_container = widget.question_box.children[-1] + assert isinstance(save_button_container, VBox) + assert len(save_button_container.children) > 0 + save_button = save_button_container.children[-1] + assert isinstance(save_button, Button) + assert save_button.description == "Save Answers" + + +@fixture +def helpers(): + """Provide helper functions for testing.""" + return Helpers() diff --git a/tests/data/example_questionnaire.yaml b/tests/data/example_questionnaire.yaml new file mode 100644 index 0000000..723be88 --- /dev/null +++ b/tests/data/example_questionnaire.yaml @@ -0,0 +1,46 @@ +initial_commentary: This is an example commentary. +initial_template: '{{code}}' +questions: +- question: Example question? + answers: + - answer: Example answer + commentary: This is some commentary. + templates: + - code: example_code {{follow}} + replacement: code + tooltip: This is an example tooltip. + followups: + - answers: + - answer: Follow-up answer + commentary: '' + followups: [] + templates: + - code: 'followup_code {{code}}' + replacement: follow + tooltip: This is a follow-up tooltip. + - answer: Second follow-up answer + templates: + - code: 'second_followup_code {{code}}' + replacement: follow + tooltip: This is a second follow-up tooltip. + question: Follow-up question? + - answers: + - answer: Another follow-up answer + templates: [] + question: Another follow-up question? + - answer: Another answer + commentary: Some other commentary. + templates: + - code: 'another_code {{code}}' + replacement: code + tooltip: This is another tooltip. + followups: [] +- question: Second question? + answers: + - answer: Second answer + commentary: Next commentary. + templates: + - code: second_code + replacement: code + followups: [] + tooltip: This is a second tooltip. diff --git a/tests/data/example_questionnaire_followup_switch.yaml b/tests/data/example_questionnaire_followup_switch.yaml new file mode 100644 index 0000000..bd1915d --- /dev/null +++ b/tests/data/example_questionnaire_followup_switch.yaml @@ -0,0 +1,60 @@ +initial_commentary: This is an example commentary. +initial_template: '{{code}}' +questions: +- question: Example question? + answers: + - answer: First answer + commentary: This is some commentary. + templates: + - code: 'example_code {{follow}}' + replacement: code + tooltip: This is an example tooltip. + followups: + - question: Follow-up question? + variable: example_var + answers: + - answer: Follow-up answer + commentary: '' + followups: [] + templates: + - code: 'followup_code {{code}}' + replacement: follow + tooltip: This is a follow-up tooltip. + - answer: Second follow-up answer + templates: + - code: 'second_followup_code {{code}}' + replacement: follow + tooltip: This is a second follow-up tooltip. + - answer: Second answer + commentary: Some other commentary. + followups: [] + templates: + - code: 'another_code {{code}}' + replacement: code + tooltip: This is another tooltip. +- switch: example_var + cases: + - value: 0 + questions: + - question: Second Question 1? + answers: + - answer: Second answer 1 + templates: + - code: 'followup_code_1 {{code}}' + replacement: code + - value: 1 + questions: + - answers: + - answer: Second answer 2 + templates: + - code: 'followup_code_2 {{code}}' + replacement: code + question: Second Question 2? + - value: null + questions: + - question: Default Question? + answers: + - answer: Default answer + templates: + - code: 'default_code {{code}}' + replacement: code diff --git a/tests/data/example_questionnaire_switch.yaml b/tests/data/example_questionnaire_switch.yaml new file mode 100644 index 0000000..16ba00f --- /dev/null +++ b/tests/data/example_questionnaire_switch.yaml @@ -0,0 +1,54 @@ +initial_commentary: This is an example commentary. +initial_template: '{{code}}' +questions: +- question: Example question? + variable: example_var + answers: + - answer: First answer + commentary: This is some commentary. + templates: + - code: 'example_code {{code}}' + replacement: code + tooltip: This is an example tooltip. + - answer: Second answer + commentary: Some other commentary. + followups: [] + templates: + - code: 'another_code {{code}}' + replacement: code + tooltip: This is another tooltip. + - answer: Third answer + commentary: Some other commentary. + followups: [] + templates: + - code: 'third_code {{code}}' + replacement: code + tooltip: This is another tooltip. +- switch: example_var + cases: + - value: 0 + questions: + - question: Second Question 1? + answers: + - answer: Second answer 1 + templates: + - code: 'followup_code_1 {{code}}' + replacement: code + - value: 1 + questions: + - question: Second Question 2? + answers: + - answer: Second answer 2 + templates: + - code: 'followup_code_2 {{code}}' + replacement: code + - value: null + questions: + - question: Default Question? + answers: + - answer: Default answer + templates: + - code: 'default_code {{code}}' + replacement: code + + diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..25d77fd --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,35 @@ +import pytest +from jump_starter.models import Questionnaire + + +def test_validate_model(example_questionnaire_dict): + """Test that the Questionnaire model validates correctly.""" + questionnaire = Questionnaire.model_validate(example_questionnaire_dict) + assert questionnaire.initial_template == "{{code}}" + assert questionnaire.initial_commentary == "This is an example commentary." + assert len(questionnaire.questions) == 2 + question = questionnaire.questions[0] + assert question.question == "Example question?" + assert len(question.answers) == 2 + answer = question.answers[0] + assert answer.answer == "Example answer" + assert len(answer.templates) == 1 + assert answer.templates[0].replacement == "code" + assert len(answer.followups) == 2 + followup = answer.followups[0] + assert followup.question == "Follow-up question?" + + +def test_model_fails(example_questionnaire_dict): + """Test that the Questionnaire model raises errors for invalid data.""" + invalid_dict = example_questionnaire_dict.copy() + invalid_dict["initial_template"] = 123 + + with pytest.raises(ValueError): + Questionnaire.model_validate(invalid_dict) + + invalid_dict = example_questionnaire_dict.copy() + invalid_dict["questions"][0]["question"] = 123 + + with pytest.raises(ValueError): + Questionnaire.model_validate(invalid_dict) diff --git a/tests/test_questionnaire.py b/tests/test_questionnaire.py new file mode 100644 index 0000000..bae97ac --- /dev/null +++ b/tests/test_questionnaire.py @@ -0,0 +1,459 @@ +import os +from pathlib import Path + +import yaml +from ipywidgets import HTML, Button +from jump_starter import QuestionnaireWidget + + +def test_questionnaire_widget_init(example_questionnaire, helpers): + """Test that the widget initializes correctly with the example questionnaire.""" + widget = QuestionnaireWidget(example_questionnaire) + assert widget.questions == example_questionnaire.questions + assert widget.code_output == example_questionnaire.initial_template + assert widget.commentary == example_questionnaire.initial_commentary + + assert widget.current_question == example_questionnaire.questions[0] + assert widget.questions_stack == example_questionnaire.questions[1:] + assert widget.question_answers == [] + + helpers.assert_widget_ui_matches_state(widget) + + +def test_questionnaire_show(example_questionnaire, mocker): + """Mock the display function to test that show() calls it correctly.""" + mocker.patch("jump_starter.questionnaire.display") + + widget = QuestionnaireWidget(example_questionnaire) + widget.show() + + from jump_starter.questionnaire import display + + display.assert_called_once_with(widget.ui) + + +def test_questionnaire_handle_answer_selection(example_questionnaire, helpers): + """Test that selecting an answer updates the widget correctly.""" + widget = QuestionnaireWidget(example_questionnaire) + + first_question = example_questionnaire.questions[0] + first_answer = first_question.answers[0] + first_button = helpers.get_answer_button(widget, 0) # First answer button + + first_button.click() + + assert widget.code_output == "example_code {{follow}}" + assert widget.commentary == "This is some commentary." + + assert widget.question_answers == [(first_question, 0)] + + assert widget.current_question == first_answer.followups[0] + assert widget.questions_stack == first_answer.followups[1:] + example_questionnaire.questions[1:] + + helpers.assert_widget_ui_matches_state(widget) + + +def test_questionnaire_complete_all_questions(example_questionnaire, helpers): + """Test completing the entire questionnaire.""" + widget = QuestionnaireWidget(example_questionnaire) + + answer_inds = [0, 1, 0, 0] + + expected_questions = [ + example_questionnaire.questions[0], + example_questionnaire.questions[0].answers[0].followups[0], + example_questionnaire.questions[0].answers[0].followups[1], + example_questionnaire.questions[1], + ] + + for i, ans_ind in enumerate(answer_inds): + current_question = widget.current_question + assert current_question == expected_questions[i] + assert widget.question_answers == list(zip(expected_questions[:i], answer_inds[:i], strict=False)) + helpers.assert_widget_ui_matches_state(widget) + + button = helpers.get_answer_button(widget, ans_ind) + button.click() + + assert widget.current_question is None + assert widget.question_answers == list(zip(expected_questions, answer_inds, strict=False)) + helpers.assert_widget_ui_matches_state(widget) + + +def test_questionnaire_switch_variable(example_questionnaire_with_switch, helpers): + """Test that switch-case logic in the questionnaire works correctly.""" + + # Test that the switch-case logic works correctly for the first case. + widget = QuestionnaireWidget(example_questionnaire_with_switch) + + first_question = example_questionnaire_with_switch.questions[0] + first_answer = first_question.answers[0] + first_button = helpers.get_answer_button(widget, 0) # First answer button + + first_button.click() + + assert widget.code_output == first_answer.templates[0].code + assert widget.commentary == first_answer.commentary + + assert widget.question_answers == [(first_question, 0)] + assert widget.variables == {first_question.variable: 0} + + switch = example_questionnaire_with_switch.questions[1] + case = switch.cases[0] + + assert widget.current_question == case.questions[0] + assert widget.questions_stack == case.questions[1:] + example_questionnaire_with_switch.questions[2:] + + helpers.assert_widget_ui_matches_state(widget) + + # Now test that the switch-case logic works correctly for the second case. + widget = QuestionnaireWidget(example_questionnaire_with_switch) + first_question = example_questionnaire_with_switch.questions[0] + second_answer = first_question.answers[1] + second_button = helpers.get_answer_button(widget, 1) # Second answer button + + second_button.click() + + assert widget.code_output == second_answer.templates[0].code + assert widget.commentary == second_answer.commentary + assert widget.question_answers == [(first_question, 1)] + assert widget.variables == {first_question.variable: 1} + + switch = example_questionnaire_with_switch.questions[1] + case = switch.cases[1] + + assert widget.current_question == case.questions[0] + assert widget.questions_stack == case.questions[1:] + example_questionnaire_with_switch.questions[2:] + + helpers.assert_widget_ui_matches_state(widget) + + # Test that the default case is used if no case matches. + widget = QuestionnaireWidget(example_questionnaire_with_switch) + + first_question = example_questionnaire_with_switch.questions[0] + third_answer = first_question.answers[2] + third_button = helpers.get_answer_button(widget, 2) # Third answer button + + third_button.click() + + assert widget.code_output == third_answer.templates[0].code + assert widget.commentary == third_answer.commentary + assert widget.question_answers == [(first_question, 2)] + assert widget.variables == {first_question.variable: 2} + + switch = example_questionnaire_with_switch.questions[1] + case = switch.cases[2] # Default case where value is None + + assert widget.current_question == case.questions[0] + assert widget.questions_stack == case.questions[1:] + example_questionnaire_with_switch.questions[2:] + + helpers.assert_widget_ui_matches_state(widget) + + +def test_questionnaire_followup_switch(example_questionnaire_with_followup_switch, helpers): + """Test that a switch based on a follow-up question works correctly.""" + + widget = QuestionnaireWidget(example_questionnaire_with_followup_switch) + + # Select the first answer to the first question (which has a follow-up question) + first_question = example_questionnaire_with_followup_switch.questions[0] + first_answer = first_question.answers[0] + first_button = helpers.get_answer_button(widget, 0) # First answer button + + first_button.click() + + assert widget.code_output == first_answer.templates[0].code + assert widget.commentary == first_answer.commentary + + assert widget.question_answers == [(first_question, 0)] + + followup_question = first_answer.followups[0] + assert widget.current_question == followup_question + assert widget.questions_stack == example_questionnaire_with_followup_switch.questions[1:] + + helpers.assert_widget_ui_matches_state(widget) + + # Select the first answer to the follow-up question (which sets the switch variable) + + followup_answer = followup_question.answers[0] + followup_button = helpers.get_answer_button(widget, 0) # First answer button for the followup question + + followup_button.click() + + assert widget.code_output == "example_code followup_code {{code}}" + assert widget.commentary == followup_answer.commentary + + assert widget.question_answers == [(first_question, 0), (followup_question, 0)] + assert widget.variables == {followup_question.variable: 0} + + # Check that the switch question is handled correctly + + switch = example_questionnaire_with_followup_switch.questions[1] + case = switch.cases[0] + + assert widget.current_question == case.questions[0] + assert ( + widget.questions_stack + == case.questions[1:] + example_questionnaire_with_followup_switch.questions[2:] + ) + + helpers.assert_widget_ui_matches_state(widget) + + # Test that the second case of the switch works correctly + + widget = QuestionnaireWidget(example_questionnaire_with_followup_switch) + + # Select the first answer to the first question (which has a follow-up question) + first_question = example_questionnaire_with_followup_switch.questions[0] + first_answer = first_question.answers[0] + first_button = helpers.get_answer_button(widget, 0) # First answer button + + first_button.click() + + assert widget.code_output == first_answer.templates[0].code + assert widget.commentary == first_answer.commentary + + assert widget.question_answers == [(first_question, 0)] + + followup_question = first_answer.followups[0] + assert widget.current_question == followup_question + assert widget.questions_stack == example_questionnaire_with_followup_switch.questions[1:] + + helpers.assert_widget_ui_matches_state(widget) + + # Select the Second answer to the follow-up question (which sets the switch variable) + + followup_answer = followup_question.answers[1] + followup_button = helpers.get_answer_button(widget, 1) # Second answer button for the followup question + + followup_button.click() + + assert widget.code_output == "example_code second_followup_code {{code}}" + assert widget.commentary == followup_answer.commentary + + assert widget.question_answers == [(first_question, 0), (followup_question, 1)] + assert widget.variables == {followup_question.variable: 1} + + # Check that the switch question is handled correctly + + switch = example_questionnaire_with_followup_switch.questions[1] + case = switch.cases[1] + + assert widget.current_question == case.questions[0] + assert ( + widget.questions_stack + == case.questions[1:] + example_questionnaire_with_followup_switch.questions[2:] + ) + + helpers.assert_widget_ui_matches_state(widget) + + # Test that the default case of the switch works correctly if the follow-up question is skipped + + widget = QuestionnaireWidget(example_questionnaire_with_followup_switch) + + # Select the second answer to the first question (which does not have a follow-up question) + first_question = example_questionnaire_with_followup_switch.questions[0] + second_answer = first_question.answers[1] + second_button = helpers.get_answer_button(widget, 1) # Second answer button + + second_button.click() + + assert widget.code_output == second_answer.templates[0].code + assert widget.commentary == second_answer.commentary + assert widget.question_answers == [(first_question, 1)] + assert widget.variables == {} + + switch = example_questionnaire_with_followup_switch.questions[1] + case = switch.cases[2] # Default case where value is None + + assert widget.current_question == case.questions[0] + assert ( + widget.questions_stack + == case.questions[1:] + example_questionnaire_with_followup_switch.questions[2:] + ) + + helpers.assert_widget_ui_matches_state(widget) + + +def test_questionnaire_previous_question_navigation(example_questionnaire, helpers): + """Test that clicking on a previous question button navigates back to that point in the questionnaire.""" + widget = QuestionnaireWidget(example_questionnaire) + + # Complete the first two questions + answer_inds = [0, 1] + expected_questions = [ + example_questionnaire.questions[0], + example_questionnaire.questions[0].answers[0].followups[0], + ] + + # Answer the first question + first_button = helpers.get_answer_button(widget, answer_inds[0]) + first_button.click() + + # Answer the second question + second_button = helpers.get_answer_button(widget, answer_inds[1]) + second_button.click() + + # Verify we're at the expected state after answering two questions + assert widget.question_answers == list(zip(expected_questions, answer_inds, strict=False)) + assert widget.current_question == example_questionnaire.questions[0].answers[0].followups[1] + + # Now click on the second previous question button to go back to that point + prev_button = helpers.get_prev_question_button(widget, 1) + prev_button.click() + + # Verify we're back at the state after answering only the first question + assert widget.question_answers == [(expected_questions[0], answer_inds[0])] + assert widget.current_question == expected_questions[1] + + # Answer the second question differently this time + different_answer_ind = 0 # Different from the original answer_inds[1] + different_button = helpers.get_answer_button(widget, different_answer_ind) + different_button.click() + + # Verify the new answer was recorded + assert widget.question_answers == [ + (expected_questions[0], answer_inds[0]), + (expected_questions[1], different_answer_ind), + ] + + +def test_questionnaire_feedback_url(example_questionnaire_with_feedback, helpers): + """Test that the feedback URL is included when present in the questionnaire.""" + widget = QuestionnaireWidget(example_questionnaire_with_feedback) + + # Complete all questions + _inds = [0, 1, 0, 0] + for ans_ind in _inds: + button = helpers.get_answer_button(widget, ans_ind) + button.click() + + # Check that the questionnaire is completed + assert widget.current_question is None + assert widget.feedback_url == example_questionnaire_with_feedback.feedback_url + + # Verify the widget UI matches its state (including feedback URL check) + helpers.assert_widget_ui_matches_state(widget) + + +def test_save_button_functionality(example_questionnaire, helpers, tmp_path): + """Test that the save button functionality works correctly.""" + # Change to the temporary directory for file operations + original_dir = Path.cwd() + os.chdir(tmp_path) + + try: + # Create the widget + widget = QuestionnaireWidget(example_questionnaire) + + # Test saving when there are no answers yet + # Get the save button + save_button_container = widget.question_box.children[-1] + save_button = save_button_container.children[-1] + assert isinstance(save_button, Button) + assert save_button.description == "Save Answers" + + # Click the save button + save_button.click() + + # Check that the warning message was set + assert widget.save_message is None # Message is reset after rendering + assert isinstance(widget.question_box.children[-1].children[0], HTML) + assert "No answers to save" in widget.question_box.children[-1].children[0].value + + answer_inds = [0, 1, 0, 0] + + for answer_ind in answer_inds: + button = helpers.get_answer_button(widget, answer_ind) + button.click() + + # Click the save button again + save_button_container = widget.question_box.children[-1] + save_button = save_button_container.children[-1] + save_button.click() + + helpers.assert_widget_ui_matches_state(widget) + + # Check that the file was created in the temporary directory + yaml_files = list(tmp_path.glob("questionnaire_answers_*.yaml")) + assert len(yaml_files) == 1 + + # Check that the success message was set + assert widget.save_message is None # Message is reset after rendering + assert isinstance(widget.question_box.children[-1].children[0], HTML) + assert "Answers saved to" in widget.question_box.children[-1].children[0].value + + # Read the file and parse the YAML + with open(yaml_files[0], "r") as f: + data = yaml.safe_load(f) + + # Verify the parsed YAML data structure + assert "answers" in data + assert isinstance(data["answers"], list) + assert len(data["answers"]) == 4 + + expected_questions = [ + example_questionnaire.questions[0], + example_questionnaire.questions[0].answers[0].followups[0], + example_questionnaire.questions[0].answers[0].followups[1], + example_questionnaire.questions[1], + ] + + for eq, ans_ind, ans in zip(expected_questions, answer_inds, data["answers"], strict=False): + assert ans["question"] == eq.question + assert ans["answer"] == eq.answers[ans_ind].answer + assert ans["value"] == ans_ind + + finally: + # Change back to the original directory + os.chdir(original_dir) + + +def test_save_with_path(example_questionnaire, helpers, tmp_path): + """Test that the save button functionality works correctly with a specified path.""" + + # Create the widget + widget = QuestionnaireWidget(example_questionnaire, save_directory=str(tmp_path)) + + answer_inds = [0, 1] + + for answer_ind in answer_inds: + button = helpers.get_answer_button(widget, answer_ind) + button.click() + + # Click the save button with the specific path + save_button_container = widget.question_box.children[-1] + save_button = save_button_container.children[-1] + save_button.click() + + helpers.assert_widget_ui_matches_state(widget) + + # Check that the file was created in the temporary directory + yaml_files = list(tmp_path.glob("questionnaire_answers_*.yaml")) + assert len(yaml_files) == 1 + + # Read the file and parse the YAML + with open(yaml_files[0], "r") as f: + data = yaml.safe_load(f) + + # Verify the parsed YAML data structure + assert "answers" in data + assert isinstance(data["answers"], list) + assert len(data["answers"]) == len(answer_inds) + + +def test_init_with_answers(example_questionnaire, example_question_answers, helpers): + """Test that the widget initializes correctly with pre-existing answers.""" + widget = QuestionnaireWidget(example_questionnaire, initial_answers=example_question_answers) + + expected_questions = [ + example_questionnaire.questions[0], + example_questionnaire.questions[0].answers[0].followups[0], + example_questionnaire.questions[0].answers[0].followups[1], + ] + expected_answer_inds = [0, 1, 0] + + assert widget.question_answers == list(zip(expected_questions, expected_answer_inds, strict=False)) + assert widget.current_question is example_questionnaire.questions[1] + + helpers.assert_widget_ui_matches_state(widget)