mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc installe. Trois briques : - module github.com/nathaninline/ajean, cmd/ajean, internal/ajean, package ajean. Purement interne. - AjeanHome() remplace JeanHome(). Les defauts deviennent %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant au premier lancement. La migration est un os.Rename dans le dossier parent de l'ancien chemin : intra-volume, donc atomique et instantane meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue (handle ouvert sous Windows, droits, volume en lecture seule) on CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais de suppression, le pire cas est "rien n'a bouge". $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que /etc/default/ajean et /etc/default/jean : un chemin impose a la main est un ordre, on ne le migre pas. - l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>, dans cet ordre. Indispensable dans les deux sens : les binaires deja installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir s'installer depuis une release anterieure au renommage. Couvert par des tests, dont le repli quand un handle ouvert bloque le rename sous Windows.
181 lines
6.0 KiB
Go
181 lines
6.0 KiB
Go
package ajean
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newTestConv crée une conversation isolée (le global `conv` sert au process réel).
|
|
func newTestConv() *Conversation {
|
|
c := &Conversation{}
|
|
c.cond = sync.NewCond(&c.mu)
|
|
return c
|
|
}
|
|
|
|
// Subscribe doit rejouer les événements déjà journalisés PUIS suivre le direct,
|
|
// et se terminer quand le contexte (la connexion) est annulé.
|
|
func TestSubscribeReplayAndLive(t *testing.T) {
|
|
c := newTestConv()
|
|
c.appendDelta(c.epoch, map[string]any{"user": "salut"})
|
|
c.appendDelta(c.epoch, map[string]any{"content": "bon"})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
got := make(chan int, 32)
|
|
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
|
if s, ok := m["seq"].(int); ok {
|
|
got <- s
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Les 2 événements déjà présents (replay).
|
|
waitSeq(t, got, 1)
|
|
waitSeq(t, got, 2)
|
|
// Un événement en direct après abonnement.
|
|
c.appendDelta(c.epoch, map[string]any{"content": "jour"})
|
|
waitSeq(t, got, 3)
|
|
}
|
|
|
|
// Un abonné qui démarre à from=N ne reçoit que ce qui est plus récent que N.
|
|
func TestSubscribeFromOffset(t *testing.T) {
|
|
c := newTestConv()
|
|
c.appendDelta(c.epoch, map[string]any{"user": "a"})
|
|
c.appendDelta(c.epoch, map[string]any{"user": "b"})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
got := make(chan int, 8)
|
|
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
|
|
if s, ok := m["seq"].(int); ok {
|
|
got <- s
|
|
}
|
|
return true
|
|
})
|
|
// from=1 → on saute le seq 1, on reçoit 2 en premier.
|
|
waitSeq(t, got, 2)
|
|
}
|
|
|
|
// Reset bump l'epoch et pousse un {reset:true} aux abonnés, qui repartent de 0.
|
|
func TestResetNotifiesSubscribers(t *testing.T) {
|
|
c := newTestConv()
|
|
c.appendDelta(c.epoch, map[string]any{"user": "x"})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
resetSeen := make(chan bool, 4)
|
|
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
|
if _, ok := m["reset"]; ok {
|
|
resetSeen <- true
|
|
}
|
|
return true
|
|
})
|
|
// laisse l'abonné consommer le replay initial
|
|
time.Sleep(20 * time.Millisecond)
|
|
c.Reset()
|
|
select {
|
|
case <-resetSeen:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("l'abonné n'a pas reçu l'événement reset")
|
|
}
|
|
if c.Seq != 0 || len(c.Log) != 0 {
|
|
t.Fatalf("après Reset: Seq=%d len(Log)=%d, attendu 0/0", c.Seq, len(c.Log))
|
|
}
|
|
}
|
|
|
|
// Un Reset survenu pendant un tour invalide l'epoch : les deltas du tour en
|
|
// cours sont jetés au lieu de polluer la nouvelle conversation (Seq repartis
|
|
// de zéro, messages fantômes).
|
|
func TestAppendDeltaStaleEpochDropped(t *testing.T) {
|
|
c := newTestConv()
|
|
epoch := c.epoch // capturé comme au début d'un tour
|
|
c.appendDelta(epoch, map[string]any{"user": "avant"})
|
|
c.Reset()
|
|
c.appendDelta(epoch, map[string]any{"content": "fantôme"}) // tour périmé
|
|
if c.Seq != 0 || len(c.Log) != 0 {
|
|
t.Fatalf("delta périmé accepté après Reset: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
|
|
}
|
|
c.appendDelta(c.epoch, map[string]any{"user": "nouveau"}) // nouveau tour
|
|
if c.Seq != 1 || len(c.Log) != 1 {
|
|
t.Fatalf("delta du nouvel epoch refusé: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
|
|
}
|
|
}
|
|
|
|
// Les handlers de contrôle répondent en JSON sans dépendre du modèle.
|
|
func TestChatControlHandlers(t *testing.T) {
|
|
// reset → conversation vide
|
|
rr := httptest.NewRecorder()
|
|
handleChatReset(rr, httptest.NewRequest("POST", "/api/chat/reset", nil))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("reset code %d", rr.Code)
|
|
}
|
|
// state → seq 0, pas de génération
|
|
rr = httptest.NewRecorder()
|
|
handleChatState(rr, httptest.NewRequest("GET", "/api/chat/state", nil))
|
|
if rr.Code != 200 || !strings.Contains(rr.Body.String(), "\"seq\":0") {
|
|
t.Fatalf("state inattendu: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
// send message vide → 400
|
|
rr = httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":""}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
handleChatSend(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("send vide devrait être 400, obtenu %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func waitSeq(t *testing.T, ch <-chan int, want int) {
|
|
t.Helper()
|
|
select {
|
|
case s := <-ch:
|
|
if s != want {
|
|
t.Fatalf("seq reçu %d, attendu %d", s, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timeout en attendant seq %d", want)
|
|
}
|
|
}
|
|
|
|
// Régression : un client qui a déjà affiché une PARTIE d'un bloc de texte et qui se
|
|
// reconnecte APRÈS la compaction de fin de tour recevait le bloc entier sans savoir
|
|
// qu'il en avait déjà le début → la réponse s'affichait en double (1re copie
|
|
// tronquée à l'endroit exact où le client en était). Le bloc coalescé doit porter
|
|
// `seq0` et, quand `from` tombe dedans, l'indicateur `replace`.
|
|
func TestReplayMarksReplaceWhenClientSawPartOfBlock(t *testing.T) {
|
|
c := newTestConv()
|
|
c.appendDelta(c.epoch, map[string]any{"user": "salut"}) // seq 1
|
|
c.appendDelta(c.epoch, map[string]any{"content": "Oui, "}) // seq 2
|
|
c.appendDelta(c.epoch, map[string]any{"content": "j'ai "}) // seq 3
|
|
c.appendDelta(c.epoch, map[string]any{"content": "accès"}) // seq 4
|
|
c.mu.Lock()
|
|
c.compactLogLocked() // fin de tour : les 3 deltas deviennent UN événement seq 4
|
|
c.mu.Unlock()
|
|
|
|
// Le client avait vu jusqu'au seq 3 (milieu du bloc) : il doit recevoir le bloc
|
|
// entier AVEC replace, pour remplacer sa bulle au lieu d'y concaténer.
|
|
out := coalesceReplay(c.Log, 3)
|
|
if len(out) != 1 {
|
|
t.Fatalf("attendu 1 événement rejoué, obtenu %d (%v)", len(out), out)
|
|
}
|
|
if got := out[0]["content"]; got != "Oui, j'ai accès" {
|
|
t.Fatalf("texte rejoué = %q, attendu le bloc entier", got)
|
|
}
|
|
if r, _ := out[0]["replace"].(bool); !r {
|
|
t.Fatalf("replace absent alors que le client avait déjà affiché le début du bloc")
|
|
}
|
|
|
|
// Client qui n'a rien vu du bloc : pas de replace (il doit juste l'ajouter).
|
|
out = coalesceReplay(c.Log, 1)
|
|
if len(out) != 1 {
|
|
t.Fatalf("attendu 1 événement, obtenu %d", len(out))
|
|
}
|
|
if _, ok := out[0]["replace"]; ok {
|
|
t.Fatalf("replace ne doit PAS être posé quand le client n'a rien vu du bloc")
|
|
}
|
|
}
|