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.
[](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"""
+
+ """
+ )
+ # 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}}
+
+
+
+
+
\ 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)