From e454e308a248018f9c2d5f7e7bae44291ed3c1f0 Mon Sep 17 00:00:00 2001 From: Daniel Konecny Date: Sat, 23 Oct 2021 19:00:31 +0200 Subject: [PATCH 1/3] Implement synchronization for testing Key events are asynchronous when testing terminal application. To have more deterministic testing we introduce notification channel, which is used only during testing to find out when all the events were processed in the MainLoop. --- gui.go | 12 +++ tcell_driver.go | 3 + testing.go | 35 ++++---- testing_test.go | 216 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 244 insertions(+), 22 deletions(-) diff --git a/gui.go b/gui.go index 847aee2..88ca177 100644 --- a/gui.go +++ b/gui.go @@ -73,6 +73,8 @@ type Gui struct { outputMode OutputMode stop chan struct{} blacklist []Key + testCounter int // used for testing synchronization + testNotify chan struct{} // BgColor and FgColor allow to configure the background and foreground // colors of the GUI. @@ -485,6 +487,7 @@ func (g *Gui) MainLoop() error { if err := g.flush(); err != nil { return err } + g.testCounter = 0 for { select { case ev := <-g.gEvents: @@ -505,6 +508,12 @@ func (g *Gui) MainLoop() error { if err := g.flush(); err != nil { return err } + // used during testing for synchronization + if g.testNotify != nil && g.testCounter > 0 { + for ; g.testCounter > 0; g.testCounter-- { + g.testNotify <- struct{}{} + } + } } } @@ -532,6 +541,9 @@ func (g *Gui) handleEvent(ev *gocuiEvent) error { switch ev.Type { case eventKey, eventMouse: return g.onKey(ev) + case eventTime: + g.testCounter++ + return nil case eventError: return ev.Err // Not sure if this should be handled. It acts weirder when it's here diff --git a/tcell_driver.go b/tcell_driver.go index 7778db1..2fca9f4 100644 --- a/tcell_driver.go +++ b/tcell_driver.go @@ -124,6 +124,7 @@ const ( eventInterrupt eventError eventRaw + eventTime ) var ( @@ -221,6 +222,8 @@ func pollEvent() gocuiEvent { Ch: 0, Mod: mouseMod, } + case *tcell.EventTime: + return gocuiEvent{Type: eventTime} default: return gocuiEvent{Type: eventNone} } diff --git a/testing.go b/testing.go index c70de64..2fc24cd 100644 --- a/testing.go +++ b/testing.go @@ -46,11 +46,13 @@ func (g *Gui) GetTestingScreen() TestingScreen { // defer cleanup() // func (t *TestingScreen) StartGui() func() { + t.gui.testNotify = make(chan struct{}) go func() { if err := t.gui.MainLoop(); err != nil && !errors.Is(err, ErrQuit) { log.Panic(err) } }() + t.WaitSync() t.started = true @@ -76,6 +78,25 @@ func (t *TestingScreen) SendKey(key Key) { t.screen.InjectKey(tcell.Key(key), rune(key), tcell.ModNone) } +// SendsKeySync sends a key to gocui and wait until MainLoop process it. +func (t *TestingScreen) SendKeySync(key Key) { + if !t.started { + panic("TestingScreen must be started using 'StartGui' before injecting keys") + } + t.screen.InjectKey(tcell.Key(key), rune(key), tcell.ModNone) + t.WaitSync() +} + +// WaitSync sends time event to gocui and awaits notification that it was received. +// +// Notification is sent from gocui at the end of MainLoop, so after this function returns, +// user has confirmation that all the keys sent to gocui before time event were processed. +func (t *TestingScreen) WaitSync() { + ev := &tcell.EventTime{} + t.screen.PostEvent(ev) + <-t.gui.testNotify +} + // GetViewContent gets the current conent of a view from the simulated screen func (t *TestingScreen) GetViewContent(viewName string) (string, error) { if !t.started { @@ -136,21 +157,7 @@ func (t *TestingScreen) injectString(str string) { s := i * 10 e := i*10 + 10 simulationScreen.InjectKeyBytes([]byte(str[s:e])) - for i := 0; i < 10; i++ { - // Trigger GoCUI to update with new input - // Todo: Is this necessary? - t.gui.Update(func(*Gui) error { - return nil - }) - } } simulationScreen.InjectKeyBytes([]byte(str[len(str)-extra:])) - for i := 0; i < extra; i++ { - // Trigger GoCUI to update with new input - // Todo: Is this necessary? - t.gui.Update(func(*Gui) error { - return nil - }) - } } diff --git a/testing_test.go b/testing_test.go index 837fa9f..efabc22 100644 --- a/testing_test.go +++ b/testing_test.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "strings" + "sync" "testing" "time" ) @@ -58,23 +59,222 @@ func TestTestingScreenReturnsCorrectContent(t *testing.T) { // Send a key to gocui testingScreen.SendKey(KeyCtrlC) - // Wait for key to be processed - <-time.After(time.Millisecond * 50) + testingScreen.WaitSync() // Test that the keybinding fired and set "didCallCTRLC" to true if !didCallCTRLC { t.Error("Expect the simulator to invoke the key handler for CTRLC") } - // Get the content from the testing screen - actualContent, err := testingScreen.GetViewContent(viewName) + // check view content + assertView(t, testingScreen, viewName, expectedViewContent) +} + +func TestTestingScreenMultipleKeys(t *testing.T) { + // Track what happened in the view, we'll assert on these + didCallCTRLC := false + expectedViewContent := "Hello world!" + expectedViewContent1 := "Hello World!" + expectedViewContent2 := "HELLO WORLD!" + expectedViewContent3 := "Hello lord!!" + viewName := "testView1" + + // Create a view specifying the "OutputSimulator" mode + g, err := NewGui(OutputSimulator, true) if err != nil { - t.Error(err) + log.Panicln(err) } + g.SetManagerFunc(func(g *Gui) error { + maxX, maxY := g.Size() + if v, err := g.SetView(viewName, maxX/2-7, maxY/2, maxX/2+7, maxY/2+2, 0); err != nil { + if !errors.Is(err, ErrUnknownView) { + return err + } + + if _, err := g.SetCurrentView(viewName); err != nil { + return err + } + + // Have the view draw "Hello world!" + fmt.Fprintln(v, expectedViewContent) + } + + return nil + }) + + // Create a key binding which sets "didCallCTRLC" when triggered + exampleBindingToTest := func(g *Gui, v *View) error { + didCallCTRLC = true + return nil + } + if err := g.SetKeybinding("", KeyCtrlC, ModNone, exampleBindingToTest); err != nil { + log.Panicln(err) + } + + if err := g.SetKeybinding("", KeyF1, ModNone, func(g *Gui, v *View) error { + v.Clear() + fmt.Fprintln(v, expectedViewContent1) + return nil + }); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding("", KeyF2, ModNone, func(g *Gui, v *View) error { + v.Clear() + <-time.After(time.Millisecond * 100) + fmt.Fprintln(v, expectedViewContent2) + return nil + }); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding("", KeyF3, ModNone, func(g *Gui, v *View) error { + v.Clear() + fmt.Fprintln(v, expectedViewContent3) + return nil + }); err != nil { + log.Panicln(err) + } + + // Create a test screen and start gocui + testingScreen := g.GetTestingScreen() + cleanup := testingScreen.StartGui() + defer cleanup() + + // check view content + assertView(t, testingScreen, viewName, expectedViewContent) + + // Send a key to gocui + testingScreen.SendKeySync(KeyCtrlC) + + // Test that the keybinding fired and set "didCallCTRLC" to true + if !didCallCTRLC { + t.Error("Expect the simulator to invoke the key handler for CTRLC") + } + + // Send a key to gocui + testingScreen.SendKeySync(KeyF1) + + // check view content + assertView(t, testingScreen, viewName, expectedViewContent1) + + // Send a key to gocui + testingScreen.SendKeySync(KeyF2) - // Test that it contains the "Hello World!" we thought the view should draw - if strings.TrimSpace(actualContent) != expectedViewContent { - t.Error(fmt.Printf("Expected view content to be: %q got: %q", expectedViewContent, actualContent)) + // check view content + assertView(t, testingScreen, viewName, expectedViewContent2) + + // Send a key to gocui + testingScreen.SendKeySync(KeyF3) + + // check view content + assertView(t, testingScreen, viewName, expectedViewContent3) +} + +func TestTestingScreenParallelKeys(t *testing.T) { + // Track what happened in the view, we'll assert on these + didCallCTRLC := false + didCallF1 := false + didCallF2 := false + didCallF3 := false + expectedViewContent := "Hello world!" + viewName := "testView1" + + // Create a view specifying the "OutputSimulator" mode + g, err := NewGui(OutputSimulator, true) + if err != nil { + log.Panicln(err) + } + g.SetManagerFunc(func(g *Gui) error { + maxX, maxY := g.Size() + if v, err := g.SetView(viewName, maxX/2-7, maxY/2, maxX/2+7, maxY/2+2, 0); err != nil { + if !errors.Is(err, ErrUnknownView) { + return err + } + + if _, err := g.SetCurrentView(viewName); err != nil { + return err + } + + // Have the view draw "Hello world!" + fmt.Fprintln(v, expectedViewContent) + } + + return nil + }) + + // Create a key bindings + if err := g.SetKeybinding("", KeyCtrlC, ModNone, func(g *Gui, v *View) error { + didCallCTRLC = true + return nil + }); err != nil { + log.Panicln(err) + } + + if err := g.SetKeybinding("", KeyF1, ModNone, func(g *Gui, v *View) error { + didCallF1 = true + return nil + }); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding("", KeyF2, ModNone, func(g *Gui, v *View) error { + didCallF2 = true + return nil + }); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding("", KeyF3, ModNone, func(g *Gui, v *View) error { + didCallF3 = true + return nil + }); err != nil { + log.Panicln(err) + } + + // Create a test screen and start gocui + testingScreen := g.GetTestingScreen() + cleanup := testingScreen.StartGui() + defer cleanup() + + // check view content + assertView(t, testingScreen, viewName, expectedViewContent) + + // Send a key to gocui + testingScreen.SendKeySync(KeyCtrlC) + var wg sync.WaitGroup + wg.Add(3) + go func() { + testingScreen.SendKeySync(KeyF1) + wg.Done() + }() + go func() { + testingScreen.SendKeySync(KeyF2) + wg.Done() + }() + go func() { + testingScreen.SendKeySync(KeyF3) + wg.Done() + }() + + wg.Wait() + + // Test that the keybinding fired + if !didCallCTRLC { + t.Error("Expect the simulator to invoke the key handler for CTRLC") + } + if !didCallF1 || !didCallF2 || !didCallF3 { + t.Error("Expect the simulator to invoke the key handler for F1, F2 and F3") + } +} + +// assertView checks if view contains provided content. +func assertView(t *testing.T, ts TestingScreen, viewName, content string) { + t.Helper() + // Get the content from the testing screen + if actualContent, err := ts.GetViewContent(viewName); err != nil { + t.Error(err) + } else { + // Test that it contains the "Hello World!" we thought the view should draw + if strings.TrimSpace(actualContent) != content { + t.Error(fmt.Printf("Expected view content to be: %q got: %q", content, actualContent)) + } } } From 457781ca8f6c135b98d2baba02425c9a170d0d97 Mon Sep 17 00:00:00 2001 From: Daniel Konecny Date: Mon, 25 Oct 2021 11:38:10 +0200 Subject: [PATCH 2/3] Change for-loop to more Go-like --- gui.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gui.go b/gui.go index 88ca177..76bcb5c 100644 --- a/gui.go +++ b/gui.go @@ -510,7 +510,8 @@ func (g *Gui) MainLoop() error { } // used during testing for synchronization if g.testNotify != nil && g.testCounter > 0 { - for ; g.testCounter > 0; g.testCounter-- { + for g.testCounter > 0 { + g.testCounter-- g.testNotify <- struct{}{} } } From 923b8626ec661012731fe1dbdbb049c208f87359 Mon Sep 17 00:00:00 2001 From: Daniel Konecny Date: Mon, 25 Oct 2021 11:45:06 +0200 Subject: [PATCH 3/3] Minor updates in test --- testing_test.go | 57 +++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/testing_test.go b/testing_test.go index efabc22..3d3a8f5 100644 --- a/testing_test.go +++ b/testing_test.go @@ -7,7 +7,6 @@ package gocui import ( "errors" "fmt" - "log" "strings" "sync" "testing" @@ -23,7 +22,7 @@ func TestTestingScreenReturnsCorrectContent(t *testing.T) { // Create a view specifying the "OutputSimulator" mode g, err := NewGui(OutputSimulator, true) if err != nil { - log.Panicln(err) + t.Error(err) } g.SetManagerFunc(func(g *Gui) error { maxX, maxY := g.Size() @@ -49,7 +48,7 @@ func TestTestingScreenReturnsCorrectContent(t *testing.T) { return nil } if err := g.SetKeybinding("", KeyCtrlC, ModNone, exampleBindingToTest); err != nil { - log.Panicln(err) + t.Error(err) } // Create a test screen and start gocui @@ -57,6 +56,7 @@ func TestTestingScreenReturnsCorrectContent(t *testing.T) { cleanup := testingScreen.StartGui() defer cleanup() + // NOTE: This sequence can be replaced with `testingScreen.SendKeySync(KeyCtrlC)` (we keep it for covering the use case) // Send a key to gocui testingScreen.SendKey(KeyCtrlC) // Wait for key to be processed @@ -83,7 +83,7 @@ func TestTestingScreenMultipleKeys(t *testing.T) { // Create a view specifying the "OutputSimulator" mode g, err := NewGui(OutputSimulator, true) if err != nil { - log.Panicln(err) + t.Error(err) } g.SetManagerFunc(func(g *Gui) error { maxX, maxY := g.Size() @@ -109,7 +109,7 @@ func TestTestingScreenMultipleKeys(t *testing.T) { return nil } if err := g.SetKeybinding("", KeyCtrlC, ModNone, exampleBindingToTest); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF1, ModNone, func(g *Gui, v *View) error { @@ -117,7 +117,7 @@ func TestTestingScreenMultipleKeys(t *testing.T) { fmt.Fprintln(v, expectedViewContent1) return nil }); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF2, ModNone, func(g *Gui, v *View) error { v.Clear() @@ -125,14 +125,14 @@ func TestTestingScreenMultipleKeys(t *testing.T) { fmt.Fprintln(v, expectedViewContent2) return nil }); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF3, ModNone, func(g *Gui, v *View) error { v.Clear() fmt.Fprintln(v, expectedViewContent3) return nil }); err != nil { - log.Panicln(err) + t.Error(err) } // Create a test screen and start gocui @@ -151,23 +151,20 @@ func TestTestingScreenMultipleKeys(t *testing.T) { t.Error("Expect the simulator to invoke the key handler for CTRLC") } - // Send a key to gocui - testingScreen.SendKeySync(KeyF1) - - // check view content - assertView(t, testingScreen, viewName, expectedViewContent1) - - // Send a key to gocui - testingScreen.SendKeySync(KeyF2) - - // check view content - assertView(t, testingScreen, viewName, expectedViewContent2) - - // Send a key to gocui - testingScreen.SendKeySync(KeyF3) - - // check view content - assertView(t, testingScreen, viewName, expectedViewContent3) + tests := []struct { + key Key + content string + }{ + {KeyF1, expectedViewContent1}, + {KeyF2, expectedViewContent2}, + {KeyF3, expectedViewContent3}, + } + for _, key := range tests { + // Send a key to gocui + testingScreen.SendKeySync(key.key) + // check view content + assertView(t, testingScreen, viewName, key.content) + } } func TestTestingScreenParallelKeys(t *testing.T) { @@ -182,7 +179,7 @@ func TestTestingScreenParallelKeys(t *testing.T) { // Create a view specifying the "OutputSimulator" mode g, err := NewGui(OutputSimulator, true) if err != nil { - log.Panicln(err) + t.Error(err) } g.SetManagerFunc(func(g *Gui) error { maxX, maxY := g.Size() @@ -207,26 +204,26 @@ func TestTestingScreenParallelKeys(t *testing.T) { didCallCTRLC = true return nil }); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF1, ModNone, func(g *Gui, v *View) error { didCallF1 = true return nil }); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF2, ModNone, func(g *Gui, v *View) error { didCallF2 = true return nil }); err != nil { - log.Panicln(err) + t.Error(err) } if err := g.SetKeybinding("", KeyF3, ModNone, func(g *Gui, v *View) error { didCallF3 = true return nil }); err != nil { - log.Panicln(err) + t.Error(err) } // Create a test screen and start gocui