Files
ZacharyZhang-NY 0e21870b64 Add media components 346-365; input.SlideBase, guarded Pad
An audio player with transport, scrubber and volume; an equalizer and
a visualizer; a playlist; a cassette, cartridge and record that turn
while they play and hold where they stop; piano keys, a step sequencer
and a tracker you can type into; a waveform, a 16:9 video frame, an
image compare, a gallery, a filmstrip, a record button and an FM tuner.

input exports SlideBase, the undrawn rail its sliders share; the
gallery treats exported Bases as building blocks, not components.
pixel's micro font gains '#' for notes like C#4. pixel.Pad and the
slide rail read input only while their base is enabled, so Inert or a
fresh Disabled stops a drag or a key already sent. AGENTS records why
a drawn container keeps the DISABLED description: MyGo paints a
disabled subtree at half opacity.
2026-10-12 06:17:12 -04:00

344 lines
9.6 KiB
Go

package media_test
import (
"image"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media"
"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 bool
pal *pixel.Palette
}
func newWorld() *world {
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}, pal: pixel.SpringDay}
}
// 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: wd.pal, Now: wd.clk.now})
build(c)
}, w, h)
if wd.reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
}
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, pal *pixel.Palette, x, y int) pixel.Slot {
c := img.RGBAAt(2*x, 2*y)
for s, want := range pal.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, pal *pixel.Palette, 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, pal, 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()
}
func TestTransportPlaysStopsAndSkips(t *testing.T) {
wd := newWorld()
playing := false
var got media.TransportResult
tt := view(400, 100, wd, func(c *ui.Context) {
if r := media.Transport(c, "Deck", &playing, media.TransportOptions{}); r.Changed || r.Stopped || r.Previous || r.Next {
got = r
}
})
// ▶ has 50 ink dots; ❚❚ has 60.
ink := func() int {
x, y, w, h := dots(find(t, tt, "PLAY"))
return count(tt.Image(), wd.pal, x, y, w, h, pixel.Ink)
}
if n := ink(); n != 50 {
t.Fatalf("stopped, PLAY shows %d ink dots, want ▶'s 50", n)
}
click(t, tt, "PLAY")
if !playing || !got.Changed || ink() != 60 {
t.Fatalf("PLAY: playing %v, %+v, %d ink dots", playing, got, ink())
}
got = media.TransportResult{}
click(t, tt, "STOP")
if playing || !got.Stopped || !got.Changed {
t.Fatalf("STOP: playing %v, %+v", playing, got)
}
got = media.TransportResult{}
click(t, tt, "STOP")
if !got.Stopped || got.Changed {
t.Fatalf("STOP while stopped: %+v; nothing changed", got)
}
for _, b := range []string{"PREVIOUS", "NEXT"} {
got = media.TransportResult{}
click(t, tt, b)
if got.Previous != (b == "PREVIOUS") || got.Next != (b == "NEXT") || got.Changed {
t.Fatalf("%s: %+v", b, got)
}
}
}
func TestScrubberSeeksByPressAndKeys(t *testing.T) {
wd := newWorld()
pos, total := 72*time.Second, 192*time.Second
changed := false
tt := view(400, 100, wd, func(c *ui.Context) {
changed = media.Scrubber(c, "Seek", &pos, total, media.ScrubberOptions{Width: 96}).Changed || changed
})
find(t, tt, "1:12")
find(t, tt, "3:12")
r := find(t, tt, "Seek")
tt.Press(r.X+r.W/2, r.Y+r.H/2)
tt.Release(r.X+r.W/2, r.Y+r.H/2)
if !changed || pos != total/2 || !tt.HasText("1:36") {
t.Fatalf("pressing the middle: changed %v, at %v; want %v", changed, pos, total/2)
}
tt.Key(0, ui.KeyHome)
if pos != 0 || !tt.HasText("0:00") {
t.Fatalf("Home: at %v, %q", pos, tt.Texts())
}
tt.Key(0, ui.KeyRight)
if pos != 9600*time.Millisecond {
t.Fatalf("Right steps a twentieth of 3:12, 9.6 s: %v", pos)
}
mustPanic(t, "a place past the end", func() {
view(400, 100, newWorld(), func(c *ui.Context) {
p := total + time.Second
media.Scrubber(c, "Seek", &p, total, media.ScrubberOptions{})
})
})
}
func TestVolumeStepsMutesAndLights(t *testing.T) {
wd := newWorld()
level, muted := 5, false
changed := false
tt := view(400, 100, wd, func(c *ui.Context) {
changed = media.Volume(c, "Volume", &level, &muted, media.VolumeOptions{}).Changed || changed
})
bars := func() []pixel.Slot {
img := tt.Image()
x, y, _, h := dots(find(t, tt, "Volume"))
var out []pixel.Slot
for i := range 8 {
out = append(out, at(img, wd.pal, x+4*i+1, y+h-2))
}
return out
}
want := []pixel.Slot{pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Well, pixel.Well, pixel.Well}
if got := bars(); !slices.Equal(got, want) {
t.Fatalf("level 5 lights %v, want %v", got, want)
}
r := find(t, tt, "Volume")
tt.Press(r.X+r.W-1, r.Y+r.H/2)
tt.Release(r.X+r.W-1, r.Y+r.H/2)
if level != 8 || !changed {
t.Fatalf("pressing the top bar: level %d, changed %v", level, changed)
}
if got := bars(); got[5] != pixel.Good || got[6] != pixel.Warn || got[7] != pixel.Warn {
t.Fatalf("the loud quarter is Warn: %v", got)
}
tt.Key(0, ui.KeyLeft)
if level != 7 {
t.Fatalf("Left: level %d, want 7", level)
}
changed = false
click(t, tt, "MUTE")
if !muted || !changed || bars()[0] != pixel.Faint {
t.Fatalf("MUTE: muted %v, changed %v, bars %v", muted, changed, bars())
}
mustPanic(t, "level above the bars", func() {
view(400, 100, newWorld(), func(c *ui.Context) {
l, m := 9, false
media.Volume(c, "Volume", &l, &m, media.VolumeOptions{})
})
})
}
func TestEqualizerReportsTheBand(t *testing.T) {
wd := newWorld()
gains := []float64{0, 0, 0}
band := -1
tt := view(400, 200, wd, func(c *ui.Context) {
if b := media.Equalizer(c, "EQ", []string{"BASS", "MID", "HIGH"}, gains, media.EqualizerOptions{}).Band; b >= 0 {
band = b
}
})
click(t, tt, "MID")
tt.Key(0, ui.KeyUp)
if band != 1 || gains[1] <= 1 || gains[0] != 0 || gains[2] != 0 {
t.Fatalf("Up on MID: band %d, gains %v", band, gains)
}
mustPanic(t, "gain past 12 dB", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
media.Equalizer(c, "EQ", []string{"A"}, []float64{13}, media.EqualizerOptions{})
})
})
mustPanic(t, "bands without gains", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
media.Equalizer(c, "EQ", []string{"A", "B"}, []float64{0}, media.EqualizerOptions{})
})
})
}
var songs = []media.Track{
{Title: "One", Artist: "Ann", Length: 61 * time.Second},
{Title: "Two", Artist: "Bob", Length: 3723 * time.Second},
{Title: "Six", Artist: "Cy", Length: 9 * time.Second},
}
func TestPlaylistMarksPlayingAndSubmits(t *testing.T) {
wd := newWorld()
sel := 0
var got media.PlaylistResult
tt := view(500, 300, wd, func(c *ui.Context) {
if r := media.Playlist(c, "List", songs, 2, &sel, media.PlaylistOptions{}); r.Changed || r.Submitted {
got = r
}
})
find(t, tt, "Six, Cy, PLAYING")
click(t, tt, "Two, Bob")
if sel != 1 || !got.Changed {
t.Fatalf("clicking Two: selected %d, %+v", sel, got)
}
got = media.PlaylistResult{}
tt.Key(0, ui.KeyEnter)
if !got.Submitted {
t.Fatalf("Enter did not submit: %+v", got)
}
// Past the ♪, the playing row's title is Accent too.
x, y, _, _ := dots(find(t, tt, "Six, Cy, PLAYING"))
if count(tt.Image(), wd.pal, x+27, y, 30, 12, pixel.Accent) == 0 {
t.Fatal("the playing title is not Accent")
}
mustPanic(t, "playing past the end", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
s := 0
media.Playlist(c, "List", songs, 3, &s, media.PlaylistOptions{})
})
})
}
func TestAudioPlayerStopRewinds(t *testing.T) {
wd := newWorld()
pl := media.Player{At: 30 * time.Second, Level: 4}
var got media.AudioPlayerResult
tt := view(600, 300, wd, func(c *ui.Context) {
if r := media.AudioPlayer(c, "Deck", songs[1], &pl, media.AudioPlayerOptions{}); r.Changed || r.Previous || r.Next {
got = r
}
})
find(t, tt, "Two, Bob")
find(t, tt, "1:02:03")
click(t, tt, "PLAY")
if !pl.Playing || !got.Changed {
t.Fatalf("PLAY: %+v, %+v", pl, got)
}
got = media.AudioPlayerResult{}
click(t, tt, "STOP")
if pl.Playing || pl.At != 0 || !got.Stopped || !got.Changed {
t.Fatalf("STOP did not end play and rewind: %+v, %+v", pl, got)
}
got = media.AudioPlayerResult{}
click(t, tt, "NEXT")
if !got.Next || got.Changed {
t.Fatalf("NEXT: %+v", got)
}
got = media.AudioPlayerResult{}
click(t, tt, "MUTE")
if !pl.Muted || !got.Changed {
t.Fatalf("MUTE: %+v, %+v", pl, got)
}
}
func TestVideoFrameShowsTheScreenAndSeeks(t *testing.T) {
wd := newWorld()
playing, pos := false, time.Duration(0)
changed := false
tt := view(600, 400, wd, func(c *ui.Context) {
screen := func() { ui.Box(c).Size(400, 400).Label("film").Role(ui.RoleImage) }
changed = media.VideoFrame(c, "Clip", &playing, &pos, 20*time.Second, media.VideoFrameOptions{}, screen).Changed || changed
})
// The screen, 160 by 90 dots, clips the film.
if f := find(t, tt, "film"); f.H != 180 {
t.Fatalf("the screen shows %v of the film, want 180 pixels tall", f)
}
click(t, tt, "PLAY")
if !playing || !changed {
t.Fatalf("PLAY: playing %v, changed %v", playing, changed)
}
changed = false
r := find(t, tt, "SEEK")
tt.Press(r.X+r.W/2, r.Y+r.H/2)
tt.Release(r.X+r.W/2, r.Y+r.H/2)
if pos != 10*time.Second || !changed {
t.Fatalf("seeking the middle: at %v, changed %v", pos, changed)
}
}