Rework AI-Typewriter en application d'arrière-plan multi-profils
- Rollback gestion d'équations : écriture littérale caractère par caractère (suppression de math_format.py et de la normalisation Unicode). - Nouveau profil par défaut 'Mathématiques (LaTeX)' : le modèle émet du LaTeX encadré [EQ]...[/EQ], intercepté par KeyStepper qui déclenche Alt+= en début d'équation et -> en fin. - Config multi-profils dans %APPDATA%/ai-typewriter/config.json (ConfigStore + profils par défaut auto-créés). - Icône de zone de notification (pystray) : Ouvrir les logs (temps réel), Ajouter/Modifier un profil, Gérer l'authentification, Quitter. - Sélecteur de modèles : modèles locaux Ollama + recherche/téléchargement depuis la bibliothèque publique ; fournisseurs tiers (OpenAI, OpenRouter, Gemini, custom). - Clés d'API stockées de façon sécurisée dans le Gestionnaire d'identifiants Windows via keyring (jamais en clair dans config.json). - Tests : 44 tests verts (stepper, profils, clients IA, credentials, catalogue de modèles, moteur).
This commit is contained in:
+92
-8
@@ -2,27 +2,36 @@ import pytest
|
||||
import requests
|
||||
|
||||
from ai_typewriter.ai_client import AIClientError, ask_ai
|
||||
from ai_typewriter.config import AppConfig
|
||||
from ai_typewriter.config import Profile
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ollama_payload_contains_strict_system_prompt(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(url, json, timeout, **kwargs):
|
||||
seen["url"] = url
|
||||
seen["json"] = json
|
||||
return FakeResponse({"message": {"content": "ok"}})
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
|
||||
result = ask_ai("texte", AppConfig(provider="ollama", model="m"))
|
||||
result = ask_ai("texte", Profile(provider="ollama", model="m"))
|
||||
|
||||
assert result == "ok"
|
||||
assert seen["url"].endswith("/api/chat")
|
||||
@@ -30,31 +39,106 @@ def test_ollama_payload_contains_strict_system_prompt(monkeypatch):
|
||||
assert "Uniquement la réponse brute" in seen["json"]["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_ollama_uses_profile_system_prompt(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(url, json, timeout, **kwargs):
|
||||
seen["json"] = json
|
||||
return FakeResponse({"message": {"content": "ok"}})
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
profile = Profile(provider="ollama", system_prompt="Prompt personnalisé")
|
||||
ask_ai("x", profile)
|
||||
assert seen["json"]["messages"][0]["content"] == "Prompt personnalisé"
|
||||
|
||||
|
||||
def test_ollama_timeout_message_suggests_config_change(monkeypatch):
|
||||
def fake_post(url, json, timeout, **kwargs):
|
||||
raise requests.Timeout("too slow")
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
|
||||
with pytest.raises(AIClientError) as exc:
|
||||
ask_ai("texte", AppConfig(provider="ollama", model="m", request_timeout_seconds=300))
|
||||
ask_ai("texte", Profile(provider="ollama", model="m", request_timeout_seconds=300))
|
||||
|
||||
assert "300 s" in str(exc.value)
|
||||
assert "request_timeout_seconds" in str(exc.value)
|
||||
assert "0 pour désactiver" in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gemini_payload(monkeypatch):
|
||||
seen = {}
|
||||
def fake_post(url, params, json, timeout):
|
||||
|
||||
def fake_resolver(profile, store):
|
||||
return "cle_secrete"
|
||||
|
||||
def fake_post(url, params, json, timeout, **kwargs):
|
||||
seen["url"] = url
|
||||
seen["params"] = params
|
||||
seen["json"] = json
|
||||
return FakeResponse({"candidates": [{"content": {"parts": [{"text": "brut"}]}}]})
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
|
||||
result = ask_ai("texte", AppConfig(provider="gemini", model="gemini-1.5-flash", api_key="k"))
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
profile = Profile(provider="gemini", model="gemini-1.5-flash", credential="gem")
|
||||
|
||||
result = ask_ai("texte", profile, resolve_key=fake_resolver)
|
||||
|
||||
assert result == "brut"
|
||||
assert seen["params"] == {"key": "k"}
|
||||
assert seen["params"] == {"key": "cle_secrete"}
|
||||
assert seen["url"].endswith("/v1beta/models/gemini-1.5-flash:generateContent")
|
||||
assert "systemInstruction" in seen["json"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-compatible
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_openai_payload_with_bearer_key(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_resolver(profile, store):
|
||||
return "sk-test"
|
||||
|
||||
def fake_post(url, json, headers, timeout, **kwargs):
|
||||
seen["url"] = url
|
||||
seen["headers"] = headers
|
||||
return FakeResponse({"choices": [{"message": {"content": "gpt-reponse"}}]})
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post)
|
||||
profile = Profile(
|
||||
provider="openai",
|
||||
model="gpt-4o-mini",
|
||||
credential="openai",
|
||||
server_url="https://api.openai.com/v1",
|
||||
)
|
||||
|
||||
result = ask_ai("texte", profile, resolve_key=fake_resolver)
|
||||
|
||||
assert result == "gpt-reponse"
|
||||
assert seen["url"].endswith("/chat/completions")
|
||||
assert seen["headers"]["Authorization"] == "Bearer sk-test"
|
||||
|
||||
|
||||
def test_missing_key_raises_actionable_error(monkeypatch):
|
||||
profile = Profile(provider="openai", model="gpt", credential="openai")
|
||||
|
||||
with pytest.raises(AIClientError) as exc:
|
||||
ask_ai("texte", profile, resolve_key=lambda p, s: "")
|
||||
|
||||
assert "Aucune clé" in str(exc.value)
|
||||
assert "authentification" in str(exc.value)
|
||||
|
||||
|
||||
def test_unsupported_provider(monkeypatch):
|
||||
with pytest.raises(AIClientError):
|
||||
ask_ai("x", Profile(provider="inconnu", model="m"))
|
||||
|
||||
|
||||
def test_empty_prompt_rejected():
|
||||
with pytest.raises(AIClientError):
|
||||
ask_ai(" ", Profile(provider="ollama"))
|
||||
+67
-38
@@ -1,51 +1,80 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_typewriter.config import load_config
|
||||
from ai_typewriter.config import (
|
||||
ConfigError,
|
||||
ConfigStore,
|
||||
Profile,
|
||||
load_config,
|
||||
math_latex_profile,
|
||||
)
|
||||
|
||||
|
||||
def test_load_default_config(tmp_path):
|
||||
def test_store_creates_defaults_when_missing(tmp_path):
|
||||
path = tmp_path / "nested" / "config.json"
|
||||
store = ConfigStore(path)
|
||||
store.ensure_defaults()
|
||||
|
||||
assert path.exists()
|
||||
assert len(store.profiles) == 2
|
||||
names = [p.name for p in store.profiles]
|
||||
assert "Général" in names
|
||||
assert math_latex_profile().name in names
|
||||
store.load()
|
||||
assert store.active_name in names
|
||||
|
||||
|
||||
def test_default_math_profile_enables_equation_markers():
|
||||
p = math_latex_profile()
|
||||
assert p.equation_enabled is True
|
||||
assert p.eq_start_marker == "[EQ]"
|
||||
assert p.eq_end_marker == "[/EQ]"
|
||||
assert p.eq_start_key == "alt+="
|
||||
assert p.eq_end_key == "right"
|
||||
assert "[EQ]" in p.effective_prompt()
|
||||
|
||||
|
||||
def test_crud_upsert_set_active_and_remove(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "ollama"}), encoding="utf-8")
|
||||
store = ConfigStore(path)
|
||||
store.ensure_defaults()
|
||||
store.load()
|
||||
|
||||
cfg = load_config(path)
|
||||
names_before = len(store.get_all())
|
||||
prof = math_latex_profile(name="MaesProfil")
|
||||
store.upsert(prof)
|
||||
store.set_active("MaesProfil")
|
||||
assert store.active().name == "MaesProfil"
|
||||
assert len(store.get_all()) == names_before + 1
|
||||
|
||||
assert cfg.provider == "ollama"
|
||||
assert cfg.hotkey == "ctrl+alt+a"
|
||||
assert cfg.math_text_format == "plain"
|
||||
assert "Réponds directement" in cfg.system_prompt
|
||||
prof2 = Profile(name="MaesProfil", provider="openai", model="gpt-4o-mini")
|
||||
store.upsert(prof2)
|
||||
assert store.get("MaesProfil").provider == "openai"
|
||||
|
||||
store.remove("MaesProfil")
|
||||
with pytest.raises(KeyError):
|
||||
store.get("MaesProfil")
|
||||
|
||||
|
||||
def test_accept_unicode_format(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "ollama", "math_text_format": "unicode"}), encoding="utf-8")
|
||||
|
||||
cfg = load_config(path)
|
||||
|
||||
assert cfg.math_text_format == "unicode"
|
||||
def test_remove_last_profile_blocked(tmp_path):
|
||||
store = ConfigStore(tmp_path / "config.json")
|
||||
store.ensure_defaults()
|
||||
store.load()
|
||||
# Il y a 2 profils par défaut : le premier retrait réussit…
|
||||
store.remove(store.get_all()[0].name)
|
||||
assert len(store.get_all()) == 1
|
||||
# …mais retirer le dernier est interdit.
|
||||
with pytest.raises(ConfigError):
|
||||
store.remove(store.get_all()[0].name)
|
||||
|
||||
|
||||
def test_timeout_zero_disables_timeout(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "ollama", "request_timeout_seconds": 0}), encoding="utf-8")
|
||||
|
||||
cfg = load_config(path)
|
||||
|
||||
assert cfg.request_timeout_seconds is None
|
||||
def test_set_active_unknown_raises(tmp_path):
|
||||
store = ConfigStore(tmp_path / "config.json")
|
||||
store.ensure_defaults()
|
||||
store.load()
|
||||
with pytest.raises(KeyError):
|
||||
store.set_active("inexistant")
|
||||
|
||||
|
||||
def test_reject_invalid_math_text_format(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "ollama", "math_text_format": "bad"}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_config(path)
|
||||
|
||||
|
||||
def test_reject_invalid_provider(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "bad"}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_config(path)
|
||||
def test_load_config_return_store(tmp_path):
|
||||
store = load_config(str(tmp_path / "config.json"))
|
||||
assert isinstance(store, ConfigStore)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests du stockage sécurisé (keyring mocké pour éviter tout accès au
|
||||
gestionnaire d'identifiants réel de la machine)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_typewriter.credentials import CredentialError, SecureStore, get_cred
|
||||
|
||||
|
||||
class FakeKeyring:
|
||||
"""Mini stub de keyring en mémoire, exposant la même interface."""
|
||||
|
||||
_data = {}
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._data = {}
|
||||
|
||||
@classmethod
|
||||
def set_password(cls, service, username, password):
|
||||
cls._data[(service, username)] = password
|
||||
|
||||
@classmethod
|
||||
def get_password(cls, service, username):
|
||||
return cls._data.get((service, username))
|
||||
|
||||
@classmethod
|
||||
def delete_password(cls, service, username):
|
||||
cls._data.pop((service, username), None)
|
||||
|
||||
|
||||
class FakeKeyringErrors:
|
||||
class PasswordDeleteError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def test_store_and_get(monkeypatch):
|
||||
import ai_typewriter.credentials as cred
|
||||
|
||||
monkeypatch.setattr(cred, "keyring", FakeKeyring)
|
||||
monkeypatch.setattr(cred.keyring, "errors", FakeKeyringErrors, raising=False)
|
||||
FakeKeyring.reset()
|
||||
|
||||
store = SecureStore("test-service")
|
||||
store.store("openai", "sk-secret")
|
||||
assert store.get("openai") == "sk-secret"
|
||||
assert store.has("openai") is True
|
||||
|
||||
|
||||
def test_empty_credential_returns_empty(monkeypatch):
|
||||
import ai_typewriter.credentials as cred
|
||||
|
||||
monkeypatch.setattr(cred, "keyring", FakeKeyring)
|
||||
FakeKeyring.reset()
|
||||
|
||||
store = SecureStore()
|
||||
assert store.get("") == ""
|
||||
assert store.has("") is False
|
||||
|
||||
|
||||
def test_get_cred_helper_creates_store(monkeypatch):
|
||||
import ai_typewriter.credentials as cred
|
||||
|
||||
monkeypatch.setattr(cred, "keyring", FakeKeyring)
|
||||
monkeypatch.setattr(cred.keyring, "errors", FakeKeyringErrors, raising=False)
|
||||
FakeKeyring.reset()
|
||||
|
||||
# via un store passé explicitement
|
||||
store = SecureStore("t")
|
||||
store.store("k", "v")
|
||||
assert get_cred(store, "k") == "v"
|
||||
# via None (crée un store par défaut, mais retombe sur service réel) —
|
||||
# on vérifie que quelques accesseurs ne plantent pas.
|
||||
assert get_cred(None, "") == ""
|
||||
|
||||
|
||||
def test_store_invalid_credential_name(monkeypatch):
|
||||
import ai_typewriter.credentials as cred
|
||||
|
||||
monkeypatch.setattr(cred, "keyring", FakeKeyring)
|
||||
FakeKeyring.reset()
|
||||
|
||||
store = SecureStore()
|
||||
with pytest.raises(CredentialError):
|
||||
store.store("", "secret")
|
||||
|
||||
|
||||
def test_delete(monkeypatch):
|
||||
import ai_typewriter.credentials as cred
|
||||
|
||||
monkeypatch.setattr(cred, "keyring", FakeKeyring)
|
||||
monkeypatch.setattr(cred.keyring, "errors", FakeKeyringErrors, raising=False)
|
||||
FakeKeyring.reset()
|
||||
|
||||
store = SecureStore("t")
|
||||
store.store("gemini", "cle")
|
||||
store.delete("gemini")
|
||||
assert store.has("gemini") is False
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests du moteur : capture -> IA -> dactylographie (tout mocké)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ai_typewriter.config import ConfigStore
|
||||
from ai_typewriter.engine import AITypewriterEngine
|
||||
|
||||
|
||||
def _make_store(tmp_path) -> ConfigStore:
|
||||
store = ConfigStore(tmp_path / "config.json")
|
||||
store.ensure_defaults()
|
||||
store.load()
|
||||
return store
|
||||
|
||||
|
||||
def _fake_keyboard(monkeypatch, typed, sent):
|
||||
hook = {}
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.write",
|
||||
lambda char, delay=0, exact=True: typed.append(char),
|
||||
)
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.send", lambda v: sent.append(v))
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.hook",
|
||||
lambda callback, suppress=True: hook.update({"callback": callback}) or "hook",
|
||||
)
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda v: None)
|
||||
return hook
|
||||
|
||||
|
||||
def test_capture_ask_and_step_types_response(monkeypatch, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
engine = AITypewriterEngine(store)
|
||||
typed, sent = [], []
|
||||
hook = _fake_keyboard(monkeypatch, typed, sent)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.capture_clipboard", lambda: "Question de test"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.ask_ai", lambda prompt, profile, store: "Réponse IA"
|
||||
)
|
||||
|
||||
engine.capture_ask_and_step()
|
||||
assert engine._stepper is not None
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
assert typed == ["R", "é"]
|
||||
|
||||
|
||||
def test_capture_ask_and_step_with_equations(monkeypatch, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
store.set_active("Mathématiques (LaTeX)")
|
||||
engine = AITypewriterEngine(store)
|
||||
typed, sent = [], []
|
||||
hook = _fake_keyboard(monkeypatch, typed, sent)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.capture_clipboard", lambda: "Calcule"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.ask_ai", lambda prompt, profile, store: r"[EQ]a=x[/EQ]"
|
||||
)
|
||||
|
||||
engine.capture_ask_and_step()
|
||||
# 1:[EQ]->alt+= ; a: char ; =: char ; x: char ; [/EQ]->right => 5 actions
|
||||
for _ in range(5):
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert sent == ["alt+=", "right"]
|
||||
assert typed == ["a", "=", "x"]
|
||||
|
||||
|
||||
def test_empty_clipboard_does_not_ask(monkeypatch, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
engine = AITypewriterEngine(store)
|
||||
called = []
|
||||
monkeypatch.setattr("ai_typewriter.engine.capture_clipboard", lambda: " ")
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.ask_ai",
|
||||
lambda prompt, profile, store: called.append(prompt) or "x",
|
||||
)
|
||||
engine.capture_ask_and_step()
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_ask_only_returns_answer(monkeypatch, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
engine = AITypewriterEngine(store)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.engine.ask_ai",
|
||||
lambda prompt, profile, store: "reponse-brute",
|
||||
)
|
||||
assert engine.ask_only("bonjour") == "reponse-brute"
|
||||
|
||||
|
||||
def test_concurrent_hotkey_ignored_while_busy(monkeypatch, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
engine = AITypewriterEngine(store)
|
||||
|
||||
# Occupe le verrou
|
||||
assert engine._busy.acquire(blocking=False) is True
|
||||
calls = []
|
||||
engine.handle_hotkey() # doit être ignoré (busy)
|
||||
assert calls == []
|
||||
engine._busy.release()
|
||||
+130
-42
@@ -1,48 +1,108 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ai_typewriter.key_stepper import KeyStepper
|
||||
from ai_typewriter.key_stepper import Action, KeyStepper, build_actions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_actions : découpage en actions (sans clavier)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plain_text_actions_single_char_per_action():
|
||||
actions = build_actions("abc")
|
||||
assert actions == [
|
||||
Action("char", "a"),
|
||||
Action("char", "b"),
|
||||
Action("char", "c"),
|
||||
]
|
||||
|
||||
|
||||
def test_equation_markers_replaced_by_key_sequences():
|
||||
actions = build_actions(
|
||||
r"Les racines sont [EQ]z_1 = x[/EQ].",
|
||||
equation_enabled=True,
|
||||
)
|
||||
assert Action("seq", "alt+=") in actions
|
||||
assert Action("seq", "right") in actions
|
||||
# aucun marqueur littéral ne doit être tapé
|
||||
assert Action("char", "[") not in actions
|
||||
kinds = [a.kind for a in actions]
|
||||
assert kinds.count("seq") == 2
|
||||
# le contenu LaTeX est conservé caractère par caractère
|
||||
chars = "".join(a.value for a in actions if a.kind == "char")
|
||||
assert "z_1 = x" in chars
|
||||
|
||||
|
||||
def test_multiple_equations_each_get_start_and_end():
|
||||
text = r"[EQ]a[/EQ] et [EQ]b[/EQ]"
|
||||
actions = build_actions(text, equation_enabled=True)
|
||||
seqs = [a.value for a in actions if a.kind == "seq"]
|
||||
assert seqs == ["alt+=", "right", "alt+=", "right"]
|
||||
|
||||
|
||||
def test_equation_disabled_keeps_markers_literal():
|
||||
text = r"[EQ]\frac{a}{b}[/EQ]"
|
||||
assert build_actions(text, equation_enabled=False) == [
|
||||
Action("char", c) for c in text
|
||||
]
|
||||
|
||||
|
||||
def test_custom_markers_and_keys():
|
||||
actions = build_actions(
|
||||
"<<START>>x^2<<END>>",
|
||||
equation_enabled=True,
|
||||
eq_start_marker="<<START>>",
|
||||
eq_end_marker="<<END>>",
|
||||
eq_start_key="ctrl+shift+e",
|
||||
eq_end_key="space",
|
||||
)
|
||||
assert Action("seq", "ctrl+shift+e") in actions
|
||||
assert Action("seq", "space") in actions
|
||||
# marqueurs retirés, non tapés
|
||||
chars = "".join(a.value for a in actions if a.kind == "char")
|
||||
assert chars == "x^2"
|
||||
assert "START" not in chars
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KeyStepper (monkeypatch du hook clavier)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_hook(monkeypatch, typed, sent, unhooked):
|
||||
hook = {}
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.write",
|
||||
lambda char, delay=0, exact=True: typed.append(char),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.send",
|
||||
lambda value: sent.append(value),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.hook",
|
||||
lambda callback, suppress=True: hook.update({"callback": callback}) or "hook",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.unhook", lambda value: unhooked.append(value)
|
||||
)
|
||||
return hook
|
||||
|
||||
|
||||
def test_start_installs_hook_without_typing_immediately(monkeypatch):
|
||||
typed = []
|
||||
hooked = []
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hooked.append((callback, suppress)) or "hook")
|
||||
typed, sent, unhooked = [], [], []
|
||||
_install_hook(monkeypatch, typed, sent, unhooked)
|
||||
|
||||
stepper = KeyStepper("abc")
|
||||
stepper.start()
|
||||
|
||||
assert typed == []
|
||||
assert hooked and hooked[0][1] is True
|
||||
assert typed == [] and sent == []
|
||||
assert stepper.remaining_characters == 3
|
||||
|
||||
|
||||
def test_single_character_response_waits_for_key_then_unhooks(monkeypatch):
|
||||
typed = []
|
||||
hook = {}
|
||||
unhooked = []
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: unhooked.append(value))
|
||||
|
||||
stepper = KeyStepper("x")
|
||||
stepper.start()
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert typed == ["x"]
|
||||
assert unhooked == ["hook"]
|
||||
assert stepper.remaining_characters == 0
|
||||
|
||||
|
||||
def test_each_key_down_types_next_character(monkeypatch):
|
||||
typed = []
|
||||
hook = {}
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None)
|
||||
typed, sent, unhooked = [], [], []
|
||||
hook = _install_hook(monkeypatch, typed, sent, unhooked)
|
||||
|
||||
stepper = KeyStepper("ab")
|
||||
stepper.start()
|
||||
@@ -51,19 +111,47 @@ def test_each_key_down_types_next_character(monkeypatch):
|
||||
|
||||
assert typed == ["a", "b"]
|
||||
assert stepper.remaining_characters == 0
|
||||
assert unhooked # libère le hook à la fin
|
||||
|
||||
|
||||
def test_latex_markers_are_typed_literally_character_by_character(monkeypatch):
|
||||
typed = []
|
||||
hook = {}
|
||||
def test_single_char_response_waits_for_key_then_unhooks(monkeypatch):
|
||||
typed, sent, unhooked = [], [], []
|
||||
hook = _install_hook(monkeypatch, typed, sent, unhooked)
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None)
|
||||
|
||||
stepper = KeyStepper(r"[EQ]\frac{a}{b}[/EQ]")
|
||||
stepper = KeyStepper("x")
|
||||
stepper.start()
|
||||
for _ in range(len(stepper.text)):
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert typed == ["x"] and sent == []
|
||||
assert unhooked == ["hook"]
|
||||
|
||||
|
||||
def test_equation_markers_trigger_key_sequences_while_typing_latex(monkeypatch):
|
||||
typed, sent, unhooked = [], [], []
|
||||
hook = _install_hook(monkeypatch, typed, sent, unhooked)
|
||||
|
||||
stepper = KeyStepper(
|
||||
r"[EQ]a=x[/EQ]",
|
||||
equation_enabled=True,
|
||||
eq_start_key="alt+=",
|
||||
eq_end_key="right",
|
||||
)
|
||||
stepper.start()
|
||||
# 1: [EQ] -> Alt+= ; 2-4: a, =, x ; 5: [/EQ] -> right
|
||||
for _ in range(5):
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert "".join(typed) == r"[EQ]\frac{a}{b}[/EQ]"
|
||||
assert sent == ["alt+=", "right"]
|
||||
assert typed == ["a", "=", "x"]
|
||||
assert stepper.remaining_characters == 0
|
||||
|
||||
|
||||
def test_empty_text_does_not_install_hook(monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.key_stepper.keyboard.hook",
|
||||
lambda callback, suppress=True: called.append(1),
|
||||
)
|
||||
stepper = KeyStepper("")
|
||||
stepper.start()
|
||||
assert called == []
|
||||
@@ -1,18 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from ai_typewriter.math_format import format_math_text
|
||||
|
||||
|
||||
def test_plain_mode_keeps_latex_and_markers_literal():
|
||||
assert format_math_text(r"[EQ]\frac{a}{b}[/EQ]", "plain") == r"[EQ]\frac{a}{b}[/EQ]"
|
||||
|
||||
|
||||
def test_unicode_mode_converts_indices_exponents_and_symbols():
|
||||
result = format_math_text(r"z_1 + x^2 + x_(i+1) + \alpha + \infty", "unicode")
|
||||
|
||||
assert result == "z₁ + x² + xᵢ₊₁ + α + ∞"
|
||||
|
||||
|
||||
def test_invalid_math_text_format_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
format_math_text("x_1", "bad")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests de l'actuaire des modèles : listage local, catalogue en ligne et
|
||||
téléchargement (le tout mocké, sans accéder au réseau ni à la CLI ollama)."""
|
||||
|
||||
from ai_typewriter import model_catalog
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload=None, status=200):
|
||||
self.payload = payload
|
||||
self.status_code = status
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_list_local_models_parses_names(monkeypatch):
|
||||
def fake_get(url, timeout, **kwargs):
|
||||
return FakeResponse(
|
||||
{"models": [{"name": "llama3.1"}, {"model": "mistral"}, {"name": "llama3.1"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.model_catalog.requests.get", fake_get)
|
||||
|
||||
names = model_catalog.list_local_models("http://local:11434")
|
||||
assert names == ["llama3.1", "mistral"] # triés + dédupliqués
|
||||
|
||||
|
||||
def test_list_local_models_returns_empty_on_error(monkeypatch):
|
||||
import requests
|
||||
|
||||
def fake_get(url, timeout, **kwargs):
|
||||
raise requests.ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.model_catalog.requests.get", fake_get)
|
||||
assert model_catalog.list_local_models() == []
|
||||
|
||||
|
||||
def test_search_online_filters_by_query():
|
||||
results = model_catalog.search_online_models("llama")
|
||||
assert results
|
||||
assert all("llama" in m.name for m in results)
|
||||
assert all(m.source == "registry" for m in results)
|
||||
|
||||
|
||||
def test_provider_info():
|
||||
info = model_catalog.provider_info("openai")
|
||||
assert info["needs_key"] is True
|
||||
info_ollama = model_catalog.provider_info("ollama")
|
||||
assert info_ollama["needs_key"] is False
|
||||
|
||||
|
||||
def test_resolve_exact_model_true_when_200(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.model_catalog.requests.get", lambda url, timeout: FakeResponse(status=200)
|
||||
)
|
||||
assert model_catalog.resolve_exact_model("llama3.1") is True
|
||||
|
||||
|
||||
def test_resolve_exact_model_false_when_notfound(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.model_catalog.requests.get", lambda url, timeout: FakeResponse(status=404)
|
||||
)
|
||||
assert model_catalog.resolve_exact_model("n-existe-pas") is False
|
||||
|
||||
|
||||
def test_pull_model_uses_cli_when_available(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.model_catalog.shutil.which", lambda name: "/usr/bin/ollama")
|
||||
monkeypatch.setattr("ai_typewriter.model_catalog.subprocess.Popen", FakePopen)
|
||||
|
||||
model_catalog.pull_model("llama3.1", server_url="http://local")
|
||||
assert calls and calls[0] == ["/usr/bin/ollama", "pull", "llama3.1"]
|
||||
|
||||
|
||||
def test_has_internet_true(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"ai_typewriter.model_catalog.requests.head",
|
||||
lambda url, timeout, allow_redirects: FakeResponse(status=200),
|
||||
)
|
||||
assert model_catalog.has_internet() is True
|
||||
Reference in New Issue
Block a user