Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: Go

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

jobs:

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Update the pkgs"
run: sudo apt-get update
# We install xorg-dev because of https://github.com/go-gl/glfw/issues/129#issuecomment-75928365
# We install xvfb because of the error "The DISPLAY environment variable is missing" so we need
# to have a fake DISPLAY and xvfb does exactly that.
# Found it in https://stackoverflow.com/questions/834723/a-dev-null-equivilent-for-display-when-the-display-is-just-noise
- name: "Install xorg-dev and xvfb"
run: sudo apt-get install xvfb xorg-dev

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25'

- name: Test
run: make test ARGS="-v"
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/xescugc/ebitest.svg)](https://pkg.go.dev/github.com/xescugc/ebitest)

# Ebitest

Ebitest is a lib to test Ebiten UI through inputs and asserting on what should be on the screen.
Expand Down Expand Up @@ -106,6 +108,27 @@ And if you open the `_ebitest_dump/019b1537-1c60-7041-ad54-0297ea4b0eef.png` (on
<img src="docs/error_image.png" width=50% height=50%>
</p>

## Run it on a CI

If the CI has low resources (like GitHub Actions) it'll most likely fail (check `Known issues#2`) but you
can check what I install for it to run on the [`go.yml`](.github/workflows/go.yml)

## Known issues and Limitations

1/ You cannot have more than 1 test case

Basically you cannot run more than one test as even calling `Ebitest.Close()` there are some resources missing and you may get an error like

> panic: ebiten: NewImage cannot be called after RunGame finishes [recovered, repanicked]

2/ Some false positive/negative

Due to the nature of this test (the game is running on a goroutine) there may be the case in which an input is not registered by the game
so an expectation may randomly fail.

I kind of fixed it using a custom [PingPong](./ping_pong.go) that basically forces a context switch but it still fails in low resource like
GitHub [Actions](https://github.com/xescugc/ebitest/actions) for example.

## Plans

* Add more helpers for assertions (like animations)
Expand Down
25 changes: 17 additions & 8 deletions ebitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ const (
type Ebitest struct {
game *Game
t *testing.T
pingPong *PingPong
PingPong *PingPong

ctxCancelFn context.CancelFunc
endGameChan chan struct{}

options options
}
Expand Down Expand Up @@ -66,13 +67,18 @@ func Run(t *testing.T, game ebiten.Game, opts ...optionsFn) *Ebitest {
ctx, cfn := context.WithCancel(context.TODO())
pingPong := NewPingPong()
g := newGame(ctx, game, pingPong)
go ebiten.RunGame(g)
endGameChan := make(chan struct{})
go func() {
ebiten.RunGame(g)
endGameChan <- struct{}{}
}()

et := &Ebitest{
game: g,
ctxCancelFn: cfn,
t: t,
pingPong: pingPong,
PingPong: pingPong,
endGameChan: endGameChan,
}

op := options{}
Expand All @@ -82,7 +88,7 @@ func Run(t *testing.T, game ebiten.Game, opts ...optionsFn) *Ebitest {
}
et.options = op

et.pingPong.Ping()
et.PingPong.Ping()

if et.options.dumpErrorImages {
os.RemoveAll(baseDumpFoler)
Expand All @@ -95,13 +101,15 @@ func Run(t *testing.T, game ebiten.Game, opts ...optionsFn) *Ebitest {
// Close stops the underlying game
func (e *Ebitest) Close() {
e.ctxCancelFn()
<-e.endGameChan
close(e.endGameChan)
}

// Should checks if selector(s) is present in the game and returns it
// s can be a: 'string', 'image.Image', '*ebiten.Image' and '*ebitest.Selector'
func (e *Ebitest) Should(s interface{}) (*Selector, bool) {
e.t.Helper()
e.pingPong.Ping()
e.PingPong.Ping()

sel, ok := e.findSelector(s)
if !ok {
Expand All @@ -121,7 +129,7 @@ func (e *Ebitest) Should(s interface{}) (*Selector, bool) {
// s can be a: 'string', 'image.Image', '*ebiten.Image' and '*ebitest.Selector'
func (e *Ebitest) ShouldNot(s interface{}) bool {
e.t.Helper()
e.pingPong.Ping()
e.PingPong.Ping()

sel, ok := e.findSelector(s)
if !ok {
Expand All @@ -142,7 +150,7 @@ func (e *Ebitest) ShouldNot(s interface{}) bool {
// s can be a: 'string', 'image.Image', '*ebiten.Image' and '*ebitest.Selector'
func (e *Ebitest) Must(s interface{}) *Selector {
e.t.Helper()
e.pingPong.Ping()
e.PingPong.Ping()

sel, ok := e.findSelector(s)
if !ok {
Expand All @@ -163,7 +171,7 @@ func (e *Ebitest) Must(s interface{}) *Selector {
// s can be a: 'string', 'image.Image', '*ebiten.Image' and '*ebitest.Selector'
func (e *Ebitest) MustNot(s interface{}) {
e.t.Helper()
e.pingPong.Ping()
e.PingPong.Ping()

sel, ok := e.findSelector(s)
if !ok {
Expand Down Expand Up @@ -210,6 +218,7 @@ func (e *Ebitest) findSelector(ss interface{}) (*Selector, bool) {
sely := sel.Image().Bounds().Dy()

sel.rect = image.Rect(x, y, x+selx, y+sely)
sel.PingPong = e.PingPong
return sel, true
}
}
Expand Down
18 changes: 14 additions & 4 deletions ebitest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,29 @@ import (
"image/color"
"testing"

"github.com/go-vgo/robotgo"
"github.com/stretchr/testify/assert"
"github.com/xescugc/ebitest"
"github.com/xescugc/ebitest/testdata"
)

func TestGameUI(t *testing.T) {
face, _ := loadFont(20)
g := newGameUI()
func TestGameButton(t *testing.T) {
face, _ := testdata.LoadFont(20)
g := testdata.NewGame()
et := ebitest.Run(t, g,
ebitest.WithFace(face),
ebitest.WithColor(color.White),
ebitest.WithDumpErrorImages(),
)
defer et.Close()

robotgo.Move(0, 0)
robotgo.Click("left", true)

assert.True(t, g.Clicked)

et.PingPong.Ping()

text1 := "Click Me"
text2 := "Clicked Me"

Expand All @@ -25,6 +35,6 @@ func TestGameUI(t *testing.T) {

t1s.Click()

et.Should(text1)
et.ShouldNot(text1)
et.Should(text2)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/xescugc/ebitest

go 1.25.1
go 1.25

require (
github.com/ebitenui/ebitenui v0.7.2
Expand Down
3 changes: 3 additions & 0 deletions selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
type Selector struct {
img image.Image
rect image.Rectangle

PingPong *PingPong
}

func NewFromText(txt string, f text.Face, c color.Color) *Selector {
Expand Down Expand Up @@ -39,6 +41,7 @@ func (s *Selector) Click() {
cx, cy := s.center()
robotgo.Move(cx, cy)
robotgo.Click("left", true)
s.PingPong.Ping()
}

func (s *Selector) center() (int, int) {
Expand Down
26 changes: 16 additions & 10 deletions gameui_test.go → testdata/game.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ebitest_test
package testdata

import (
"bytes"
Expand All @@ -10,22 +10,25 @@ import (
"github.com/ebitenui/ebitenui/image"
"github.com/ebitenui/ebitenui/widget"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"golang.org/x/image/font/gofont/goregular"
)

// Game object used by ebiten.
type gameui struct {
type Game struct {
ui *ebitenui.UI
btn *widget.Button

Clicked bool
}

func newGameUI() *gameui {
func NewGame() *Game {
// load images for button states: idle, hover, and pressed.
buttonImage, _ := loadButtonImage()

// load button text font.
face, _ := loadFont(20)
face, _ := LoadFont(20)

// construct a new container that serves as the root of the UI hierarchy.
rootContainer := widget.NewContainer(
Expand Down Expand Up @@ -78,7 +81,7 @@ func newGameUI() *gameui {
Container: rootContainer,
}

game := gameui{
game := Game{
ui: &ui,
btn: button,
}
Expand All @@ -87,26 +90,29 @@ func newGameUI() *gameui {
}

// Layout implements Game.
func (g *gameui) Layout(outsideWidth int, outsideHeight int) (int, int) {
func (g *Game) Layout(outsideWidth int, outsideHeight int) (int, int) {
return outsideWidth, outsideHeight
}

// Update implements Game.
func (g *gameui) Update() error {
func (g *Game) Update() error {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
g.Clicked = true
}

// update the UI
g.ui.Update()

return nil
}

// Draw implements Ebiten's Draw method.
func (g *gameui) Draw(screen *ebiten.Image) {
func (g *Game) Draw(screen *ebiten.Image) {
// draw the UI onto the screen
g.ui.Draw(screen)
}

func loadButtonImage() (*widget.ButtonImage, error) {

idle := image.NewBorderedNineSliceColor(color.NRGBA{R: 170, G: 170, B: 180, A: 255}, color.NRGBA{90, 90, 90, 255}, 3)

hover := image.NewBorderedNineSliceColor(color.NRGBA{R: 130, G: 130, B: 150, A: 255}, color.NRGBA{70, 70, 70, 255}, 3)
Expand All @@ -120,7 +126,7 @@ func loadButtonImage() (*widget.ButtonImage, error) {
}, nil
}

func loadFont(size float64) (text.Face, error) {
func LoadFont(size float64) (text.Face, error) {
s, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
if err != nil {
log.Fatal(err)
Expand Down
Loading