WIP motion 2
No files matched your search
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestBlinkHighlightFlashesOnceWithDirection(t *testing.T) {
|
||||
value := 10.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
BlinkHighlight(c, value, BlinkHighlightOptions{Duration: 200 * time.Millisecond})
|
||||
}, 200, 60)
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("first value flashed")
|
||||
}
|
||||
if _, ok := tt.Find("10.00"); !ok {
|
||||
t.Fatal("value not named")
|
||||
}
|
||||
value = 12
|
||||
tt.Frame()
|
||||
a := encode(t, tt.Image())
|
||||
if _, ok := tt.Find("12.00, up"); !ok {
|
||||
t.Fatalf("direction not named: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || motionBusy(tt, n) {
|
||||
t.Fatal("flash did not fade and stop")
|
||||
}
|
||||
value = 9
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find("9.00, down"); !ok {
|
||||
t.Fatalf("down not named: %q", tt.Texts())
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
value = 11
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("flash under reduced motion")
|
||||
}
|
||||
if _, ok := tt.Find("11.00, up"); !ok {
|
||||
t.Fatal("static mark missing under reduced motion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlinkHighlightInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
BlinkHighlight(c, 1, BlinkHighlightOptions{Duration: -1})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestBlinkHighlightSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "blink_highlight", 300, 100, func(c *ui.Context) {
|
||||
v := ui.Local(c.Root(), "v", func() float64 { return 101.5 })
|
||||
w := ui.Local(c.Root(), "w", func() float64 { return 88.25 })
|
||||
ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
BlinkHighlight(c, *v, BlinkHighlightOptions{})
|
||||
BlinkHighlight(c, *w, BlinkHighlightOptions{})
|
||||
})
|
||||
*v, *w = 103.75, 86.5
|
||||
}, func(tt *ui.Tester) { tt.Frame() })
|
||||
}
|
||||
@@ -91,8 +91,8 @@ func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts
|
||||
cols := []DataColumn[costRow]{
|
||||
{ID: "name", Title: groupTitle, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.name, b.name) }, Text: func(r costRow) string { return r.name }},
|
||||
{ID: "calls", Title: msg(c, "cost_breakdown.calls"), Width: 80, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.calls, b.calls) }, Text: func(r costRow) string { return count(r.calls) }},
|
||||
{ID: "input", Title: msg(c, "cost_breakdown.input"), Width: 110, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.input, b.input) }, Text: func(r costRow) string { return count(r.input) }},
|
||||
{ID: "output", Title: msg(c, "cost_breakdown.output"), Width: 110, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.output, b.output) }, Text: func(r costRow) string { return count(r.output) }},
|
||||
{ID: "input", Title: msg(c, "cost_breakdown.input"), Width: 120, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.input, b.input) }, Text: func(r costRow) string { return count(r.input) }},
|
||||
{ID: "output", Title: msg(c, "cost_breakdown.output"), Width: 130, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.output, b.output) }, Text: func(r costRow) string { return count(r.output) }},
|
||||
{ID: "cost", Title: msg(c, "cost_breakdown.cost"), Width: 160, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.cost, b.cost) },
|
||||
Text: func(r costRow) string { return money(r.cost) },
|
||||
Cell: func(r costRow) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func costSample() []CostItem {
|
||||
return []CostItem{
|
||||
{Model: "opus", Tool: "search", Step: "plan", InputTokens: 1000, OutputTokens: 200, Cost: 0.30},
|
||||
{Model: "haiku", Tool: "search", Step: "gather", InputTokens: 4000, OutputTokens: 500, Cost: 0.05},
|
||||
{Model: "opus", Tool: "write", Step: "write", InputTokens: 2000, OutputTokens: 3000, Cost: 1.15},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostSum(t *testing.T) {
|
||||
rows, total := costSum(costSample(), CostByModel, "?")
|
||||
if len(rows) != 2 || rows[0].name != "opus" || rows[0].calls != 2 || rows[0].input != 3000 || total.calls != 3 {
|
||||
t.Fatalf("rows %+v total %+v", rows, total)
|
||||
}
|
||||
if d := total.cost - 1.5; d > 1e-9 || d < -1e-9 {
|
||||
t.Fatalf("total cost %v", total.cost)
|
||||
}
|
||||
rows, _ = costSum([]CostItem{{Cost: 1}}, CostByTool, "(none)")
|
||||
if rows[0].name != "(none)" {
|
||||
t.Fatalf("unnamed group %q", rows[0].name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownGroupsAndTotal(t *testing.T) {
|
||||
var s CostBreakdownState
|
||||
by := CostByModel
|
||||
items := costSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
CostBreakdown(c, &s, items, CostBreakdownOptions{GroupBy: by}).Element.Grow(1)
|
||||
})
|
||||
}, 800, 420)
|
||||
for _, want := range []string{"opus", "haiku", "$1.45", "$0.05", "97%", "Total", "$1.50", "7000 in · 3700 out"} {
|
||||
if !tt.HasText(want) {
|
||||
t.Errorf("missing %q in %q", want, tt.Texts())
|
||||
}
|
||||
}
|
||||
if _, ok := tt.Find("Total $1.50 over 3 calls"); !ok {
|
||||
t.Error("total not named")
|
||||
}
|
||||
rows := &s.rows[0]
|
||||
tt.Frame()
|
||||
if &s.rows[0] != rows {
|
||||
t.Fatal("sums recomputed without a change")
|
||||
}
|
||||
by = CostByStep
|
||||
tt.Frame()
|
||||
if !tt.HasText("gather") || tt.HasText("haiku") {
|
||||
t.Fatalf("group by step: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Cost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Table.Sort.Column != "cost" {
|
||||
t.Fatalf("sort %+v", s.Table.Sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownInvalid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
items []CostItem
|
||||
opts CostBreakdownOptions
|
||||
}{{[]CostItem{{Cost: -1}}, CostBreakdownOptions{}}, {nil, CostBreakdownOptions{GroupBy: 7}}, {nil, CostBreakdownOptions{Decimals: -1}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", tc)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
var s CostBreakdownState
|
||||
CostBreakdown(c, &s, tc.items, tc.opts)
|
||||
}, 300, 200, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownSnapshot(t *testing.T) {
|
||||
items := costSample()
|
||||
snapshot(t, "cost_breakdown", 760, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *CostBreakdownState { return &CostBreakdownState{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
CostBreakdown(c, *s, items, CostBreakdownOptions{}).Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestCountUpCountsAndSettles(t *testing.T) {
|
||||
value := 100.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
CountUp(c, value, CountUpOptions{Duration: 300 * time.Millisecond})
|
||||
}, 200, 60)
|
||||
if !tt.HasText("100") || motionBusy(tt, n) {
|
||||
t.Fatalf("first value shown at once: %q", tt.Texts())
|
||||
}
|
||||
value = 200
|
||||
tt.Frame()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
mid := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if s != "200" {
|
||||
mid = s
|
||||
}
|
||||
}
|
||||
if mid == "" || mid == "100" {
|
||||
t.Fatalf("no value between: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionCount(tt, "200") != 2 || motionBusy(tt, n) {
|
||||
t.Fatalf("did not settle: %q", tt.Texts())
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
value = 5
|
||||
tt.Frame()
|
||||
if motionCount(tt, "5") != 2 {
|
||||
t.Fatalf("reduced motion did not jump: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountUpFormatAndInvalid(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CountUp(c, 12.345, CountUpOptions{Decimals: 1})
|
||||
CountUp(c, 3, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.0f items", v) }})
|
||||
}, 300, 80)
|
||||
if !tt.HasText("12.3") || !tt.HasText("3 items") {
|
||||
t.Fatalf("format: %q", tt.Texts())
|
||||
}
|
||||
for _, o := range []CountUpOptions{{Duration: -1}, {Decimals: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", o)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); CountUp(c, 1, o) }, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "finite") {
|
||||
t.Fatal("NaN did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); CountUp(c, nanValue(), CountUpOptions{}) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func nanValue() float64 {
|
||||
zero := 0.0
|
||||
return zero / zero
|
||||
}
|
||||
|
||||
func TestCountUpSnapshot(t *testing.T) {
|
||||
snapshot(t, "count_up", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
CountUp(c, 12840, CountUpOptions{}).FontSize(FontSize(c, 28)).Font("Georgia, serif")
|
||||
CountUp(c, 0.875, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func evalSample() []EvalCase {
|
||||
return []EvalCase{
|
||||
{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true},
|
||||
{ID: "c2", Name: "Refuses forgery", Score: 0.41},
|
||||
{ID: "c3", Name: "Summarizes letter", Score: 0.87, Passed: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableSummaryAndDiff(t *testing.T) {
|
||||
var s DataTableState[string]
|
||||
diff := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
r := EvalResultTable(c, &s, evalSample(), EvalResultTableOptions{})
|
||||
r.Table.Element.Grow(1)
|
||||
if id, ok := r.Diff(); ok {
|
||||
diff = id
|
||||
}
|
||||
})
|
||||
}, 700, 420)
|
||||
for _, want := range []string{"2 of 3 passed", "0.41", "Fail", "Pass"} {
|
||||
if !tt.HasText(want) {
|
||||
t.Errorf("missing %q: %q", want, tt.Texts())
|
||||
}
|
||||
}
|
||||
if err := tt.Click("View differences for Refuses forgery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "c2" {
|
||||
t.Fatalf("diff %q", diff)
|
||||
}
|
||||
if err := tt.Click("Score"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Sort.Column != "score" {
|
||||
t.Fatalf("sort %+v", s.Sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableInvalid(t *testing.T) {
|
||||
for _, cases := range [][]EvalCase{{{Name: "x"}}, {{ID: "a", Score: nanValue()}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", cases)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
var s DataTableState[string]
|
||||
EvalResultTable(c, &s, cases, EvalResultTableOptions{})
|
||||
}, 300, 200, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableSnapshot(t *testing.T) {
|
||||
cases := evalSample()
|
||||
snapshot(t, "eval_result_table", 640, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *DataTableState[string] { return &DataTableState[string]{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
EvalResultTable(c, *s, cases, EvalResultTableOptions{}).Table.Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestInterruptButtonDirectAndConfirmed(t *testing.T) {
|
||||
running, interrupting, confirm := true, false, false
|
||||
stops := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
if InterruptButton(c, running, InterruptButtonOptions{Confirm: confirm, Interrupting: interrupting}).Changed() {
|
||||
stops++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
if err := tt.Click("Stop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stops != 1 {
|
||||
t.Fatalf("direct stop %d", stops)
|
||||
}
|
||||
confirm = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stop")
|
||||
if stops != 1 || !tt.HasText("Stop the agent?") {
|
||||
t.Fatalf("confirm not asked: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Keep running")
|
||||
if stops != 1 || tt.HasText("Stop the agent?") {
|
||||
t.Fatal("cancel interrupted")
|
||||
}
|
||||
_ = tt.Click("Stop")
|
||||
tt.Key(0, ui.KeyTab) // Danger focuses Cancel first; Tab reaches Stop
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if stops != 2 {
|
||||
t.Fatalf("confirmed stop %d: %q", stops, tt.Texts())
|
||||
}
|
||||
interrupting = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stopping")
|
||||
if stops != 2 || !tt.HasText("Stopping") {
|
||||
t.Fatalf("interrupting: stops %d %q", stops, tt.Texts())
|
||||
}
|
||||
interrupting, running = false, false
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stop")
|
||||
if stops != 2 {
|
||||
t.Fatal("idle button interrupted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterruptButtonInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
InterruptButton(c, false, InterruptButtonOptions{Interrupting: true})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestInterruptButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "interrupt_button", 460, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).Children(func() {
|
||||
InterruptButton(c, true, InterruptButtonOptions{})
|
||||
InterruptButton(c, true, InterruptButtonOptions{Interrupting: true})
|
||||
InterruptButton(c, false, InterruptButtonOptions{Label: "Cancel run"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,7 @@ func init() {
|
||||
"tool_registry_panel.empty": {"No tools", "没有工具"},
|
||||
"tool_registry_panel.choose": {"Choose a tool to see its parameters", "选择工具查看参数"},
|
||||
"tool_registry_panel.no_params": {"No parameters", "无参数"},
|
||||
"tool_registry_panel.enable": {"Enable %s", "启用 %s"},
|
||||
"tool_registry_panel.required": {"required", "必填"},
|
||||
|
||||
"typewriter.skip": {"Skip", "跳过"},
|
||||
|
||||
@@ -45,23 +45,26 @@ func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *
|
||||
fill = k.Border
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
e := ui.Box(c).Key(still).Height(h).Role(ui.RoleProgress).Label(label)
|
||||
e := ui.Column(c).Height(h).AlignItems(ui.Stretch).Role(ui.RoleProgress).Label(label)
|
||||
if opts.Width > 0 {
|
||||
e.Width(opts.Width)
|
||||
} else {
|
||||
e.FillWidth()
|
||||
}
|
||||
e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, fill, theme.CardRadius)
|
||||
if still {
|
||||
return
|
||||
}
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1600) / 1600
|
||||
band := max(r.W*0.25, 24)
|
||||
x := internal.Snap(p, r.X-band+(r.W+band)*t)
|
||||
p.Clip(r, theme.CardRadius, func() {
|
||||
p.Fill(ui.Rect{X: x, Y: r.Y, W: band, H: r.H}, k.Surface.Alpha(0.55), 0)
|
||||
// Keyed by motion so a flip repaints.
|
||||
e.Children(func() {
|
||||
ui.Box(c).Key(still).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, fill, theme.CardRadius)
|
||||
if still {
|
||||
return
|
||||
}
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1600) / 1600
|
||||
band := max(r.W*0.25, 24)
|
||||
x := internal.Snap(p, r.X-band+(r.W+band)*t)
|
||||
p.Clip(r, theme.CardRadius, func() {
|
||||
p.Fill(ui.Rect{X: x, Y: r.Y, W: band, H: r.H}, k.Surface.Alpha(0.55), 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
return e
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestShimmerOnlyWhileLoading(t *testing.T) {
|
||||
loading := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
Shimmer(c, loading, ShimmerOptions{Height: 40, Label: "Loading report"}, func() { ui.Text(c, "Report ready") })
|
||||
}, 300, 100)
|
||||
if _, ok := tt.Find("Loading report"); !ok || tt.HasText("Report ready") {
|
||||
t.Fatalf("loading: %q", tt.Texts())
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("shimmer does not sweep")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("shimmer repaints under reduced motion")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
loading = false
|
||||
tt.Frame()
|
||||
if !tt.HasText("Report ready") || motionBusy(tt, n) {
|
||||
t.Fatalf("loaded: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShimmerInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Shimmer(c, true, ShimmerOptions{Width: -1}, nil)
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestShimmerSnapshot(t *testing.T) {
|
||||
snapshot(t, "shimmer", 400, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
Shimmer(c, true, ShimmerOptions{}, nil)
|
||||
Shimmer(c, true, ShimmerOptions{Width: 160, Height: 24}, nil)
|
||||
Shimmer(c, false, ShimmerOptions{}, func() { ui.Text(c, "Loaded content") })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestShimmerTextSweepsAndStops(t *testing.T) {
|
||||
active := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ShimmerText(c, "Thinking", ShimmerTextOptions{Active: active})
|
||||
}, 200, 60)
|
||||
if _, ok := tt.Find("Thinking"); !ok {
|
||||
t.Fatal("text not named")
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("no sweep")
|
||||
}
|
||||
active = false
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) || !tt.HasText("Thinking") {
|
||||
t.Fatal("inactive text still sweeps")
|
||||
}
|
||||
active = true
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("sweeps under reduced motion")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("empty text did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ShimmerText(c, "", ShimmerTextOptions{})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestShimmerTextSnapshot(t *testing.T) {
|
||||
snapshot(t, "shimmer_text", 300, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
ShimmerText(c, "Thinking…", ShimmerTextOptions{Active: true})
|
||||
ShimmerText(c, "思考中…", ShimmerTextOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func tokenUsageSample() []TokenUsage {
|
||||
return []TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Search", Input: 4800, Output: 650}, {Label: "Write", Input: 2100, Output: 2900}}
|
||||
}
|
||||
|
||||
func TestTokenUsageChartBarsKeyboard(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return TokenUsageChart(c, usage, TokenUsageChartOptions{}) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Token usage")
|
||||
want := []string{"Plan · Input 1200", "Plan · Output 300", "Search · Input 4800"}
|
||||
for i, w := range want {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != w {
|
||||
t.Fatalf("mark %d readout %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
if !tt.HasText("Input") || !tt.HasText("Output") {
|
||||
t.Fatal("legend missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsageChartLineEmptyInvalid(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return TokenUsageChart(c, usage, TokenUsageChartOptions{Line: true, Frame: ChartFrame{Label: "Tokens per step"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tokens per step")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.res.Point < 0 {
|
||||
t.Fatal("line chart has no keyboard path")
|
||||
}
|
||||
usage = nil
|
||||
tt.Frame()
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative tokens did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TokenUsageChart(c, []TokenUsage{{Label: "x", Input: -1}}, TokenUsageChartOptions{})
|
||||
}, 300, 200, 1)
|
||||
}
|
||||
|
||||
func TestTokenUsageChartSnapshot(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
snapshot(t, "token_usage_chart", 520, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
TokenUsageChart(c, usage, TokenUsageChartOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -66,7 +67,7 @@ func ToolRegistryPanel(c *ui.Context, s *ListState[string], tools []AgentTool, o
|
||||
ui.Text(c, t.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
on := t.Enabled
|
||||
Switch(c, &on, "", SwitchOptions{Description: t.Name}).Label(t.Name)
|
||||
Switch(c, &on, "", SwitchOptions{}).Label(fmt.Sprintf(msg(c, "tool_registry_panel.enable"), t.Name))
|
||||
if on != t.Enabled && !row.IsDisabled() {
|
||||
res.toggled, res.on = t.Name, on
|
||||
slog.Debug("mujica: tool registry toggle", "tool", t.Name, "on", on)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func toolSample() []AgentTool {
|
||||
return []AgentTool{
|
||||
{Name: "web_search", Description: "Search the web", Enabled: true, Params: []ToolParam{
|
||||
{Name: "query", Type: "string", Required: true, Description: "What to look for"},
|
||||
{Name: "limit", Type: "integer"},
|
||||
}},
|
||||
{Name: "read_file", Description: "Read a local file"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelToggleAndPreview(t *testing.T) {
|
||||
var s ListState[string]
|
||||
tools := toolSample()
|
||||
disabled := false
|
||||
var toggled []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
r := ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{Disabled: disabled})
|
||||
r.Element.Grow(1)
|
||||
if name, on, ok := r.Toggled(); ok {
|
||||
toggled = append(toggled, name)
|
||||
for i := range tools {
|
||||
if tools[i].Name == name {
|
||||
tools[i].Enabled = on
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 800, 420)
|
||||
if !tt.HasText("Choose a tool to see its parameters") {
|
||||
t.Fatalf("empty preview: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Search the web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("query") || !tt.HasText("required") || !tt.HasText("integer") {
|
||||
t.Fatalf("schema: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("read_file"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("No parameters") {
|
||||
t.Fatalf("no params: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Enable read_file"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(toggled) != 1 || toggled[0] != "read_file" || !tools[1].Enabled {
|
||||
t.Fatalf("toggle %q enabled %v", toggled, tools[1].Enabled)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Enable read_file")
|
||||
if len(toggled) != 1 {
|
||||
t.Fatal("disabled panel toggled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
var s ListState[string]
|
||||
ToolRegistryPanel(c, &s, []AgentTool{{Name: "a"}, {Name: "a"}}, ToolRegistryPanelOptions{})
|
||||
}, 300, 200, 1)
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelSnapshot(t *testing.T) {
|
||||
tools := toolSample()
|
||||
snapshotAfter(t, "tool_registry_panel", 760, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *ListState[string] { return &ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
ToolRegistryPanel(c, *s, tools, ToolRegistryPanelOptions{}).Element.Grow(1)
|
||||
})
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Search the web") })
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
@@ -70,6 +71,9 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra
|
||||
{ID: "name", Title: msg(c, "trace_viewer.span"), Compare: func(a, b string) int { return cmp.Compare(span(a).Name, span(b).Name) },
|
||||
Cell: func(id string) {
|
||||
sp := span(id)
|
||||
if sp.Failed {
|
||||
ui.Icon(c, icons.Must("circle-x")).Size(14, 14).TextColor(k.Danger).Shrink(0).Margin(0, u, 0, 0)
|
||||
}
|
||||
t := ui.Text(c, sp.Name).SingleLine()
|
||||
if sp.Failed {
|
||||
t.TextColor(k.Danger).Label(sp.Name + ", " + msg(c, "trace_viewer.failed"))
|
||||
@@ -95,7 +99,14 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra
|
||||
}
|
||||
x0 := r.X + r.W*float32(float64(sp.Start)/float64(ix.end))
|
||||
w := max(2, r.W*float32(float64(sp.End-sp.Start)/float64(ix.end)))
|
||||
p.Fill(ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: internal.Snap(p, w), H: r.H}, col, 2)
|
||||
bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: internal.Snap(p, w), H: r.H}
|
||||
if sp.Failed {
|
||||
// Failed spans are hollow, not told apart by color alone.
|
||||
p.Fill(bar, k.Surface, 2)
|
||||
p.Stroke(bar, col, 2, 1.5)
|
||||
return
|
||||
}
|
||||
p.Fill(bar, col, 2)
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func traceSample() []TraceSpan {
|
||||
ms := time.Millisecond
|
||||
return []TraceSpan{
|
||||
{ID: "root", Name: "agent.run", Start: 0, End: 900 * ms, Attrs: []TraceAttr{{"model", "opus"}}},
|
||||
{ID: "plan", Parent: "root", Name: "llm.plan", Start: 10 * ms, End: 210 * ms, Attrs: []TraceAttr{{"tokens", "1200"}}},
|
||||
{ID: "search", Parent: "root", Name: "tool.search", Start: 220 * ms, End: 520 * ms, Failed: true},
|
||||
{ID: "fetch", Parent: "search", Name: "http.get", Start: 230 * ms, End: 500 * ms},
|
||||
{ID: "write", Parent: "root", Name: "llm.write", Start: 530 * ms, End: 880 * ms},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceBuild(t *testing.T) {
|
||||
ix := traceBuild(traceSample())
|
||||
if len(ix.roots) != 1 || ix.roots[0] != "root" || len(ix.kids["root"]) != 3 || ix.end != 900*time.Millisecond {
|
||||
t.Fatalf("index %+v", ix)
|
||||
}
|
||||
for name, spans := range map[string][]TraceSpan{
|
||||
"dup": {{ID: "a"}, {ID: "a"}},
|
||||
"time": {{ID: "a", Start: 2, End: 1}},
|
||||
"cycle": {{ID: "a", Parent: "b"}, {ID: "b", Parent: "a"}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s did not panic", name)
|
||||
}
|
||||
}()
|
||||
traceBuild(spans)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerSelectShowsAttributes(t *testing.T) {
|
||||
var s TraceViewerState
|
||||
spans := traceSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(600).Children(func() {
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Grow(1)
|
||||
})
|
||||
}, 900, 620)
|
||||
if !tt.HasText("llm.plan") || !tt.HasText("200.0 ms") {
|
||||
t.Fatalf("tree: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("tool.search, failed"); !ok {
|
||||
t.Fatal("failed span not named")
|
||||
}
|
||||
if tt.HasText("http.get") {
|
||||
t.Fatal("nested span shown before opening its parent")
|
||||
}
|
||||
if err := tt.Click("llm.plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, _ := s.Tree.Selected(); id != "plan" || !tt.HasText("tokens") || !tt.HasText("1200") {
|
||||
t.Fatalf("attributes: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if id, _ := s.Tree.Selected(); id != "search" || !tt.HasText("http.get") {
|
||||
t.Fatalf("keyboard open: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerSnapshot(t *testing.T) {
|
||||
spans := traceSample()
|
||||
snapshot(t, "trace_viewer", 820, 420, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *TraceViewerState {
|
||||
st := &TraceViewerState{}
|
||||
st.Tree.SetOpen("search", true)
|
||||
return st
|
||||
})
|
||||
ui.Column(c).Padding(16).Height(420).Children(func() {
|
||||
TraceViewer(c, *s, spans, TraceViewerOptions{}).Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestPresenceEntersExitsAndRemoves(t *testing.T) {
|
||||
for _, kind := range []PresenceKind{PresenceFade, PresenceSlide, PresenceCollapse} {
|
||||
show := false
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() {
|
||||
Presence(c, show, PresenceOptions{Kind: kind}, func() { ui.Text(c, "Notice") })
|
||||
ui.Text(c, "Below")
|
||||
})
|
||||
}, 300, 120)
|
||||
if tt.HasText("Notice") {
|
||||
t.Fatal("hidden content built")
|
||||
}
|
||||
show = true
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if !tt.HasText("Notice") || *n-before < 3 {
|
||||
t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
show = false
|
||||
before = *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatalf("kind %d: no exit motion", kind)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Notice") || motionBusy(tt, n) {
|
||||
t.Fatalf("kind %d: not removed after exit", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceReducedMotionAndInvalid(t *testing.T) {
|
||||
show := false
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() { Presence(c, show, PresenceOptions{Kind: PresenceSlide}, func() { ui.Text(c, "Notice") }) })
|
||||
}, 300, 120)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
show = true
|
||||
if motionBusy(tt, n) || !tt.HasText("Notice") {
|
||||
t.Fatal("reduced motion animated")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown kind did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestStaggerRevealsInTurnOnce(t *testing.T) {
|
||||
count := 5
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() {
|
||||
Stagger(c, count, StaggerOptions{Interval: 50 * time.Millisecond}, func(i int) { ui.Textf(c, "Row %d", i) })
|
||||
})
|
||||
}, 300, 240)
|
||||
if !tt.HasText("Row 0") || tt.HasText("Row 4") {
|
||||
t.Fatalf("first frame: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 2") || tt.HasText("Row 4") {
|
||||
t.Fatalf("after 120 ms: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 4") || motionBusy(tt, n) {
|
||||
t.Fatalf("did not finish and settle: %q", tt.Texts())
|
||||
}
|
||||
count = 6
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 5") {
|
||||
t.Fatal("item added after the sequence waited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaggerReducedMotionAndInvalid(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if c.Preferences().ReduceMotion {
|
||||
Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Row %d", i) })
|
||||
}
|
||||
}, 300, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if !tt.HasText("Row 2") {
|
||||
t.Fatalf("reduced motion staggered: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative n did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestReorderTransitionSlides(t *testing.T) {
|
||||
keys := []string{"a", "b", "c"}
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ReorderTransition(c, keys, ReorderTransitionOptions{Gap: 4}, func(k string) {
|
||||
ui.Text(c, "Item "+k).Padding(8)
|
||||
})
|
||||
}, 300, 200)
|
||||
ra, _ := tt.Find("Item a")
|
||||
keys = []string{"c", "b", "a"}
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatal("reorder did not animate")
|
||||
}
|
||||
mid, _ := tt.Find("Item a")
|
||||
time.Sleep(260 * time.Millisecond)
|
||||
tt.Frame()
|
||||
end, _ := tt.Find("Item a")
|
||||
if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) {
|
||||
t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("duplicate key did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestLayoutTransitionResizes(t *testing.T) {
|
||||
wide := false
|
||||
var w float32
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
box := LayoutTransition(c, LayoutTransitionOptions{Clip: true}, func() {
|
||||
width := float32(100)
|
||||
if wide {
|
||||
width = 300
|
||||
}
|
||||
ui.Box(c).Size(width, 40)
|
||||
})
|
||||
w = box.Bounds().W
|
||||
})
|
||||
}, 400, 120)
|
||||
wide = true
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatal("resize did not animate")
|
||||
}
|
||||
time.Sleep(260 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) || w != 300 {
|
||||
t.Fatalf("settled width %v", w)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative duration did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, nil) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestPresenceSnapshot(t *testing.T) {
|
||||
snapshot(t, "presence", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Presence(c, true, PresenceOptions{}, func() { ui.Text(c, "The doors open.") })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaggerSnapshot(t *testing.T) {
|
||||
snapshot(t, "stagger", 300, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Guest %d arrives", i+1) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderTransitionSnapshot(t *testing.T) {
|
||||
snapshot(t, "reorder_transition", 300, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ReorderTransition(c, []string{"Sakiko", "Uika", "Mutsumi"}, ReorderTransitionOptions{Gap: 4}, func(k string) {
|
||||
ui.Text(c, k).Padding(4, 8).Background(Tokens(c).Surface).Radius(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayoutTransitionSnapshot(t *testing.T) {
|
||||
snapshot(t, "layout_transition", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() {
|
||||
LayoutTransition(c, LayoutTransitionOptions{}, func() {
|
||||
ui.Text(c, "A panel that grows").Padding(12).Background(Tokens(c).Surface).Radius(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
|
||||
if speed < 0 {
|
||||
panic("mujica: Typewriter Speed is negative")
|
||||
}
|
||||
col := ui.Column(c).AlignItems(ui.Start).Gap(Density(c).Unit() * 2)
|
||||
col := ui.Column(c).AlignItems(ui.Start).Gap(Density(c).Unit() * 2).Role(ui.RoleGroup).Label(text)
|
||||
res := TypewriterResult{Element: col}
|
||||
st := ui.Local(col, "typing", func() *typewriterState { return &typewriterState{last: c.Now()} })
|
||||
s := *st
|
||||
@@ -51,8 +51,9 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
|
||||
s.shown, s.skipped, s.done = 0, false, false
|
||||
slog.Debug("mujica: typewriter restart")
|
||||
}
|
||||
if len(runes) > len(s.text) {
|
||||
s.done = false
|
||||
if len(runes) > len(s.text) && s.done {
|
||||
// New text continues from where it stood, at the typing speed.
|
||||
s.done, s.skipped, s.last = false, false, c.Now()
|
||||
}
|
||||
s.text = runes
|
||||
now := c.Now()
|
||||
@@ -71,7 +72,7 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
|
||||
s.last = now
|
||||
}
|
||||
col.Children(func() {
|
||||
ui.Text(c, string(runes[:s.shown])).Label(text)
|
||||
ui.Text(c, string(runes[:s.shown]))
|
||||
if opts.Skippable && s.shown < len(runes) {
|
||||
if Button(c, msg(c, "typewriter.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
|
||||
s.skipped, s.shown = true, len(runes)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestTypewriterRevealsFinishesAndSkips(t *testing.T) {
|
||||
text := "The court assembles at dusk."
|
||||
finished := 0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
if Typewriter(c, text, TypewriterOptions{Speed: 100, Skippable: true}).Finished() {
|
||||
finished++
|
||||
}
|
||||
}, 400, 120)
|
||||
if tt.HasText(text) && motionCount(tt, text) == 2 {
|
||||
t.Fatal("whole text shown at once")
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
shown := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s != text && s != "Skip" && len(s) > shown {
|
||||
shown = len(s)
|
||||
}
|
||||
}
|
||||
if shown < 5 || shown >= len(text) {
|
||||
t.Fatalf("partial reveal %d of %d: %q", shown, len(text), tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Skip"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if motionCount(tt, text) != 2 || finished != 1 || tt.HasText("Skip") || motionBusy(tt, n) {
|
||||
t.Fatalf("skip: finished %d texts %q", finished, tt.Texts())
|
||||
}
|
||||
text += " The music begins."
|
||||
tt.Frame()
|
||||
if motionCount(tt, "The court assembles at dusk.") != 1 {
|
||||
t.Fatalf("appended text restarted: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionCount(tt, text) != 2 || finished != 2 {
|
||||
t.Fatalf("append: finished %d %q", finished, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypewriterReducedMotionAndInvalid(t *testing.T) {
|
||||
finished := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
finished = Typewriter(c, "All at once", TypewriterOptions{}).Finished() || finished
|
||||
}, 300, 80)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
_ = tt
|
||||
tt2 := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
finished = Typewriter(c, "Shown now", TypewriterOptions{}).Finished() || finished
|
||||
}, 300, 80)
|
||||
tt2.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionCount(tt2, "Shown now") != 2 || !finished {
|
||||
t.Fatalf("reduced motion: %q", tt2.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative speed did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { Use(c, Settings{}); Typewriter(c, "x", TypewriterOptions{Speed: -1}) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestTypewriterSnapshot(t *testing.T) {
|
||||
snapshot(t, "typewriter", 420, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Typewriter(c, "The court assembles at dusk; the music begins.", TypewriterOptions{Skippable: true})
|
||||
})
|
||||
})
|
||||
}
|
||||