mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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.
143 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|