feat: add compose and decompose numbers problem
This commit is contained in:
@@ -53,6 +53,8 @@ For a new grade, follow the same pattern:
|
|||||||
3. Include the new grade router from `app/routers/math.py`.
|
3. Include the new grade router from `app/routers/math.py`.
|
||||||
4. Add tests.
|
4. Add tests.
|
||||||
|
|
||||||
|
Every time you add a new problem, also add an endpoint curl example on the ENDPOINTS-EXAMPLE.md file.
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
|
|
||||||
- Endpoint paths use the existing style: `/math/grade_1/<problem_name>`.
|
- Endpoint paths use the existing style: `/math/grade_1/<problem_name>`.
|
||||||
|
|||||||
@@ -49,3 +49,20 @@ curl -X POST "http://127.0.0.1:8000/math/grade_1/where_are_more_items" \
|
|||||||
"seed": 1
|
"seed": 1
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Compose and decompose numbers
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST "http://127.0.0.1:8000/math/grade_1/compose_and_decompose_numbers" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"picture": {
|
||||||
|
"id": "cube",
|
||||||
|
"name": "Cube",
|
||||||
|
"image_path": "/images/cube.png"
|
||||||
|
},
|
||||||
|
"whole": 10,
|
||||||
|
"randomize_rows": false,
|
||||||
|
"seed": 1
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|||||||
42
app/problems/grade_1/compose_and_decompose_numbers.py
Normal file
42
app/problems/grade_1/compose_and_decompose_numbers.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from app.schemas.grade_1.compose_and_decompose_numbers import (
|
||||||
|
ComposeAndDecomposeNumbersProblem,
|
||||||
|
DecompositionRow,
|
||||||
|
PictureAsset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_and_decompose_numbers(
|
||||||
|
picture: dict,
|
||||||
|
whole: int = 10,
|
||||||
|
randomize_rows: bool = False,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate number decomposition rows for one picture asset."""
|
||||||
|
if whole < 2:
|
||||||
|
raise ValueError("whole must be at least 2")
|
||||||
|
|
||||||
|
selected_picture = PictureAsset.model_validate(picture)
|
||||||
|
pairs = [(first_part, whole - first_part) for first_part in range(whole - 1, 0, -1)]
|
||||||
|
|
||||||
|
if randomize_rows:
|
||||||
|
rng = random.Random(seed)
|
||||||
|
rng.shuffle(pairs)
|
||||||
|
|
||||||
|
problem = ComposeAndDecomposeNumbersProblem(
|
||||||
|
whole=whole,
|
||||||
|
picture=selected_picture,
|
||||||
|
rows=[
|
||||||
|
DecompositionRow(
|
||||||
|
position=index + 1,
|
||||||
|
whole=whole,
|
||||||
|
first_part=first_part,
|
||||||
|
second_part=second_part,
|
||||||
|
picture=selected_picture,
|
||||||
|
)
|
||||||
|
for index, (first_part, second_part) in enumerate(pairs)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return problem.model_dump()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -1,9 +1,16 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.problems.grade_1.compose_and_decompose_numbers import (
|
||||||
|
compose_and_decompose_numbers,
|
||||||
|
)
|
||||||
from app.problems.grade_1.join_pictures_with_quantity import (
|
from app.problems.grade_1.join_pictures_with_quantity import (
|
||||||
join_pictures_with_quantity,
|
join_pictures_with_quantity,
|
||||||
)
|
)
|
||||||
from app.problems.grade_1.where_are_more_items import where_are_more_items
|
from app.problems.grade_1.where_are_more_items import where_are_more_items
|
||||||
|
from app.schemas.grade_1.compose_and_decompose_numbers import (
|
||||||
|
ComposeAndDecomposeNumbersProblem,
|
||||||
|
ComposeAndDecomposeNumbersRequest,
|
||||||
|
)
|
||||||
from app.schemas.grade_1.join_pictures_with_quantity import (
|
from app.schemas.grade_1.join_pictures_with_quantity import (
|
||||||
JoinPicturesWithQuantityProblem,
|
JoinPicturesWithQuantityProblem,
|
||||||
JoinPicturesWithQuantityRequest,
|
JoinPicturesWithQuantityRequest,
|
||||||
@@ -16,6 +23,24 @@ from app.schemas.grade_1.where_are_more_items import (
|
|||||||
router = APIRouter(prefix="/grade_1", tags=["Grade 1"])
|
router = APIRouter(prefix="/grade_1", tags=["Grade 1"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/compose_and_decompose_numbers",
|
||||||
|
response_model=ComposeAndDecomposeNumbersProblem,
|
||||||
|
)
|
||||||
|
def create_compose_and_decompose_numbers_problem(
|
||||||
|
request: ComposeAndDecomposeNumbersRequest,
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
return compose_and_decompose_numbers(
|
||||||
|
picture=request.picture.model_dump(),
|
||||||
|
whole=request.whole,
|
||||||
|
randomize_rows=request.randomize_rows,
|
||||||
|
seed=request.seed,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/join_pictures_with_quantity",
|
"/join_pictures_with_quantity",
|
||||||
response_model=JoinPicturesWithQuantityProblem,
|
response_model=JoinPicturesWithQuantityProblem,
|
||||||
|
|||||||
30
app/schemas/grade_1/compose_and_decompose_numbers.py
Normal file
30
app/schemas/grade_1/compose_and_decompose_numbers.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from pydantic import BaseModel, Field, PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class PictureAsset(BaseModel):
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
image_path: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DecompositionRow(BaseModel):
|
||||||
|
position: PositiveInt
|
||||||
|
whole: PositiveInt
|
||||||
|
first_part: PositiveInt
|
||||||
|
second_part: PositiveInt
|
||||||
|
picture: PictureAsset
|
||||||
|
has_answer_boxes: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeAndDecomposeNumbersProblem(BaseModel):
|
||||||
|
instructions: str = "Compón y descompón el número."
|
||||||
|
whole: PositiveInt
|
||||||
|
picture: PictureAsset
|
||||||
|
rows: list[DecompositionRow]
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeAndDecomposeNumbersRequest(BaseModel):
|
||||||
|
picture: PictureAsset
|
||||||
|
whole: PositiveInt = 10
|
||||||
|
randomize_rows: bool = False
|
||||||
|
seed: int | None = None
|
||||||
66
tests/test_compose_and_decompose_numbers_endpoint.py
Normal file
66
tests/test_compose_and_decompose_numbers_endpoint.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeAndDecomposeNumbersEndpointTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
self.picture = {
|
||||||
|
"id": "cube",
|
||||||
|
"name": "Cube",
|
||||||
|
"image_path": "/images/cube.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_creates_ordered_problem(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/math/grade_1/compose_and_decompose_numbers",
|
||||||
|
json={"picture": self.picture},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
problem = response.json()
|
||||||
|
rows = problem["rows"]
|
||||||
|
|
||||||
|
self.assertEqual(problem["instructions"], "Compón y descompón el número.")
|
||||||
|
self.assertEqual(problem["whole"], 10)
|
||||||
|
self.assertEqual(problem["picture"], self.picture)
|
||||||
|
self.assertEqual(len(rows), 9)
|
||||||
|
self.assertEqual(
|
||||||
|
[(row["first_part"], row["second_part"]) for row in rows],
|
||||||
|
[(9, 1), (8, 2), (7, 3), (6, 4), (5, 5), (4, 6), (3, 7), (2, 8), (1, 9)],
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
self.assertEqual(row["whole"], 10)
|
||||||
|
self.assertEqual(row["first_part"] + row["second_part"], 10)
|
||||||
|
self.assertEqual(row["picture"], self.picture)
|
||||||
|
|
||||||
|
def test_can_randomize_rows(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/math/grade_1/compose_and_decompose_numbers",
|
||||||
|
json={"picture": self.picture, "randomize_rows": True, "seed": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
rows = response.json()["rows"]
|
||||||
|
ordered_pairs = [(9, 1), (8, 2), (7, 3), (6, 4), (5, 5), (4, 6), (3, 7), (2, 8), (1, 9)]
|
||||||
|
randomized_pairs = [(row["first_part"], row["second_part"]) for row in rows]
|
||||||
|
|
||||||
|
self.assertNotEqual(randomized_pairs, ordered_pairs)
|
||||||
|
self.assertCountEqual(randomized_pairs, ordered_pairs)
|
||||||
|
|
||||||
|
def test_returns_bad_request_for_whole_less_than_two(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/math/grade_1/compose_and_decompose_numbers",
|
||||||
|
json={"picture": self.picture, "whole": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json(), {"detail": "whole must be at least 2"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user