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:
Hermes Agent
2026-09-18 09:48:46 +02:00
parent d07bed60e6
commit 3e58380f80
30 changed files with 2461 additions and 491 deletions
+106
View File
@@ -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()