Files
Loki/internal/loki/tasks_cron_test.go
T
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00

143 lines
4.3 KiB
Go

package loki
import (
"testing"
"time"
)
func TestNextAfterInterval(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
got, err := nextAfter("@every 2h", "", from)
if err != nil {
t.Fatalf("erreur inattendue : %v", err)
}
want := from.Add(2 * time.Hour)
if !got.Equal(want) {
t.Fatalf("@every 2h : got %v, want %v", got, want)
}
// Casse tolérée sur le préfixe.
if _, err := nextAfter("@EVERY 30m", "", from); err != nil {
t.Fatalf("@EVERY 30m devrait être valide : %v", err)
}
// Sous la minute → refusé.
if _, err := nextAfter("@every 10s", "", from); err == nil {
t.Fatalf("@every 10s aurait dû être refusé")
}
if _, err := nextAfter("@every pouet", "", from); err == nil {
t.Fatalf("durée invalide aurait dû être refusée")
}
}
func TestNextEveryDays(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
// Sans ancre horaire : intervalle glissant de N*24h.
got, err := nextAfter("@every 3d", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
if want := from.Add(72 * time.Hour); !got.Equal(want) {
t.Fatalf("@every 3d : got %v, want %v", got, want)
}
}
func TestNextEveryDayAtTime(t *testing.T) {
// « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août).
from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris
got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
// 23:00 Paris le 15 = 21:00 UTC.
want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want)
}
// Après 23:00 Paris → le lendemain.
from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris
got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2)
want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC)
if !got2.Equal(want2) {
t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2)
}
}
func TestNextCronEveryQuarter(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC)
got, err := nextAfter("*/15 * * * *", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("*/15 : got %v, want %v", got, want)
}
}
func TestNextCronWeekdayMorning(t *testing.T) {
// 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00.
from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * 1-5", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want)
}
}
func TestNextCronInParisTZ(t *testing.T) {
// « 0 9 * * * » à Paris = 07:00 UTC en été.
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * *", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want)
}
}
func TestNextCronSundayBothForms(t *testing.T) {
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi
got, err := nextAfter("0 0 * * 7", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("dow=7 : got %v, want %v", got, want)
}
}
func TestParseCronInvalid(t *testing.T) {
cases := []string{
"",
"* * * *", // 4 champs
"* * * * * *", // 6 champs
"60 * * * *", // minute hors bornes
"* 24 * * *", // heure hors bornes
"5-1 * * * *", // plage inversée
"*/0 * * * *", // pas nul
"abc * * * *", // non numérique
"@every", // durée absente
"@every 1d@25:00", // heure hors bornes
"@every 0d@10:00", // zéro jour
}
for _, c := range cases {
if err := validateSchedule(c, ""); err == nil {
t.Errorf("schedule %q aurait dû être refusé", c)
}
}
}
func TestValidateScheduleOK(t *testing.T) {
cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"}
for _, c := range cases {
if err := validateSchedule(c, "Europe/Paris"); err != nil {
t.Errorf("schedule %q aurait dû être valide : %v", c, err)
}
}
}