Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions e2e/attempt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This file is part of the QuestionPy SDK. (https://questionpy.org)
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
110 changes: 110 additions & 0 deletions e2e/attempt/test_choices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# This file is part of the QuestionPy SDK. (https://questionpy.org)
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from typing import Any, cast

import pytest
from playwright.async_api import Page, expect

from questionpy import Attempt, form

FORMULATION = """
<div xmlns="http://www.w3.org/1999/xhtml"
xmlns:qpy="http://questionpy.org/ns/question">
<fieldset qpy:shuffle-contents="">
<legend><?p question ?></legend>
{% for choice in question.options.choices %}
<div>
<label>
<input type="checkbox" name="{{ choice.id }}" />
{{ choice.label }}
</label>
</div>
{% endfor %}
</fieldset>
</div>
"""


class ChoiceModel(form.FormModel):
id: str = form.generated_id()
label: str = form.text_input("Option", required=True)
is_correct: bool = form.checkbox(right_label="Correct answer")


class DynamicChoicesFormModel(form.FormModel):
question_text: str = form.text_input("Question", required=True)
choices: list[ChoiceModel] = form.repeat(ChoiceModel, button_label="Add option", minimum=2)


class DynamicChoicesAttempt(Attempt):
def __init__(self, *args: Any):
super().__init__(*args)

self.placeholders["question"] = self.options.question_text

def _compute_score(self) -> float:
assert self.response
chosen_ids = {k for k, v in self.response.items() if v == "on"}
correct_ids = {choice.id for choice in self.options.choices if choice.is_correct}
total_correct = len(correct_ids)
num_correct_chosen = len(correct_ids & chosen_ids)
return num_correct_chosen / total_correct if total_correct > 0 else 0.0

@property
def formulation(self) -> str:
return self.jinja2.from_string(FORMULATION).render()

@property
def options(self) -> DynamicChoicesFormModel:
return cast("DynamicChoicesFormModel", self.question.options)


@pytest.fixture
def form_options() -> type[form.FormModel]:
return DynamicChoicesFormModel


@pytest.fixture
def attempt() -> type[Attempt]:
return DynamicChoicesAttempt


async def test_dynamic_choices(page: Page) -> None:
# Create question
await page.get_by_role("button", name="New question", exact=True).click()
await page.get_by_label("Question", exact=True).fill("Best programming languages?")
await page.get_by_label("Option", exact=True).nth(0).fill("Python")
await page.get_by_label("Correct answer", exact=True).nth(0).check()
await page.get_by_label("Option", exact=True).nth(1).fill("Java")
await page.get_by_role("button", name="Add option", exact=True).click()
await page.get_by_label("Option", exact=True).nth(2).fill("Swift")
await page.get_by_role("button", name="Add option", exact=True).click()
await page.get_by_label("Option", exact=True).nth(3).fill("Rust")
await page.get_by_label("Correct answer", exact=True).nth(3).check()
await page.get_by_role("button", name="Create and preview", exact=True).click()

# 1st Attempt
await page.get_by_role("button", name="New attempt", exact=True).click()
await expect(page.get_by_text("Not yet scored", exact=True)).to_be_visible()
formulation = page.frame_locator("iframe")
await expect(formulation.get_by_text("Best programming languages?")).to_be_visible()
await formulation.get_by_label("Java", exact=True).check()
await page.get_by_role("button", name="Save and submit", exact=True).click()
await expect(page.get_by_text("Score: 0.0", exact=True)).to_be_visible()

# 2nd Attempt
await page.get_by_role("button", name="Restart", exact=True).click()
await expect(page.get_by_text("Not yet scored", exact=True)).to_be_visible()
await formulation.get_by_label("Python", exact=True).check()
await page.get_by_role("button", name="Save and submit", exact=True).click()
await expect(page.get_by_text("Score: 0.5", exact=True)).to_be_visible()

# 3rd Attempt
await page.get_by_role("button", name="Restart", exact=True).click()
await expect(page.get_by_text("Not yet scored", exact=True)).to_be_visible()
await formulation.get_by_label("Python", exact=True).check()
await formulation.get_by_label("Rust", exact=True).check()
await page.get_by_role("button", name="Save and submit", exact=True).click()
await expect(page.get_by_text("Score: 1.0", exact=True)).to_be_visible()
61 changes: 48 additions & 13 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
from questionpy.form import FormModel, text_input
from questionpy_common.api.qtype import QuestionTypeInterface
from questionpy_common.environment import PackageInitFunction
from questionpy_common.manifest import Bcp47LanguageTag
from questionpy_sdk.webserver.server import WebServer
from questionpy_server.worker.impl.thread import ThreadWorker
from questionpy_server.worker.runtime.package_location import FunctionPackageLocation

# Worker needs to be able to import init function from here
package_init_func: PackageInitFunction


@pytest.fixture
def url_path() -> str:
Expand All @@ -27,10 +32,16 @@ def url(unused_tcp_port: int, url_path: str) -> str:
return urljoin(f"http://localhost:{unused_tcp_port}/", url_path)


def default_init(package: Package) -> QuestionTypeInterface:
@pytest.fixture
def form_options() -> type[FormModel]:
class PackageForm(FormModel):
text_input: str = text_input("Static text label", required=True)
text_input: str = text_input("Text input", required=True)

return PackageForm


@pytest.fixture
def attempt() -> type[Attempt]:
class TestAttempt(Attempt):
def _compute_score(self) -> float:
raise NeedsManualScoringError
Expand All @@ -41,21 +52,38 @@ def _compute_score(self) -> float:
"</div>"
)

