A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by `make up`) checks what the user writes in the compose window, through POST /api/spellcheck — the browser never talks to the engine directly. - Marks are CSS highlights over the text, never written into the message: red wavy for spelling, blue for grammar/style. Clicking one shows the message, up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore. The popup, like the link popup, opens above its anchor when there is no room below. - Checked after a pause in typing; the signature and quoted original are skipped; marks on words just edited drop at once; a check overtaken by a newer one, by turning checking off, or by closing the window is discarded. - With "auto", text is checked in chunks of lines, each in its own detected language — LanguageTool settles on one language per request, so a mixed Polish/English message had every English word flagged as a Polish typo. - Settings > Appearance: on/off and language (Auto-detect or any of the engine's languages); the compose window's More menu overrides it for one message. The browser's own spellcheck steps aside while ours runs, and comes back when it is off or the server has no engine (503 from the endpoint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
"""Spelling/grammar check proxy (compose window -> LanguageTool).
|
|
|
|
LanguageTool is replaced by a stub transport, so these run without the engine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.api import spellcheck
|
|
from app.config import get_settings
|
|
from app.identity.passwords import hash_password
|
|
from app.identity.sessions import create_session
|
|
from app.main import create_app
|
|
from app.models import User
|
|
|
|
LT_REPLY: dict[str, Any] = {
|
|
"language": {
|
|
"code": "auto",
|
|
"name": "Auto",
|
|
"detectedLanguage": {"code": "pl-PL", "name": "Polish"},
|
|
},
|
|
"matches": [
|
|
{
|
|
"offset": 12,
|
|
"length": 6,
|
|
"message": "Wyraz „prosze” to najczęściej literówka",
|
|
"shortMessage": "Literówka",
|
|
"replacements": [{"value": "proszę"}],
|
|
"rule": {"id": "PL_PROSZE", "issueType": "misspelling"},
|
|
},
|
|
{
|
|
"offset": 40,
|
|
"length": 11,
|
|
"message": "Powtórzony wyraz",
|
|
"shortMessage": "",
|
|
"replacements": [{"value": v} for v in "abcdefgh"],
|
|
"rule": {"id": "WORD_REPEAT", "issueType": "duplication"},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
|
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
|
session = async_sessionmaker(engine, expire_on_commit=False)()
|
|
user = User(handle=f"sp{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
|
session.add(user)
|
|
await session.commit()
|
|
token = await create_session(user.id, "pytest")
|
|
async with httpx.AsyncClient(
|
|
transport=ASGITransport(app=create_app()),
|
|
base_url="http://testserver",
|
|
cookies={"session": token, "csrf": "c"},
|
|
headers={"X-CSRF": "c"},
|
|
) as c:
|
|
yield c
|
|
await session.execute(delete(User).where(User.id == user.id))
|
|
await session.commit()
|
|
await session.close()
|
|
await engine.dispose()
|
|
|
|
|
|
def _stub(monkeypatch: pytest.MonkeyPatch, handler: Any) -> list[httpx.Request]:
|
|
"""Route the module's outbound calls to `handler`; return what was sent."""
|
|
seen: list[httpx.Request] = []
|
|
real = httpx.AsyncClient
|
|
|
|
def wrapped(request: httpx.Request) -> httpx.Response:
|
|
seen.append(request)
|
|
return handler(request)
|
|
|
|
monkeypatch.setattr(
|
|
spellcheck.httpx,
|
|
"AsyncClient",
|
|
lambda **kw: real(transport=httpx.MockTransport(wrapped), **kw),
|
|
)
|
|
monkeypatch.setattr(get_settings(), "languagetool_url", "http://lt.test:8010")
|
|
return seen
|
|
|
|
|
|
async def test_check_returns_trimmed_typed_issues(
|
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
|
|
resp = await client.post(
|
|
"/api/spellcheck",
|
|
json={"text": "Dzień dobry prosze o pliki ważne ważne", "language": "auto"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert (body["language"], body["language_name"]) == ("pl-PL", "Polish")
|
|
first, second = body["issues"]
|
|
assert first["kind"] == "spelling" and first["replacements"] == ["proszę"]
|
|
assert second["kind"] == "grammar" and len(second["replacements"]) == 6
|
|
assert b"language=auto" in sent[0].content
|
|
|
|
|
|
async def test_unconfigured_or_down_engine_is_a_503(
|
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def down(_r: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
_stub(monkeypatch, down)
|
|
resp = await client.post("/api/spellcheck", json={"text": "hello"})
|
|
assert resp.json()["error"]["code"] == "spellcheck_unavailable"
|
|
|
|
monkeypatch.setattr(get_settings(), "languagetool_url", "")
|
|
resp = await client.post("/api/spellcheck", json={"text": "hello"})
|
|
assert resp.status_code == 503
|
|
|
|
|
|
async def test_empty_text_never_reaches_the_engine(
|
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
|
|
resp = await client.post("/api/spellcheck", json={"text": " \n "})
|
|
assert resp.json()["issues"] == [] and sent == []
|
|
|
|
|
|
async def test_settings_store_the_spellcheck_choice(client: httpx.AsyncClient) -> None:
|
|
out = (await client.patch("/api/settings", json={"spellcheck_language": "pl-PL"})).json()
|
|
assert out["spellcheck_language"] == "pl-PL" and out["spellcheck_enabled"] is True
|
|
out = (await client.patch("/api/settings", json={"spellcheck_enabled": False})).json()
|
|
assert out["spellcheck_enabled"] is False
|
|
bad = (await client.patch("/api/settings", json={"spellcheck_language": "<script>"})).json()
|
|
assert bad["spellcheck_language"] == "pl-PL" # rejected, previous value kept
|
|
|
|
|
|
def test_auto_text_is_split_into_language_sized_chunks() -> None:
|
|
text = "Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.\n\nHi\n\nThanks!"
|
|
parts = spellcheck.chunks(text)
|
|
# The greeting rides with the long line after it; blank lines end a chunk.
|
|
assert [t for _, t in parts] == [
|
|
"Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.",
|
|
"Hi",
|
|
"Thanks!",
|
|
]
|
|
# Offsets point back into the original text.
|
|
assert all(text[o : o + len(t)] == t for o, t in parts)
|
|
|
|
|
|
async def test_auto_checks_each_chunk_and_shifts_offsets(
|
|
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def per_chunk(request: httpx.Request) -> httpx.Response:
|
|
body = request.content.decode()
|
|
lang = ("pl-PL", "Polish") if "Witam" in body else ("en-US", "English (US)")
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"language": {"detectedLanguage": {"code": lang[0], "name": lang[1]}},
|
|
"matches": [
|
|
{
|
|
"offset": 0,
|
|
"length": 2,
|
|
"message": "m",
|
|
"replacements": [],
|
|
"rule": {"id": "R", "issueType": "misspelling"},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
sent = _stub(monkeypatch, per_chunk)
|
|
text = "Witam, to jest długi polski akapit pełen słów i zdań.\n\nHello there friend"
|
|
out = (await client.post("/api/spellcheck", json={"text": text})).json()
|
|
assert len(sent) == 2
|
|
assert [i["offset"] for i in out["issues"]] == [0, text.index("Hello")]
|
|
assert out["language_name"] == "Polish" # most of the text
|