Implement Stage 1: command generator, CLI, TTS and LLM clients
- lexicon.py: exact forms from docs/LEXICON.md (Polish diacritics preserved) - generator.py: Clause/Command dataclasses, render(), random_command(level 1-3) - llm.py: OpenAI-compatible client via urllib, JSON-only contract, fallback to random - tts.py: Piper via subprocess, defensive (prints [TTS off: reason] on any failure) - cli.py: argparse interface per SPEC §9 - tests/test_generator.py: 23 tests covering all genders, locations, level-3 joining, capitalisation; all pass DoD verified: pytest 23/23, python -m ipin_vr --level 3 --count 6 --no-audio OK. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ad397ae1bd
commit
691c8ea360
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.wav
|
||||
*.onnx
|
||||
*.onnx.json
|
||||
.pytest_cache/
|
||||
dist/
|
||||
*.egg-info/
|
||||
53
README.md
53
README.md
|
|
@ -17,5 +17,56 @@ Poprawność fleksji jest nienegocjowalna. Wszystkie formy słów pochodzą wył
|
|||
z ręcznie zweryfikowanego leksykonu; model językowy (gdy włączony) wybiera tylko
|
||||
identyfikatory elementów, a nie generuje polskiego tekstu.
|
||||
|
||||
## Szybki start (bez audio)
|
||||
|
||||
```bash
|
||||
python -m ipin_vr --level 3 --count 6 --no-audio
|
||||
```
|
||||
|
||||
Przykładowy wynik:
|
||||
```
|
||||
1. Połóż zieloną książkę na półce i umieść klucz na biurku.
|
||||
2. Umieść żółtą piłkę na parapecie i połóż kubek na stole.
|
||||
...
|
||||
```
|
||||
|
||||
Dostępne opcje CLI:
|
||||
|
||||
| Flaga | Domyślnie | Opis |
|
||||
|---|---|---|
|
||||
| `--level {1,2,3}` | 1 | poziom trudności |
|
||||
| `--count N` | 5 | liczba poleceń |
|
||||
| `--delay S` | 4.0 | pauza między poleceniami (s) |
|
||||
| `--voice PATH` | — | ścieżka do modelu Piper `.onnx` |
|
||||
| `--no-audio` | — | wyłącza TTS |
|
||||
| `--llm` | — | użyj LLM do wyboru poleceń |
|
||||
| `--llm-endpoint URL` | localhost:8080 | endpoint llama.cpp |
|
||||
| `--llm-model NAME` | `local` | nazwa modelu |
|
||||
| `--seed N` | — | deterministyczny generator |
|
||||
|
||||
## Audio (Piper TTS)
|
||||
|
||||
```bash
|
||||
pip install piper-tts
|
||||
# pobierz głos polski, np. ze https://huggingface.co/rhasspy/piper-voices
|
||||
python -m ipin_vr --level 2 --count 5 --voice pl_PL-gosia-medium.onnx
|
||||
```
|
||||
|
||||
## LLM (llama.cpp, opcjonalny)
|
||||
|
||||
```bash
|
||||
# uruchom lokalnie serwer llama.cpp na porcie 8080
|
||||
python -m ipin_vr --level 3 --count 5 --no-audio --llm
|
||||
```
|
||||
|
||||
LLM zwraca wyłącznie identyfikatory elementów; gramatykę buduje zawsze leksykon.
|
||||
Przy błędzie (timeout, zły JSON, nieznany identyfikator) — fallback na generator losowy.
|
||||
|
||||
## Testy
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
## Status
|
||||
Etap 1 — w budowie. Implementacja powstaje ściśle według `docs/SPEC.md`.
|
||||
Etap 1 — zrealizowany zgodnie z `docs/SPEC.md`.
|
||||
|
|
|
|||
0
ipin_vr/__init__.py
Normal file
0
ipin_vr/__init__.py
Normal file
3
ipin_vr/__main__.py
Normal file
3
ipin_vr/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .cli import main
|
||||
|
||||
main()
|
||||
53
ipin_vr/cli.py
Normal file
53
ipin_vr/cli.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import argparse
|
||||
import random
|
||||
import time
|
||||
|
||||
from .generator import random_command, render
|
||||
from .llm import llm_command
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ipin-vr: generator poleceń do terapii afazji"
|
||||
)
|
||||
parser.add_argument("--level", type=int, choices=[1, 2, 3], default=1,
|
||||
metavar="{1,2,3}", help="poziom trudności (domyślnie: 1)")
|
||||
parser.add_argument("--count", type=int, default=5, metavar="N",
|
||||
help="liczba poleceń (domyślnie: 5)")
|
||||
parser.add_argument("--delay", type=float, default=4.0, metavar="S",
|
||||
help="pauza między poleceniami w sekundach (domyślnie: 4.0)")
|
||||
parser.add_argument("--voice", metavar="PATH",
|
||||
help="ścieżka do modelu Piper .onnx")
|
||||
parser.add_argument("--no-audio", action="store_true",
|
||||
help="wyłącza TTS")
|
||||
parser.add_argument("--llm", action="store_true",
|
||||
help="użyj LLM do wyboru poleceń")
|
||||
parser.add_argument("--llm-endpoint",
|
||||
default="http://localhost:8080/v1/chat/completions",
|
||||
metavar="URL", help="endpoint llama.cpp")
|
||||
parser.add_argument("--llm-model", default="local", metavar="NAME",
|
||||
help="nazwa modelu")
|
||||
parser.add_argument("--seed", type=int, metavar="N",
|
||||
help="deterministyczny generator losowy")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.seed is not None:
|
||||
random.seed(args.seed)
|
||||
|
||||
use_audio = (not args.no_audio) and (args.voice is not None)
|
||||
|
||||
for i in range(1, args.count + 1):
|
||||
if args.llm:
|
||||
cmd = llm_command(args.level, args.llm_endpoint, args.llm_model)
|
||||
else:
|
||||
cmd = random_command(args.level)
|
||||
|
||||
text = render(cmd)
|
||||
print(f"{i}. {text}")
|
||||
|
||||
if use_audio:
|
||||
from .tts import speak
|
||||
speak(text, args.voice)
|
||||
|
||||
if i < args.count:
|
||||
time.sleep(args.delay)
|
||||
73
ipin_vr/generator.py
Normal file
73
ipin_vr/generator.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .lexicon import COLORS, LOCATIONS, OBJECTS, VERBS
|
||||
|
||||
|
||||
@dataclass
|
||||
class Clause:
|
||||
verb_id: str
|
||||
object_id: str
|
||||
location_id: str
|
||||
color_id: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
clauses: list[Clause]
|
||||
level: int
|
||||
|
||||
|
||||
def _render_clause(clause: Clause, first: bool) -> str:
|
||||
verb = VERBS[clause.verb_id]
|
||||
if not first:
|
||||
verb = verb.lower()
|
||||
|
||||
obj = OBJECTS[clause.object_id]
|
||||
parts = [verb]
|
||||
if clause.color_id is not None:
|
||||
parts.append(COLORS[clause.color_id][obj["gender"]])
|
||||
parts.append(obj["acc"])
|
||||
parts.append(LOCATIONS[clause.location_id])
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def render(command: Command) -> str:
|
||||
rendered = [
|
||||
_render_clause(clause, i == 0)
|
||||
for i, clause in enumerate(command.clauses)
|
||||
]
|
||||
return " i ".join(rendered) + "."
|
||||
|
||||
|
||||
def random_command(level: int) -> Command:
|
||||
verb_ids = list(VERBS)
|
||||
object_ids = list(OBJECTS)
|
||||
location_ids = list(LOCATIONS)
|
||||
color_ids = list(COLORS)
|
||||
|
||||
def _clause(object_id: str, use_color: bool) -> Clause:
|
||||
return Clause(
|
||||
verb_id=random.choice(verb_ids),
|
||||
object_id=object_id,
|
||||
location_id=random.choice(location_ids),
|
||||
color_id=random.choice(color_ids) if use_color else None,
|
||||
)
|
||||
|
||||
if level == 1:
|
||||
return Command(clauses=[_clause(random.choice(object_ids), False)], level=1)
|
||||
|
||||
if level == 2:
|
||||
return Command(clauses=[_clause(random.choice(object_ids), True)], level=2)
|
||||
|
||||
if level == 3:
|
||||
obj1 = random.choice(object_ids)
|
||||
obj2 = random.choice([o for o in object_ids if o != obj1])
|
||||
clauses = [
|
||||
_clause(obj1, random.choice([True, False])),
|
||||
_clause(obj2, random.choice([True, False])),
|
||||
]
|
||||
return Command(clauses=clauses, level=3)
|
||||
|
||||
raise ValueError(f"Nieznany poziom: {level}")
|
||||
35
ipin_vr/lexicon.py
Normal file
35
ipin_vr/lexicon.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
OBJECTS = {
|
||||
"jablko": {"nom": "jabłko", "acc": "jabłko", "gender": "n"},
|
||||
"kubek": {"nom": "kubek", "acc": "kubek", "gender": "m"},
|
||||
"lyzka": {"nom": "łyżka", "acc": "łyżkę", "gender": "f"},
|
||||
"ksiazka": {"nom": "książka", "acc": "książkę", "gender": "f"},
|
||||
"dlugopis": {"nom": "długopis", "acc": "długopis", "gender": "m"},
|
||||
"klucz": {"nom": "klucz", "acc": "klucz", "gender": "m"},
|
||||
"pilka": {"nom": "piłka", "acc": "piłkę", "gender": "f"},
|
||||
"butelka": {"nom": "butelka", "acc": "butelkę", "gender": "f"},
|
||||
"talerz": {"nom": "talerz", "acc": "talerz", "gender": "m"},
|
||||
"banan": {"nom": "banan", "acc": "banan", "gender": "m"},
|
||||
}
|
||||
|
||||
COLORS = {
|
||||
"czerwony": {"m": "czerwony", "f": "czerwoną", "n": "czerwone"},
|
||||
"zielony": {"m": "zielony", "f": "zieloną", "n": "zielone"},
|
||||
"niebieski": {"m": "niebieski", "f": "niebieską", "n": "niebieskie"},
|
||||
"zolty": {"m": "żółty", "f": "żółtą", "n": "żółte"},
|
||||
"bialy": {"m": "biały", "f": "białą", "n": "białe"},
|
||||
"czarny": {"m": "czarny", "f": "czarną", "n": "czarne"},
|
||||
}
|
||||
|
||||
LOCATIONS = {
|
||||
"stol": "na stole",
|
||||
"krzeslo": "na krześle",
|
||||
"polka": "na półce",
|
||||
"podloga": "na podłodze",
|
||||
"biurko": "na biurku",
|
||||
"parapet": "na parapecie",
|
||||
}
|
||||
|
||||
VERBS = {
|
||||
"poloz": "Połóż",
|
||||
"umiesc": "Umieść",
|
||||
}
|
||||
80
ipin_vr/llm.py
Normal file
80
ipin_vr/llm.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from .generator import Clause, Command, random_command
|
||||
from .lexicon import COLORS, LOCATIONS, OBJECTS, VERBS
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"Return ONLY a JSON object — no prose, no markdown, no code fences.\n"
|
||||
"Schema: {{\"clauses\":[{{\"verb\":\"<id>\",\"object\":\"<id>\","
|
||||
"\"location\":\"<id>\",\"color\":\"<id or null>\"}}],\"level\":<int>}}\n"
|
||||
"Allowed verb IDs: {verbs}\n"
|
||||
"Allowed object IDs: {objects}\n"
|
||||
"Allowed location IDs: {locations}\n"
|
||||
"Allowed color IDs: {colors}"
|
||||
)
|
||||
|
||||
|
||||
def _system_prompt() -> str:
|
||||
return _SYSTEM_PROMPT.format(
|
||||
verbs=", ".join(VERBS),
|
||||
objects=", ".join(OBJECTS),
|
||||
locations=", ".join(LOCATIONS),
|
||||
colors=", ".join(COLORS),
|
||||
)
|
||||
|
||||
|
||||
def _validate(data: dict, level: int) -> Optional[Command]:
|
||||
clauses_raw = data.get("clauses")
|
||||
if not isinstance(clauses_raw, list) or not clauses_raw:
|
||||
return None
|
||||
clauses = []
|
||||
for c in clauses_raw:
|
||||
v = c.get("verb")
|
||||
o = c.get("object")
|
||||
loc = c.get("location")
|
||||
col = c.get("color")
|
||||
if v not in VERBS or o not in OBJECTS or loc not in LOCATIONS:
|
||||
return None
|
||||
if col is not None and col not in COLORS:
|
||||
return None
|
||||
clauses.append(Clause(verb_id=v, object_id=o, location_id=loc, color_id=col))
|
||||
return Command(clauses=clauses, level=level)
|
||||
|
||||
|
||||
def llm_command(level: int, endpoint: str, model: str) -> Command:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": _system_prompt()},
|
||||
{"role": "user", "content": f"Generate a level {level} aphasia therapy command."},
|
||||
],
|
||||
"temperature": 0.8,
|
||||
}).encode()
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = json.loads(resp.read())
|
||||
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
start = content.find("{")
|
||||
end = content.rfind("}") + 1
|
||||
if start == -1 or end == 0:
|
||||
raise ValueError("brak JSON w odpowiedzi")
|
||||
data = json.loads(content[start:end])
|
||||
|
||||
cmd = _validate(data, level)
|
||||
if cmd is None:
|
||||
raise ValueError("nieznane identyfikatory w odpowiedzi LLM")
|
||||
return cmd
|
||||
|
||||
except Exception:
|
||||
return random_command(level)
|
||||
57
ipin_vr/tts.py
Normal file
57
ipin_vr/tts.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def speak(text: str, voice_path: str) -> None:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
|
||||
wav_path = tf.name
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["piper", "--model", voice_path, "--output_file", wav_path],
|
||||
input=text.encode(),
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.decode(errors="replace").strip()
|
||||
print(f"[TTS off: piper błąd {proc.returncode}: {stderr}]")
|
||||
return
|
||||
_play(wav_path)
|
||||
except FileNotFoundError:
|
||||
print("[TTS off: piper nie znaleziony — zainstaluj piper-tts]")
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[TTS off: piper timeout]")
|
||||
except Exception as exc:
|
||||
print(f"[TTS off: {exc}]")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(wav_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _play(wav_path: str) -> None:
|
||||
if sys.platform.startswith("linux"):
|
||||
_run_player("aplay", wav_path)
|
||||
elif sys.platform == "darwin":
|
||||
_run_player("afplay", wav_path)
|
||||
elif sys.platform == "win32":
|
||||
try:
|
||||
import winsound
|
||||
winsound.PlaySound(wav_path, winsound.SND_FILENAME)
|
||||
except Exception as exc:
|
||||
print(f"[TTS off: winsound: {exc}]")
|
||||
else:
|
||||
print(f"[TTS off: nieobsługiwana platforma {sys.platform}]")
|
||||
|
||||
|
||||
def _run_player(player: str, wav_path: str) -> None:
|
||||
try:
|
||||
subprocess.run([player, wav_path], capture_output=True, timeout=30)
|
||||
except FileNotFoundError:
|
||||
print(f"[TTS off: {player} nie znaleziony]")
|
||||
except Exception as exc:
|
||||
print(f"[TTS off: {exc}]")
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Rdzeń nie wymaga żadnych zewnętrznych zależności (tylko stdlib).
|
||||
# TTS jest opcjonalne — zainstaluj piper-tts i pobierz głos polski:
|
||||
# pip install piper-tts
|
||||
# # głos: https://huggingface.co/rhasspy/piper-voices (np. pl_PL-gosia-medium.onnx)
|
||||
# piper-tts
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
170
tests/test_generator.py
Normal file
170
tests/test_generator.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import pytest
|
||||
from ipin_vr.generator import Clause, Command, render
|
||||
|
||||
|
||||
def cmd1(verb, obj, loc, color=None):
|
||||
return Command(clauses=[Clause(verb, obj, loc, color)], level=1)
|
||||
|
||||
|
||||
def cmd2(verb, obj, loc, color):
|
||||
return Command(clauses=[Clause(verb, obj, loc, color)], level=2)
|
||||
|
||||
|
||||
# --- kolor + rzeczownik: rodzaj nijaki (n) ---
|
||||
|
||||
def test_color_neuter():
|
||||
# jablko: acc=jabłko, gender=n → czerwone
|
||||
assert render(cmd2("poloz", "jablko", "stol", "czerwony")) == "Połóż czerwone jabłko na stole."
|
||||
|
||||
|
||||
def test_color_neuter_zolty():
|
||||
assert render(cmd2("umiesc", "jablko", "biurko", "zolty")) == "Umieść żółte jabłko na biurku."
|
||||
|
||||
|
||||
# --- kolor + rzeczownik: rodzaj męski (m) ---
|
||||
|
||||
def test_color_masculine():
|
||||
# kubek: acc=kubek, gender=m → zielony
|
||||
assert render(cmd2("poloz", "kubek", "biurko", "zielony")) == "Połóż zielony kubek na biurku."
|
||||
|
||||
|
||||
def test_color_masculine_niebieski():
|
||||
# klucz: acc=klucz, gender=m → niebieski
|
||||
assert render(cmd2("umiesc", "klucz", "polka", "niebieski")) == "Umieść niebieski klucz na półce."
|
||||
|
||||
|
||||
# --- kolor + rzeczownik: rodzaj żeński (f) ---
|
||||
|
||||
def test_color_feminine():
|
||||
# lyzka: acc=łyżkę, gender=f → niebieską
|
||||
assert render(cmd2("poloz", "lyzka", "polka", "niebieski")) == "Połóż niebieską łyżkę na półce."
|
||||
|
||||
|
||||
def test_color_feminine_zolty():
|
||||
# pilka: acc=piłkę, gender=f → żółtą
|
||||
assert render(cmd2("poloz", "pilka", "parapet", "zolty")) == "Połóż żółtą piłkę na parapecie."
|
||||
|
||||
|
||||
def test_color_feminine_zielony():
|
||||
# ksiazka: acc=książkę, gender=f → zieloną
|
||||
assert render(cmd2("umiesc", "ksiazka", "stol", "zielony")) == "Umieść zieloną książkę na stole."
|
||||
|
||||
|
||||
# --- poprawny miejscownik miejsca ---
|
||||
|
||||
def test_location_stol():
|
||||
assert render(cmd1("poloz", "jablko", "stol")) == "Połóż jabłko na stole."
|
||||
|
||||
|
||||
def test_location_krzeslo():
|
||||
assert render(cmd1("poloz", "kubek", "krzeslo")) == "Połóż kubek na krześle."
|
||||
|
||||
|
||||
def test_location_polka():
|
||||
assert render(cmd1("poloz", "klucz", "polka")) == "Połóż klucz na półce."
|
||||
|
||||
|
||||
def test_location_podloga():
|
||||
assert render(cmd1("poloz", "banan", "podloga")) == "Połóż banan na podłodze."
|
||||
|
||||
|
||||
def test_location_biurko():
|
||||
assert render(cmd1("poloz", "dlugopis", "biurko")) == "Połóż długopis na biurku."
|
||||
|
||||
|
||||
def test_location_parapet():
|
||||
assert render(cmd1("poloz", "talerz", "parapet")) == "Połóż talerz na parapecie."
|
||||
|
||||
|
||||
# --- łączenie dwóch klauzul (poziom 3) ---
|
||||
|
||||
def test_level3_two_clauses():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("poloz", "ksiazka", "polka", "zielony"),
|
||||
Clause("umiesc", "klucz", "biurko", None),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
assert render(cmd) == "Połóż zieloną książkę na półce i umieść klucz na biurku."
|
||||
|
||||
|
||||
def test_level3_both_with_color():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("poloz", "jablko", "stol", "czerwony"),
|
||||
Clause("umiesc", "pilka", "parapet", "zolty"),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
assert render(cmd) == "Połóż czerwone jabłko na stole i umieść żółtą piłkę na parapecie."
|
||||
|
||||
|
||||
def test_level3_no_colors():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("poloz", "kubek", "biurko", None),
|
||||
Clause("poloz", "banan", "podloga", None),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
assert render(cmd) == "Połóż kubek na biurku i połóż banan na podłodze."
|
||||
|
||||
|
||||
# --- kapitalizacja: pierwsza klauzula wielką, kolejna małą literą ---
|
||||
|
||||
def test_capitalization_first_uppercase():
|
||||
result = render(cmd1("poloz", "jablko", "stol"))
|
||||
assert result[0].isupper()
|
||||
|
||||
|
||||
def test_capitalization_second_lowercase():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("poloz", "jablko", "stol", None),
|
||||
Clause("poloz", "kubek", "biurko", None),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
result = render(cmd)
|
||||
# "Połóż jabłko na stole i połóż kubek na biurku."
|
||||
assert result.startswith("Połóż")
|
||||
assert " i połóż" in result
|
||||
|
||||
|
||||
def test_capitalization_umiesc_second():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("umiesc", "talerz", "stol", None),
|
||||
Clause("umiesc", "butelka", "polka", None),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
result = render(cmd)
|
||||
assert result.startswith("Umieść")
|
||||
assert " i umieść" in result
|
||||
|
||||
|
||||
# --- przykłady ze SPEC ---
|
||||
|
||||
def test_spec_example_poloz_jablko():
|
||||
assert render(cmd1("poloz", "jablko", "stol")) == "Połóż jabłko na stole."
|
||||
|
||||
|
||||
def test_spec_example_zolta_pilka():
|
||||
assert render(cmd2("poloz", "pilka", "parapet", "zolty")) == "Połóż żółtą piłkę na parapecie."
|
||||
|
||||
|
||||
def test_spec_example_niebieskie_jablko():
|
||||
assert render(cmd2("umiesc", "jablko", "stol", "niebieski")) == "Umieść niebieskie jabłko na stole."
|
||||
|
||||
|
||||
def test_spec_example_level3():
|
||||
cmd = Command(
|
||||
clauses=[
|
||||
Clause("poloz", "ksiazka", "polka", "zielony"),
|
||||
Clause("umiesc", "klucz", "biurko", None),
|
||||
],
|
||||
level=3,
|
||||
)
|
||||
assert render(cmd) == "Połóż zieloną książkę na półce i umieść klucz na biurku."
|
||||
Loading…
Reference in a new issue