class PackageQuestion(Question):
attempt_class = TestAttempt
options: PackageForm

return QuestionTypeWrapper(PackageQuestion, package)
return TestAttempt


@pytest.fixture
def init_func() -> PackageInitFunction:
return default_init
def init_func(form_options: type[FormModel], attempt: type[Attempt]) -> PackageInitFunction:
def _package_init_func(package: Package) -> QuestionTypeInterface:
class PackageQuestion(Question):
attempt_class = attempt

# Set type annotation at runtime
PackageQuestion.__annotations__["options"] = form_options

return QuestionTypeWrapper(PackageQuestion, package)

return _package_init_func


@pytest.fixture
def manifest() -> Manifest | None:
return None
def manifest(attempt: type[Attempt]) -> Manifest:
# namespace/short_name need to match attempt module
module_name = attempt.__module__
namespace, short_name, *_ = module_name.split(".", maxsplit=2)

return Manifest(
short_name=short_name,
namespace=namespace,
version="0.1.0-test",
api_version="0.1",
author="Jane Doe",
name={Bcp47LanguageTag("en"): "E2E Test Package"},
languages=[Bcp47LanguageTag("en")],
)


@pytest.fixture
Expand All @@ -68,7 +96,14 @@ async def page(
manifest: Manifest | None,
) -> AsyncIterator[Page]:
"""Overrides pytest-playwright-asyncio's `page` fixture to include setup/teardown for the SDK web server."""
pkg_location = FunctionPackageLocation.from_function(init_func, manifest)
async with WebServer(package_location=pkg_location, state_storage_path=tmp_path, port=unused_tcp_port):
global package_init_func # noqa: PLW0603
package_init_func = init_func

async with WebServer(
package_location=FunctionPackageLocation(__name__, "package_init_func", manifest),
state_storage_path=tmp_path,
port=unused_tcp_port,
worker_class=ThreadWorker, # SubprocessWorker wouldn't see dynamic package_init_func
):
await page.goto(url)
yield page
3 changes: 3 additions & 0 deletions e2e/question/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This file is part of the QuestionPy SDK. (https://questionpy.org)
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
Loading