Fifteen components that dress an AI agent's work as an RPG party: AgentCard, ToolCall, PlanQuest, ThinkingOrb, TokenMana, ApprovalPrompt, AgentLog, SubAgentParty, MemoryCrystals, AgentStatus, DiffScroll, ModelSelect, PromptBox, CostCoins and Checkpoints. The context window is HP and the token budget MP; tool calls are spells that CAST, glow, then land OK or FAILED; the transcript reads as a battle log whose newest line types itself out; models line up like fighters. Busy signs keep moving under Reduce Motion, decoration stops. Commands and prompts never reach the log, and a veiled PromptBox casts nothing. icon.Crystal is the new 12x12 save and memory crystal.
136 lines
2.9 KiB
Go
136 lines
2.9 KiB
Go
package agent_test
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
type clock struct{ t time.Time }
|
|
|
|
func (c *clock) now() time.Time { return c.t }
|
|
|
|
// world is the settings a test view runs under.
|
|
type world struct {
|
|
clk *clock
|
|
reduce, inert bool
|
|
}
|
|
|
|
func newWorld() *world {
|
|
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
|
|
}
|
|
|
|
// view builds on a fake clock, two pixels a dot.
|
|
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
|
|
tt := ui.NewTester(func(c *ui.Context) {
|
|
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert, Reduce: wd.reduce})
|
|
build(c)
|
|
}, w, h)
|
|
return tt
|
|
}
|
|
|
|
// later moves the clock on and builds a frame.
|
|
func later(tt *ui.Tester, wd *world, d time.Duration) {
|
|
wd.clk.t = wd.clk.t.Add(d)
|
|
tt.Frame()
|
|
}
|
|
|
|
func click(t *testing.T, tt *ui.Tester, label string) {
|
|
t.Helper()
|
|
if err := tt.Click(label); err != nil {
|
|
t.Fatalf("%v in %q", err, tt.Texts())
|
|
}
|
|
}
|
|
|
|
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
|
t.Helper()
|
|
r, ok := tt.Find(label)
|
|
if !ok {
|
|
t.Fatalf("no %q in %q", label, tt.Texts())
|
|
}
|
|
return r
|
|
}
|
|
|
|
// none is a color outside the palette.
|
|
const none pixel.Slot = 255
|
|
|
|
// at is the slot at dot x, y, or none.
|
|
func at(img *image.RGBA, x, y int) pixel.Slot {
|
|
c := img.RGBAAt(2*x, 2*y)
|
|
for s, want := range pixel.SpringDay.Colors {
|
|
if c.R == want.R && c.G == want.G && c.B == want.B {
|
|
return pixel.Slot(s)
|
|
}
|
|
}
|
|
return none
|
|
}
|
|
|
|
// count is how many dots in a box hold s.
|
|
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
|
|
n := 0
|
|
for j := y; j < y+h; j++ {
|
|
for i := x; i < x+w; i++ {
|
|
if at(img, i, j) == s {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// dots is a found box in dots.
|
|
func dots(r ui.Rect) (x, y, w, h int) {
|
|
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
|
|
}
|
|
|
|
func mustPanic(t *testing.T, name string, fn func()) {
|
|
t.Helper()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s did not panic", name)
|
|
}
|
|
}()
|
|
fn()
|
|
}
|
|
|
|
// logged runs fn with the debug log captured.
|
|
func logged(fn func()) string {
|
|
var buf bytes.Buffer
|
|
old := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
defer slog.SetDefault(old)
|
|
fn()
|
|
return buf.String()
|
|
}
|
|
|
|
// moves reports whether a view's picture changes over a second.
|
|
func moves(tt *ui.Tester, wd *world) bool {
|
|
first := append([]byte(nil), tt.Image().Pix...)
|
|
for range 15 {
|
|
later(tt, wd, time.Second/15)
|
|
if !bytes.Equal(first, tt.Image().Pix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// spill counts dots drawn outside a box.
|
|
func spill(img *image.RGBA, x, y, w, h int) int {
|
|
n, b := 0, img.Bounds()
|
|
for j := range b.Dy() / 2 {
|
|
for i := range b.Dx() / 2 {
|
|
if (i < x || i >= x+w || j < y || j >= y+h) && at(img, i, j) != pixel.Back {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|