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
+92 -8
View File
@@ -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
View File
@@ -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)
+97
View File
@@ -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
+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()
+130 -42
View File
@@ -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 == []
-18
View File
@@ -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")
+90
View File
@@ -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