mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
La vérification du rendu du gabarit après une mise à jour attend jusqu'à vingt minutes que le nouveau moteur réponde. Un retour à la version précédente, une autre version installée ou une seconde mise à jour pendant cette attente la laissaient courir : elle comparait alors l'ancien moteur à celui d'une autre bascule, ou écrasait le relevé de la suivante. - Chaque bascule ouvre une génération ; une vérification périmée s'arrête sans rien ranger, et son relevé n'écrase plus le suivant. Retour et « utiliser » effacent le relevé, qui parlerait d'un moteur arrêté. - /api/engine/plan (registre) et /api/engine/rollback (configuration et redémarrage) refusent tout autre verbe que POST — l'interface n'utilise que POST. - Tests : vérification annulée en attente, relevé périmé ignoré, GET refusé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
448 lines
17 KiB
Go
448 lines
17 KiB
Go
package loki
|
|
|
|
// Version recommandée du moteur, retour à la version précédente, et garde du
|
|
// rendu des gabarits à travers une mise à jour (web_engine_reco.go).
|
|
//
|
|
// Ce qui se joue : ne JAMAIS proposer une version qu'on n'a pas vue publiée,
|
|
// ne rien faire sans réseau d'autre que le dire, garder un chemin de retour
|
|
// sans réseau, et ne pas laisser une mise à jour changer en silence le prompt
|
|
// que voit le modèle.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Le cas nominal : le dernier build publié dépasse le minimum, et c'est son tag
|
|
// FIGÉ qui est proposé (celui qu'on installera), pas le tag mouvant.
|
|
func TestVersionRecommandeeChoixDuTag(t *testing.T) {
|
|
fauxRegistre(t, "b11351", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
|
build, tag, err := engineRecommendedTag("server-cuda")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if build != 11351 || tag != "server-cuda-b11351" {
|
|
t.Errorf("build=%d tag=%q, attendu 11351 / server-cuda-b11351", build, tag)
|
|
}
|
|
}
|
|
|
|
// Un registre dont le dernier build est encore sous le minimum, ou qui ne dit
|
|
// pas son numéro : rien n'est proposé, et l'erreur dit pourquoi.
|
|
func TestVersionRecommandeeRefusee(t *testing.T) {
|
|
for _, c := range []struct{ nom, version, attendu string }{
|
|
{"trop ancienne", "b10700", "antérieur"},
|
|
{"sans numéro", "", "n'annonce pas le build"},
|
|
} {
|
|
t.Run(c.nom, func(t *testing.T) {
|
|
fauxRegistre(t, c.version, [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
|
_, tag, err := engineRecommendedTag("server-cuda")
|
|
if err == nil || !strings.Contains(err.Error(), c.attendu) {
|
|
t.Fatalf("tag=%q err=%v, attendu une erreur « %s »", tag, err, c.attendu)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// L'annotation du tag mouvant annonce un build dont le tag figé n'existe pas
|
|
// (encore) : on ne propose pas un tag introuvable.
|
|
func TestVersionRecommandeeTagFigeAbsent(t *testing.T) {
|
|
index, _ := json.Marshal(map[string]any{
|
|
"schemaVersion": 2,
|
|
"layers": []map[string]any{{"digest": "sha256:x", "size": 1}},
|
|
"annotations": map[string]string{"org.opencontainers.image.version": "b11400"},
|
|
})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/token"):
|
|
_, _ = w.Write([]byte(`{"token":"t"}`))
|
|
case strings.HasSuffix(r.URL.Path, "/manifests/server-cuda"):
|
|
_, _ = w.Write(index)
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
old := ociBase
|
|
ociBase = srv.URL
|
|
defer func() { ociBase = old }()
|
|
|
|
if _, tag, err := engineRecommendedTag("server-cuda"); err == nil || !strings.Contains(err.Error(), "introuvable") {
|
|
t.Fatalf("tag=%q err=%v, attendu « introuvable »", tag, err)
|
|
}
|
|
}
|
|
|
|
// Sans réseau : une erreur lisible, aucun tag, rien d'installé.
|
|
func TestVersionRecommandeeSansReseau(t *testing.T) {
|
|
testHome(t)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
srv.Close() // port fermé : connexion refusée
|
|
old := ociBase
|
|
ociBase = srv.URL
|
|
defer func() { ociBase = old }()
|
|
|
|
_, tag, err := engineRecommendedTag("server-cuda")
|
|
if err == nil || tag != "" || !strings.Contains(err.Error(), "registre injoignable") {
|
|
t.Fatalf("tag=%q err=%v, attendu « registre injoignable »", tag, err)
|
|
}
|
|
if got := engineInstalled(); len(got) != 0 {
|
|
t.Errorf("versions installées sans réseau : %v", got)
|
|
}
|
|
}
|
|
|
|
// L'encart : seulement pour un build de confiance sous le minimum, là où les
|
|
// images officielles existent. Les gains cités nomment leurs builds.
|
|
func TestEncartVersionRecommandee(t *testing.T) {
|
|
r := engineRecommendation(10678, true)
|
|
if r == nil {
|
|
t.Fatal("pas d'encart pour b10678")
|
|
}
|
|
gains := strings.Join(r["gains"].([]string), "\n")
|
|
for _, want := range []string{"b10864", "b11009", "b11331", "points de reprise"} {
|
|
if !strings.Contains(gains, want) {
|
|
t.Errorf("gains sans « %s » : %s", want, gains)
|
|
}
|
|
}
|
|
for _, c := range []struct {
|
|
build int
|
|
ok bool
|
|
}{{engineMinRecommended, true}, {11351, true}, {0, true}, {10678, false}} {
|
|
if engineRecommendation(c.build, c.ok) != nil {
|
|
t.Errorf("encart pour build=%d supporté=%v", c.build, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Retour arrière : la version quittée est notée, proposée tant qu'elle existe,
|
|
// et le retour fait de la version quittée à son tour « la précédente ».
|
|
func TestRetourVersionPrecedente(t *testing.T) {
|
|
testHome(t)
|
|
mk := func(tag string) string {
|
|
d := filepath.Join(engineDir(), tag)
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(d, "llama-server")
|
|
if err := os.WriteFile(p, []byte("x"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
ancien, nouveau := mk("server-cuda-b10678"), mk("server-cuda-b11351")
|
|
|
|
if enginePrevious(nouveau) != nil {
|
|
t.Fatal("version précédente annoncée avant toute bascule")
|
|
}
|
|
// Le ménage d'après mise à jour garde bien l'ancienne (lot 1).
|
|
if keep := engineKeepAfterUpdate(nouveau, ancien, nil); !keep["server-cuda-b10678"] {
|
|
t.Fatalf("ménage : l'ancienne version n'est pas gardée (%v)", keep)
|
|
}
|
|
engineRememberPrevious(ancien, nouveau)
|
|
prev := enginePrevious(nouveau)
|
|
if prev == nil || prev["tag"] != "server-cuda-b10678" || prev["build"] != 10678 {
|
|
t.Fatalf("précédente = %v", prev)
|
|
}
|
|
// Ne jamais proposer de « revenir » sur le moteur qui tourne.
|
|
if enginePrevious(ancien) != nil {
|
|
t.Error("la version courante proposée comme précédente")
|
|
}
|
|
|
|
// Le retour par la route : BIN revient, et la version quittée devient la
|
|
// précédente (deux clics ramènent où l'on était).
|
|
if err := SetConfigKey("BIN", nouveau); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}")))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("rollback : HTTP %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := ReadConfig()["BIN"]; !samePath(got, ancien) {
|
|
t.Fatalf("BIN = %q après retour, attendu %q", got, ancien)
|
|
}
|
|
if p := enginePrevious(ancien); p == nil || p["tag"] != "server-cuda-b11351" {
|
|
t.Errorf("après retour, précédente = %v", p)
|
|
}
|
|
|
|
// Une version supprimée à la main : plus de bouton, et la route refuse.
|
|
if err := os.RemoveAll(filepath.Dir(nouveau)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if enginePrevious(ancien) != nil {
|
|
t.Error("version supprimée encore proposée")
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}")))
|
|
if rec.Code != 400 {
|
|
t.Errorf("rollback sans précédente : HTTP %d, attendu 400", rec.Code)
|
|
}
|
|
// Les deux routes nouvelles changent la configuration ou interrogent le
|
|
// registre : jamais sur un GET.
|
|
for _, h := range []http.HandlerFunc{handleEngineRollback, handleEnginePlan} {
|
|
rec = httptest.NewRecorder()
|
|
h(rec, httptest.NewRequest("GET", "/api/engine/x", nil))
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("GET : HTTP %d, attendu 405", rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Une bascule plus récente périme la vérification du rendu encore en attente
|
|
// de son moteur : elle s'arrête sans rien ranger, et son relevé n'écrase pas
|
|
// celui de la bascule suivante.
|
|
func TestVerificationDuRenduPerimee(t *testing.T) {
|
|
testHome(t)
|
|
freshTplProbe(t)
|
|
oldPoll, oldWait := engineRenderPoll, engineRenderWait
|
|
engineRenderPoll, engineRenderWait = time.Millisecond, 10*time.Second
|
|
t.Cleanup(func() {
|
|
engineRenderPoll, engineRenderWait = oldPoll, oldWait
|
|
engineRenderMu.Lock()
|
|
engineRenderLast = nil
|
|
engineRenderMu.Unlock()
|
|
})
|
|
f := &fakeTpl{mode: "qwen3", build: "b10678-old"}
|
|
f.start(t)
|
|
ref, ok := engineRenderBefore(context.Background())
|
|
if !ok {
|
|
t.Fatal("rendu de référence impossible")
|
|
}
|
|
// L'ancien moteur répond toujours : la vérification attend.
|
|
done := make(chan struct{})
|
|
go func() { engineRenderAfter(ref, "server-cuda-b11351"); close(done) }()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if c := engineRenderSnapshot(); c != nil && c.Status == "pending" {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("vérification jamais commencée")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
// Retour à la version précédente : vérification périmée, relevé effacé.
|
|
engineRenderCancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("vérification périmée toujours en attente")
|
|
}
|
|
if c := engineRenderSnapshot(); c != nil {
|
|
t.Errorf("relevé d'une bascule annulée : %+v", c)
|
|
}
|
|
// Un relevé d'une génération passée n'écrase rien.
|
|
gen := engineRenderBegin()
|
|
engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: "neuf"})
|
|
engineRenderSet(gen-1, engineRenderCheck{Status: "changed", Tag: "ancien"})
|
|
if c := engineRenderSnapshot(); c == nil || c.Tag != "neuf" {
|
|
t.Errorf("relevé écrasé par une vérification périmée : %+v", c)
|
|
}
|
|
}
|
|
|
|
// Le verdict « le prompt rendu va changer » : seulement quand c'est établi
|
|
// (gabarit qui lit preserve_thinking ET retire aujourd'hui la réflexion), et
|
|
// jamais de case cochée d'office ailleurs — sur Qwen3.8, off changerait le rendu.
|
|
func TestAvertissementRenduRaisonnement(t *testing.T) {
|
|
qwen36 := "{%- if preserve_thinking is defined and preserve_thinking %}…"
|
|
base := preserveIn{Current: 10678, Target: 11351, Template: qwen36, TplKnown: true, Preserves: tplNo}
|
|
for _, c := range []struct {
|
|
nom string
|
|
mod func(*preserveIn)
|
|
risk string
|
|
suggest bool
|
|
}{
|
|
{"Qwen3.6 sur moteur ancien", func(*preserveIn) {}, "yes", true},
|
|
{"Qwen3.8 garde déjà", func(in *preserveIn) { in.Preserves = tplYes }, "no", false},
|
|
{"gabarit sans la variable", func(in *preserveIn) { in.Template = "{{ messages }}" }, "no", false},
|
|
{"clear_thinking (GLM)", func(in *preserveIn) { in.Template = "{% if clear_thinking %}" }, "yes", true},
|
|
{"sonde sans verdict", func(in *preserveIn) { in.Preserves = tplUnknown }, "unknown", false},
|
|
{"moteur arrêté", func(in *preserveIn) { in.TplKnown, in.Template = false, "" }, "unknown", false},
|
|
{"clé posée", func(in *preserveIn) { in.Key = "on" }, "no", false},
|
|
{"EXTRA_ARGS", func(in *preserveIn) { in.Overridden = "EXTRA_ARGS" }, "no", false},
|
|
{"cible avant b10763", func(in *preserveIn) { in.Target = 10700 }, "no", false},
|
|
{"cible inconnue", func(in *preserveIn) { in.Target = 0 }, "yes", true},
|
|
{"moteur courant déjà ≥ b10763", func(in *preserveIn) { in.Current = 10800 }, "no", false},
|
|
{"preset externe", func(in *preserveIn) { in.External = true }, "no", false},
|
|
} {
|
|
t.Run(c.nom, func(t *testing.T) {
|
|
in := base
|
|
c.mod(&in)
|
|
r := reasoningPreserveRisk(in)
|
|
if r.Risk != c.risk || r.Suggest != c.suggest || r.Why == "" {
|
|
t.Errorf("= %+v, attendu risk=%s suggest=%v", r, c.risk, c.suggest)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Les réglages qui fixent déjà preserve_reasoning hors de la clé.
|
|
func TestPreserveDejaRegle(t *testing.T) {
|
|
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "")
|
|
t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "")
|
|
if got := preserveOverridden(map[string]string{"EXTRA_ARGS": "--no-reasoning-preserve -c 4096"}); got != "EXTRA_ARGS" {
|
|
t.Errorf("EXTRA_ARGS : %q", got)
|
|
}
|
|
if got := preserveOverridden(map[string]string{"EXTRA_ARGS": `--chat-template-kwargs {"preserve_reasoning":false}`}); got != "--chat-template-kwargs" {
|
|
t.Errorf("kwargs : %q", got)
|
|
}
|
|
if got := preserveOverridden(map[string]string{}); got != "" {
|
|
t.Errorf("rien de posé : %q", got)
|
|
}
|
|
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "0")
|
|
if got := preserveOverridden(map[string]string{}); got != "LLAMA_ARG_REASONING_PRESERVE" {
|
|
t.Errorf("variable : %q", got)
|
|
}
|
|
}
|
|
|
|
// Les lectures du verdict sur un vrai (faux) moteur : gabarit et sonde.
|
|
func TestAvertissementRenduSurLeMoteur(t *testing.T) {
|
|
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "")
|
|
t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "")
|
|
for _, c := range []struct{ nom, mode, tpl, risk string }{
|
|
{"retire la réflexion", "qwen3", "{% if preserve_thinking %}", "yes"},
|
|
{"la garde déjà", "keep", "{% if preserve_thinking is not defined or preserve_thinking %}", "no"},
|
|
{"ne lit pas la variable", "qwen3", "", "no"},
|
|
} {
|
|
t.Run(c.nom, func(t *testing.T) {
|
|
testHome(t)
|
|
freshTplProbe(t)
|
|
(&fakeTpl{mode: c.mode, tpl: c.tpl}).start(t)
|
|
r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351))
|
|
if r.Risk != c.risk {
|
|
t.Errorf("risk=%s (%s), attendu %s", r.Risk, r.Why, c.risk)
|
|
}
|
|
})
|
|
}
|
|
t.Run("moteur arrêté", func(t *testing.T) {
|
|
testHome(t)
|
|
freshTplProbe(t)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
u, _ := url.Parse(srv.URL)
|
|
srv.Close()
|
|
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351)); r.Risk != "unknown" || r.Suggest {
|
|
t.Errorf("= %+v, attendu unknown sans case cochée", r)
|
|
}
|
|
})
|
|
}
|
|
|
|
// REASONING_PRESERVE=off, choisi par l'utilisateur : dans le preset actif (il
|
|
// survit aux bascules), jamais par-dessus une valeur déjà posée, et pas du tout
|
|
// sur un moteur qui ne connaît pas le drapeau.
|
|
func TestPoserPreserveOff(t *testing.T) {
|
|
testHome(t)
|
|
dir := presetsDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "qwen.env"), []byte("# NAME=Qwen\nMODEL=q.gguf\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
setConfig(t, "MODEL=q.gguf\n")
|
|
oui := func() bool { return true }
|
|
|
|
if _, err := enginePreserveOffIf(func() bool { return false }); err == nil {
|
|
t.Fatal("posée alors que le moteur ne connaît pas --no-reasoning-preserve")
|
|
}
|
|
if ReadConfig()["REASONING_PRESERVE"] != "" {
|
|
t.Fatal("clé écrite malgré le refus")
|
|
}
|
|
if _, err := enginePreserveOffIf(oui); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := os.ReadFile(filepath.Join(dir, "qwen.env"))
|
|
if parseEnv(string(b))["REASONING_PRESERVE"] != "off" || ReadConfig()["REASONING_PRESERVE"] != "off" {
|
|
t.Fatalf("preset=%q config=%q", b, ReadConfig()["REASONING_PRESERVE"])
|
|
}
|
|
|
|
// Déjà posée (à on) : on ne contredit pas un choix antérieur.
|
|
setConfig(t, "MODEL=autre.gguf\nREASONING_PRESERVE=on\n")
|
|
if msg, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "on" {
|
|
t.Fatalf("msg=%q err=%v valeur=%q", msg, err, ReadConfig()["REASONING_PRESERVE"])
|
|
}
|
|
|
|
// Aucun preset ne correspond : la configuration active.
|
|
setConfig(t, "MODEL=sans-preset.gguf\n")
|
|
if _, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "off" {
|
|
t.Fatalf("err=%v valeur=%q", err, ReadConfig()["REASONING_PRESERVE"])
|
|
}
|
|
}
|
|
|
|
// La comparaison des rendus : identique, ou le premier écart, avec la
|
|
// conversation où il se trouve.
|
|
func TestComparaisonDesRendus(t *testing.T) {
|
|
a := tplRenderSep + "historique sans raisonnement ---\n<|im_start|>assistant\nloki-sonde-reponse-c1<|im_end|>\n"
|
|
if same, d := tplRenderDiff(a, a); !same || d != "" {
|
|
t.Fatalf("identiques : same=%v d=%q", same, d)
|
|
}
|
|
b := strings.Replace(a, "assistant\n", "assistant\n<think>\n\n</think>\n\n", 1)
|
|
same, d := tplRenderDiff(a, b)
|
|
if same || !strings.Contains(d, "historique sans raisonnement") || !strings.Contains(d, "<think>") {
|
|
t.Fatalf("same=%v d=%q", same, d)
|
|
}
|
|
// Un extrait ne coupe jamais un caractère.
|
|
_, d = tplRenderDiff(a+"é", a+"è")
|
|
if !strings.Contains(d, "é") || !strings.Contains(d, "è") {
|
|
t.Errorf("extrait coupé : %q", d)
|
|
}
|
|
}
|
|
|
|
// Après la bascule : le rendu relevé sur le nouveau moteur est comparé à celui
|
|
// d'avant, et l'écart est signalé dans l'état du panneau. Tant que le moteur
|
|
// qui répond annonce l'ancien build, rien n'est comparé.
|
|
func TestVerificationDuRenduApresBascule(t *testing.T) {
|
|
testHome(t)
|
|
freshTplProbe(t)
|
|
oldPoll, oldWait := engineRenderPoll, engineRenderWait
|
|
engineRenderPoll = time.Millisecond
|
|
t.Cleanup(func() {
|
|
engineRenderPoll, engineRenderWait = oldPoll, oldWait
|
|
engineRenderMu.Lock()
|
|
engineRenderLast = nil
|
|
engineRenderMu.Unlock()
|
|
})
|
|
f := &fakeTpl{mode: "qwen3", build: "b10678-old"}
|
|
f.start(t)
|
|
ref, ok := engineRenderBefore(context.Background())
|
|
if !ok || ref.build != "b10678-old" {
|
|
t.Fatalf("rendu de référence : ok=%v build=%q", ok, ref.build)
|
|
}
|
|
set := func(mode, build string) {
|
|
f.mu.Lock()
|
|
f.mode, f.build = mode, build
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// L'ancien moteur répond encore : pas de comparaison, « non vérifié ».
|
|
engineRenderWait = 50 * time.Millisecond
|
|
engineRenderAfter(ref, "server-cuda-b11351")
|
|
if c := engineRenderSnapshot(); c == nil || c.Status != "unknown" {
|
|
t.Fatalf("ancien moteur toujours là : %+v", c)
|
|
}
|
|
engineRenderWait = 10 * time.Second
|
|
|
|
// Nouveau build, même gabarit : identique.
|
|
set("qwen3", "b11351-new")
|
|
engineRenderAfter(ref, "server-cuda-b11351")
|
|
if c := engineRenderSnapshot(); c == nil || c.Status != "same" {
|
|
t.Fatalf("même gabarit : %+v", c)
|
|
}
|
|
// Le moteur garde désormais la réflexion des tours passés : changé.
|
|
set("keep", "b11351-new")
|
|
engineRenderAfter(ref, "server-cuda-b11351")
|
|
c := engineRenderSnapshot()
|
|
if c == nil || c.Status != "changed" || !strings.Contains(c.Detail, "historique avec raisonnement") {
|
|
t.Fatalf("gabarit changé : %+v", c)
|
|
}
|
|
f.assertNoCompletion(t)
|
|
}
|