feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation)
This commit is contained in:
@@ -503,4 +503,290 @@ def test_change_password_flow():
|
||||
assert new_auth["username"] == "pwd_user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5: Bookshelf & Interactive Reader Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_mock_epub(title="The Time Machine", author="H. G. Wells", chapters=None):
|
||||
import io, zipfile
|
||||
if chapters is None:
|
||||
chapters = [
|
||||
("Chapter 1: The Machine", "<p>The Time Traveller was expounding a recondite matter to us.</p>"),
|
||||
("Chapter 2: The Journey", "<p>I seemed to see the clock hands spin around.</p>")
|
||||
]
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr("META-INF/container.xml", """<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>""")
|
||||
manifest_items = []
|
||||
spine_items = []
|
||||
for i, (ch_title, ch_body) in enumerate(chapters, 1):
|
||||
manifest_items.append(f'<item id="c{i}" href="ch{i}.xhtml" media-type="application/xhtml+xml"/>')
|
||||
spine_items.append(f'<itemref idref="c{i}"/>')
|
||||
z.writestr(f"OEBPS/ch{i}.xhtml", f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{ch_title}</title></head>
|
||||
<body>
|
||||
<h1>{ch_title}</h1>
|
||||
{ch_body}
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
opf = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>{title}</dc:title>
|
||||
<dc:creator>{author}</dc:creator>
|
||||
</metadata>
|
||||
<manifest>
|
||||
{''.join(manifest_items)}
|
||||
</manifest>
|
||||
<spine>
|
||||
{''.join(spine_items)}
|
||||
</spine>
|
||||
</package>"""
|
||||
z.writestr("OEBPS/content.opf", opf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_reader_unit():
|
||||
from app.reader import count_words, parse_text, parse_epub
|
||||
|
||||
# 1. count_words
|
||||
assert count_words("") == 0
|
||||
assert count_words(" \n\t ") == 0
|
||||
assert count_words("Hello world!") == 2
|
||||
assert count_words("Привет, мир! Это тест.") == 4
|
||||
assert count_words("Don't stop believing") == 3
|
||||
|
||||
# 2. parse_text with Markdown headers
|
||||
md_content = """# My Book
|
||||
Author: Arthur Dent
|
||||
|
||||
## Chapter 1: Hitchhiker
|
||||
Far out in the uncharted backwaters.
|
||||
|
||||
## Chapter 2: The Towel
|
||||
A towel is about the most useful thing.
|
||||
"""
|
||||
t, a, chs = parse_text(md_content)
|
||||
assert t == "My Book"
|
||||
assert a == "Arthur Dent"
|
||||
assert len(chs) == 2
|
||||
assert "Chapter 1: Hitchhiker" in chs[0]["title"]
|
||||
assert "Far out" in chs[0]["content"]
|
||||
|
||||
# 3. parse_text with Chapter X headings
|
||||
txt_content = """Chapter 1. The Boy Who Lived
|
||||
Mr. and Mrs. Dursley of number four Privet Drive.
|
||||
|
||||
Chapter 2 - The Vanishing Glass
|
||||
Nearly ten years had passed.
|
||||
"""
|
||||
t, a, chs = parse_text(txt_content, title="Harry Potter", author="J.K. Rowling")
|
||||
assert t == "Harry Potter"
|
||||
assert a == "J.K. Rowling"
|
||||
assert len(chs) == 2
|
||||
assert "Chapter 1" in chs[0]["title"]
|
||||
assert "Chapter 2" in chs[1]["title"]
|
||||
|
||||
# 4. parse_text chunking on long text
|
||||
long_words = ["word"] * 7000
|
||||
long_text = "\n\n".join([" ".join(long_words[i:i + 400]) for i in range(0, len(long_words), 400)])
|
||||
t, a, chs = parse_text(long_text, title="Long Essay")
|
||||
assert t == "Long Essay"
|
||||
assert len(chs) >= 2
|
||||
|
||||
# 5. parse_epub
|
||||
epub_bytes = make_mock_epub("Brave New World", "Aldous Huxley")
|
||||
t, a, chs = parse_epub(epub_bytes)
|
||||
assert t == "Brave New World"
|
||||
assert a == "Aldous Huxley"
|
||||
assert len(chs) == 2
|
||||
assert "Chapter 1: The Machine" in chs[0]["title"]
|
||||
assert "Time Traveller" in chs[0]["content"]
|
||||
|
||||
# 6. parse_epub invalid archive error
|
||||
with pytest.raises(ValueError):
|
||||
parse_epub(b"not a zip file")
|
||||
|
||||
|
||||
def test_books_unauthenticated_blocked():
|
||||
unauth = TestClient(app)
|
||||
resp = unauth.get("/api/books")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = unauth.post("/api/books", json={"title": "Test", "content": "Text"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = unauth.get("/api/books/1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = unauth.put("/api/books/1/progress", json={"current_chapter": 1, "current_position": 0.5})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = unauth.delete("/api/books/1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_books_crud_workflow():
|
||||
# Login as admin
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# 1. Create book via JSON
|
||||
payload = {
|
||||
"title": "Fight Club",
|
||||
"author": "Chuck Palahniuk",
|
||||
"content": "Chapter 1\nTyler gets me a job.\n\nChapter 2\nA guy had come to the club.",
|
||||
"source_type": "text"
|
||||
}
|
||||
resp = client.post("/api/books", json=payload)
|
||||
assert resp.status_code == 200
|
||||
book = resp.json()
|
||||
assert book["id"] > 0
|
||||
book_id = book["id"]
|
||||
assert book["title"] == "Fight Club"
|
||||
assert book["author"] == "Chuck Palahniuk"
|
||||
assert book["source_type"] == "text"
|
||||
assert book["chapters_count"] == 2
|
||||
assert book["total_words"] > 0
|
||||
assert book["current_chapter"] == 0
|
||||
assert book["current_position"] == 0.0
|
||||
|
||||
# 2. List books (ensure chapters_json is excluded from list)
|
||||
resp = client.get("/api/books")
|
||||
assert resp.status_code == 200
|
||||
books = resp.json()
|
||||
assert isinstance(books, list)
|
||||
target = next((b for b in books if b["id"] == book_id), None)
|
||||
assert target is not None
|
||||
assert target["title"] == "Fight Club"
|
||||
assert target["chapters_count"] == 2
|
||||
assert "chapters_json" not in target
|
||||
|
||||
# 3. Get single book (includes chapters_json and chapters)
|
||||
resp = client.get(f"/api/books/{book_id}")
|
||||
assert resp.status_code == 200
|
||||
detail = resp.json()
|
||||
assert detail["id"] == book_id
|
||||
assert "chapters_json" in detail
|
||||
assert "chapters" in detail
|
||||
assert len(detail["chapters"]) == 2
|
||||
assert "Chapter 1" in detail["chapters"][0]["title"]
|
||||
|
||||
# 4. Update reading progress
|
||||
resp = client.put(f"/api/books/{book_id}/progress", json={
|
||||
"current_chapter": 1,
|
||||
"current_position": 0.75
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
updated = resp.json()
|
||||
assert updated["current_chapter"] == 1
|
||||
assert updated["current_position"] == 0.75
|
||||
|
||||
# 5. Delete book
|
||||
resp = client.delete(f"/api/books/{book_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert resp.json()["deleted_id"] == book_id
|
||||
|
||||
# 6. Verify deleted (404)
|
||||
resp = client.get(f"/api/books/{book_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_books_multipart_file_uploads():
|
||||
# Login
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# 1. Upload TXT file
|
||||
txt_data = b"Chapter 1: The Start\nOnce upon a time in a distant land.\n\nChapter 2: The End\nThey lived happily ever after."
|
||||
resp = client.post(
|
||||
"/api/books",
|
||||
files={"file": ("fairytale.txt", txt_data, "text/plain")},
|
||||
data={"title": "Fairy Tale", "author": "Brothers Grimm"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
book_txt = resp.json()
|
||||
assert book_txt["title"] == "Fairy Tale"
|
||||
assert book_txt["author"] == "Brothers Grimm"
|
||||
assert book_txt["source_type"] == "txt"
|
||||
assert book_txt["chapters_count"] == 2
|
||||
|
||||
# 2. Upload Markdown file
|
||||
md_data = b"# The Cyberpunk Guide\nBy: Anonymous\n\n## Section 1\nWelcome to the matrix.\n\n## Section 2\nJack into cyberspace."
|
||||
resp = client.post(
|
||||
"/api/books",
|
||||
files={"file": ("cyberpunk.md", md_data, "text/markdown")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
book_md = resp.json()
|
||||
assert book_md["title"] == "The Cyberpunk Guide"
|
||||
assert book_md["author"] == "Anonymous"
|
||||
assert book_md["source_type"] == "md"
|
||||
assert book_md["chapters_count"] == 2
|
||||
|
||||
# 3. Upload EPUB file
|
||||
epub_bytes = make_mock_epub("The Invisible Man", "H. G. Wells")
|
||||
resp = client.post(
|
||||
"/api/books",
|
||||
files={"file": ("invisible_man.epub", epub_bytes, "application/epub+zip")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
book_epub = resp.json()
|
||||
assert book_epub["title"] == "The Invisible Man"
|
||||
assert book_epub["author"] == "H. G. Wells"
|
||||
assert book_epub["source_type"] == "epub"
|
||||
assert book_epub["chapters_count"] == 2
|
||||
|
||||
|
||||
def test_srs_queue_and_quiz_tag_filtering():
|
||||
# Login
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# Create distinct tagged cards for book-scoped testing
|
||||
c1 = client.post("/api/cards", json={
|
||||
"term": "recondite",
|
||||
"translation": "малопонятный, глубокий",
|
||||
"tags": "book:time-machine, scifi",
|
||||
"collection": "Literature"
|
||||
}).json()
|
||||
|
||||
c2 = client.post("/api/cards", json={
|
||||
"term": "invisible",
|
||||
"translation": "невидимый",
|
||||
"tags": "book:invisible-man, scifi",
|
||||
"collection": "Literature"
|
||||
}).json()
|
||||
|
||||
c3 = client.post("/api/cards", json={
|
||||
"term": "soap",
|
||||
"translation": "мыло",
|
||||
"tags": "book:fight-club",
|
||||
"collection": "Fiction"
|
||||
}).json()
|
||||
|
||||
# 1. Filter queue by tag
|
||||
resp = client.get("/api/srs/queue?tag=book:time-machine")
|
||||
assert resp.status_code == 200
|
||||
queue_cards = resp.json()["cards"]
|
||||
card_ids = [c["id"] for c in queue_cards]
|
||||
assert c1["id"] in card_ids
|
||||
assert c2["id"] not in card_ids
|
||||
assert c3["id"] not in card_ids
|
||||
|
||||
# 2. Filter quiz by tag
|
||||
resp = client.get("/api/srs/quiz?tag=book:fight-club")
|
||||
assert resp.status_code == 200
|
||||
quiz_items = resp.json()["quiz"]
|
||||
assert any(q["card"]["id"] == c3["id"] for q in quiz_items)
|
||||
assert not any(q["card"]["id"] == c1["id"] for q in quiz_items)
|
||||
assert not any(q["card"]["id"] == c2["id"] for q in quiz_items)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user