From 5a5b04fab6c561f5071cd58dc1dc3c0619e70457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 13:59:52 -0400 Subject: [PATCH 1/4] docs_test: docs and gallery pages must match one-to-one, no fixed count --- docs_test.go | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs_test.go b/docs_test.go index ac48b43..cc19843 100644 --- a/docs_test.go +++ b/docs_test.go @@ -11,6 +11,7 @@ import ( var ( docsGoBlock = regexp.MustCompile("(?s)```go\n(.*?)```") docsSpace = regexp.MustCompile(`\s+`) + docsPageID = regexp.MustCompile(`ID: "(\d{3})"`) ) // TestDocSnippetsAreCompiledExamples keeps every doc's first Go block inside a compiled Example. @@ -32,8 +33,33 @@ func TestDocSnippetsAreCompiledExamples(t *testing.T) { if err != nil { t.Fatal(err) } - if len(docs) != 100 { - t.Fatalf("%d component docs, want 100", len(docs)) + pages, err := filepath.Glob("examples/gallery/pages_*.go") + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, f := range pages { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, m := range docsPageID.FindAllSubmatch(b, -1) { + ids[string(m[1])] = true + } + } + documented := map[string]bool{} + for _, d := range docs { + documented[filepath.Base(d)[:3]] = true + } + for id := range ids { + if !documented[id] { + t.Errorf("gallery page %s has no doc", id) + } + } + for id := range documented { + if !ids[id] { + t.Errorf("doc %s has no gallery page", id) + } } for _, d := range docs { b, err := os.ReadFile(d) From f9c0a5b558d76f483b73b605a3979e168294a26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:03:18 -0400 Subject: [PATCH 2/4] Snapshots render in a fresh child process: pixels no longer depend on test order --- AGENTS.md | 2 +- docs/upstream.md | 2 +- snapshot_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c1f959a..376e0de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ testdata/snapshots/ /-.png - `ui.NewTester` drives click, keys, typing. Assert values, not existence. - Every component: one `snapshot(t, name, w, h, view)` call (light and dark). Refresh with `go test -run -update .` - Run `go vet ./... && go test ./...` before reporting done. -- Snapshots assume the default test order. MyGo shares its path-mask cache across a process, and its own chevrons are not snapped, so `-shuffle` can shift a few pixels (docs/upstream.md). +- Each snapshot test renders in a fresh child process (snapshot_test.go), because MyGo's path-mask cache is process-wide. Order and neighbours never change pixels; `-run -update` is safe. ## Gallery page diff --git a/docs/upstream.md b/docs/upstream.md index 565e3e4..beea964 100644 --- a/docs/upstream.md +++ b/docs/upstream.md @@ -35,4 +35,4 @@ PRD 第 2 节要求公开接口不足时向上游提交扩展。以下缺口在 | `SelectBase` 高亮无设置接口 | `ui/base.go:260` | Select | | `TextInputBase` 无只读开关与占位符 | `ui/editor.go:139,734,811` | TextInput 只读模式、CommandPalette | | `ui.Stepper` 禁用时仍可步进,固定 9 位取整 | `ui/indicators.go:216`、`ui/widgets.go` `snap` | NumberInput(已改为自绘) | -| 路径遮罩缓存按四分之一像素取键,进程内共享 | `ui/path.go:189-210` | 自绘路径以 `internal.Snap` 对齐;MyGo 自绘的箭头(Outline 等)无法对齐,快照依赖测试运行顺序,`-shuffle` 下 TreeTable 快照会出现 6 像素差异 | +| 路径遮罩缓存按四分之一像素取键,进程内共享 | `ui/path.go:189-210` | 自绘路径以 `internal.Snap` 对齐;MyGo 自绘的箭头(Outline 等)无法对齐;快照测试因此在独立子进程中渲染,结果与测试顺序无关 | diff --git a/snapshot_test.go b/snapshot_test.go index acab2e2..49940ac 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -6,9 +6,12 @@ import ( "image" "image/png" "os" + "os/exec" "path/filepath" + "regexp" "runtime" "strings" + "sync" "testing" "github.com/egoist/mygo/ui" @@ -22,9 +25,18 @@ func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) { snapshotAfter(t, name, w, h, view, nil) } +// snapshotChild marks a process that renders snapshots itself. +const snapshotChild = "MUJICA_SNAPSHOT_CHILD" + +var snapshotRan sync.Map + // snapshotAfter is snapshot with drive sending input before the capture. func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) { t.Helper() + if os.Getenv(snapshotChild) == "" { + snapshotInChild(t) + return + } for _, m := range []Mode{Light, Dark} { ready := false tt := ui.NewTester(func(c *ui.Context) { @@ -64,6 +76,27 @@ func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context) } } +// snapshotInChild reruns the test alone in a fresh process; MyGo's path cache is process-wide. +func snapshotInChild(t *testing.T) { + t.Helper() + if _, done := snapshotRan.LoadOrStore(t.Name(), true); done { + return + } + parts := strings.Split(t.Name(), "/") + for i, p := range parts { + parts[i] = "^" + regexp.QuoteMeta(p) + "$" + } + args := []string{"-test.run=" + strings.Join(parts, "/"), "-test.count=1"} + if *update { + args = append(args, "-update") + } + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), snapshotChild+"=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("snapshot child failed: %v\n%s", err, out) + } +} + func encode(t *testing.T, img image.Image) []byte { t.Helper() var b bytes.Buffer From d0082ba04774edfa8c2f61101324bb80843b7a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:05:37 -0400 Subject: [PATCH 3/4] Gallery test: wait for the visible overlay; no password in failure output --- examples/gallery/composite_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/gallery/composite_test.go b/examples/gallery/composite_test.go index 1526d8e..101805c 100644 --- a/examples/gallery/composite_test.go +++ b/examples/gallery/composite_test.go @@ -111,11 +111,11 @@ func TestCompositeFormInvalidThenValid(t *testing.T) { click(t, tt, "I accept the covenant of the masquerade") if s.name != "Mortis" || s.password != "hunter22secret" || s.debut.Format("2006-01-02") != "2026-12-24" || !slices.Equal(s.tags, []string{"guitar"}) || !s.terms { - t.Fatalf("values %q %q %v %q %v", s.name, s.password, s.debut, s.tags, s.terms) + t.Fatalf("values %q, password ok %v, %v %q %v", s.name, s.password == "hunter22secret", s.debut, s.tags, s.terms) } click(t, tt, "Submit entry") - await(tt, s.st.Submitting) // the name check finishes first - if !s.st.Submitting() || !tt.HasText("Submitting…") { + await(tt, func() bool { return tt.HasText("Submitting…") }) // the name check finishes first + if !s.st.Submitting() { t.Fatalf("no loading overlay in %q", tt.Texts()) } click(t, tt, "Submit entry") // the overlay blocks a second submit @@ -124,9 +124,9 @@ func TestCompositeFormInvalidThenValid(t *testing.T) { t.Fatalf("phase %d, texts %q", s.phase, tt.Texts()) } submits := 0 - for _, e := range g.events { + for i, e := range g.events { if strings.Contains(e.text, "hunter22secret") { - t.Fatalf("password logged: %q", e.text) + t.Fatalf("password logged in event %d of page %s", i, e.page) } if e.page == formID && strings.HasPrefix(e.text, "submitted ") { submits++ From 486d2c6a8659d2e8fc8800361f735808a75a986b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:09:53 -0400 Subject: [PATCH 4/4] Web mirror: idle polling for timers, true wheel sign, modified click on release, numpad, Alt text, every announcement and copy, cursor notices, checked writes --- examples/gallery/main.go | 5 +- examples/gallery/web.go | 208 +++++++++----------------------- examples/gallery/web/index.html | 2 +- examples/gallery/web_input.go | 126 +++++++++++++++++++ examples/gallery/web_keys.go | 4 + examples/gallery/web_test.go | 127 +++++++++++++++++++ 6 files changed, 318 insertions(+), 154 deletions(-) create mode 100644 examples/gallery/web_input.go diff --git a/examples/gallery/main.go b/examples/gallery/main.go index 7dfd576..0b0656a 100644 --- a/examples/gallery/main.go +++ b/examples/gallery/main.go @@ -1,7 +1,4 @@ -// Gallery tours every MujicaUI component in a native MyGo window. -// -// go run ./examples/gallery native window -// go run ./examples/gallery -web :8080 browser mirror +// Gallery tours MujicaUI natively, or mirrored to a browser with -web. package main import ( diff --git a/examples/gallery/web.go b/examples/gallery/web.go index c2e0ea9..26c424f 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -20,18 +20,20 @@ var indexHTML []byte // mirror runs the gallery view headless and streams its frames. type mirror struct { - mu sync.Mutex - tt *ui.Tester - frame []byte - seq int - hash uint64 - seed maphash.Seed - subs map[chan []byte]struct{} - wake chan struct{} - urls int - told int - clip string - enc png.Encoder + mu sync.Mutex + tt *ui.Tester + frame []byte + seq int + hash uint64 + seed maphash.Seed + subs map[chan []byte]struct{} + wake chan struct{} + urls int + clip string + copied bool + cursor ui.Cursor + down *input + enc png.Encoder } func serveWeb(addr string, view func(c *ui.Context)) error { @@ -54,7 +56,9 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(indexHTML) + if _, err := w.Write(indexHTML); err != nil { + slog.Warn("gallery: web page write failed", "err", err) + } }) mux.HandleFunc("GET /frame.png", m.serveFrame) mux.HandleFunc("GET /events", m.serveEvents) @@ -62,20 +66,27 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) { return m, mux } -// loop draws frames while the picture keeps changing, then sleeps. +// loop frames at 33 ms while changing, else polls at 200 ms for timers. func (m *mirror) loop() { - for range m.wake { - for still := 0; still < 6; { - time.Sleep(33 * time.Millisecond) - m.mu.Lock() - m.tt.Frame() - changed := m.capture() - m.mu.Unlock() - if changed { - still = 0 - } else { - still++ - } + still := 0 + for { + wait := 33 * time.Millisecond + if still > 30 { + wait = 200 * time.Millisecond + } + select { + case <-m.wake: + still = 0 + case <-time.After(wait): + } + m.mu.Lock() + m.tt.Frame() + changed := m.capture() + m.mu.Unlock() + if changed { + still = 0 + } else { + still++ } } } @@ -87,12 +98,12 @@ func (m *mirror) kick() { } } -// capture encodes the frame and notifies browsers; the caller holds mu. +// capture encodes a changed frame and notifies browsers; the caller holds mu. func (m *mirror) capture() bool { img := m.tt.Image() h := maphash.Bytes(m.seed, img.Pix) notice := m.notices() - if h == m.hash && notice == nil { + if h == m.hash && len(notice) == 0 { return false } if h != m.hash { @@ -103,11 +114,8 @@ func (m *mirror) capture() bool { m.frame, m.hash = b.Bytes(), h m.seq++ } - if notice == nil { - notice = map[string]any{} - } notice["seq"] = m.seq - notice["cursor"] = cursorCSS(m.tt.Cursor()) + notice["cursor"] = cursorCSS(m.cursor) msg, err := json.Marshal(notice) if err != nil { panic(err) @@ -122,20 +130,21 @@ func (m *mirror) capture() bool { return true } -// notices gathers opened URLs, announcements and clipboard changes. +// notices gathers opened URLs, announcements, clipboard writes and cursor changes. func (m *mirror) notices() map[string]any { n := map[string]any{} if urls := m.tt.OpenedURLs(); len(urls) > m.urls { n["open"], m.urls = urls[m.urls:], len(urls) } - if told := m.tt.Announcements(); len(told) > m.told { - n["announce"], m.told = told[m.told:], len(told) + if told := m.tt.Announcements(); len(told) > 0 { + n["announce"] = told } - if clip := m.tt.Clipboard(); clip != m.clip { - n["clipboard"], m.clip = clip, clip + if clip := m.tt.Clipboard(); clip != m.clip || m.copied { + n["clipboard"], m.clip, m.copied = clip, clip, false } - if len(n) == 0 { - return nil + if c := m.tt.Cursor(); c != m.cursor { + m.cursor = c + n["cursorChanged"] = true } return n } @@ -146,7 +155,9 @@ func (m *mirror) serveFrame(w http.ResponseWriter, r *http.Request) { m.mu.Unlock() w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "no-store") - w.Write(frame) + if _, err := w.Write(frame); err != nil { + slog.Warn("gallery: web frame write failed", "err", err) + } } func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) { @@ -169,122 +180,21 @@ func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) { }() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") - fmt.Fprintf(w, "data: %s\n\n", first) + if _, err := fmt.Fprintf(w, "data: %s\n\n", first); err != nil { + slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err) + return + } flusher.Flush() for { select { case msg := <-ch: - fmt.Fprintf(w, "data: %s\n\n", msg) + if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil { + slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err) + return + } flusher.Flush() case <-r.Context().Done(): return } } } - -// input is one browser event. -type input struct { - Type string `json:"type"` - X float32 `json:"x"` - Y float32 `json:"y"` - DX float32 `json:"dx"` - DY float32 `json:"dy"` - Button int `json:"button"` - Code string `json:"code"` - Text string `json:"text"` - Caret int `json:"caret"` - Mods []bool `json:"mods"` - W int `json:"w"` - H int `json:"h"` - Scale float32 `json:"scale"` - Dark bool `json:"dark"` - Motion bool `json:"reduceMotion"` - More bool `json:"moreContrast"` -} - -func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { - var in input - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.mu.Lock() - err := m.apply(in) - if err == nil { - m.capture() - } - m.mu.Unlock() - if err != nil { - slog.Warn("gallery: web input rejected", "type", in.Type, "err", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.kick() - w.WriteHeader(http.StatusNoContent) -} - -// apply sends one event to the tester; the caller holds mu. -func (m *mirror) apply(in input) error { - mods := modifiers(in.Mods) - switch in.Type { - case "down": - if in.Button == 2 { - m.tt.RightClickAt(in.X, in.Y) - } else if mods != 0 { - m.tt.ClickAtWith(mods, in.X, in.Y) - } else { - m.tt.Press(in.X, in.Y) - } - case "up": - if in.Button == 0 && mods == 0 { - m.tt.Release(in.X, in.Y) - } - case "move": - m.tt.Move(in.X, in.Y) - case "wheel": - m.tt.Scroll(in.X, in.Y, in.DX, in.DY) - case "key": - k, ok := keyCodes[in.Code] - if !ok { - return fmt.Errorf("unmapped key code %q", in.Code) - } - if in.Text != "" && mods&^ui.Shift == 0 { - m.tt.TypeKey(mods, k, in.Text) - } else { - m.tt.Key(mods, k) - } - case "text": - m.tt.Type(in.Text) - case "compose": - m.tt.Compose(in.Text, in.Caret) - case "paste": - m.tt.SetClipboard(in.Text) - m.clip = in.Text - m.tt.Command("paste") - case "command": - m.tt.Command(in.Text) - case "resize": - if in.W < 320 || in.H < 240 || in.Scale <= 0 { - return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale) - } - m.tt.SetSize(in.W, in.H) - m.tt.SetScale(in.Scale) - m.tt.SetDark(in.Dark) - m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1}) - case "focus": - m.tt.SetFocused(in.Text == "in") - default: - return fmt.Errorf("unknown input type %q", in.Type) - } - return nil -} - -func modifiers(m []bool) ui.Modifiers { - var out ui.Modifiers - for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} { - if i < len(m) && m[i] { - out |= mod - } - } - return out -} diff --git a/examples/gallery/web/index.html b/examples/gallery/web/index.html index 98572c4..96ca311 100644 --- a/examples/gallery/web/index.html +++ b/examples/gallery/web/index.html @@ -139,7 +139,7 @@ canvas.addEventListener("pointermove", (e) => { }); canvas.addEventListener("wheel", (e) => { const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1; - send({ type: "wheel", dx: -e.deltaX * k, dy: -e.deltaY * k, ...at(e) }); e.preventDefault(); + send({ type: "wheel", dx: e.deltaX * k, dy: e.deltaY * k, ...at(e) }); e.preventDefault(); }, { passive: false }); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("focus", () => keys.focus({ preventScroll: true })); diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go new file mode 100644 index 0000000..bd0456d --- /dev/null +++ b/examples/gallery/web_input.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/egoist/mygo/ui" +) + +// input is one browser event. +type input struct { + Type string `json:"type"` + X float32 `json:"x"` + Y float32 `json:"y"` + DX float32 `json:"dx"` + DY float32 `json:"dy"` + Button int `json:"button"` + Code string `json:"code"` + Text string `json:"text"` + Caret int `json:"caret"` + Mods []bool `json:"mods"` + W int `json:"w"` + H int `json:"h"` + Scale float32 `json:"scale"` + Dark bool `json:"dark"` + Motion bool `json:"reduceMotion"` + More bool `json:"moreContrast"` +} + +func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { + var in input + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.mu.Lock() + err := m.apply(in) + if err == nil { + m.capture() + } + m.mu.Unlock() + if err != nil { + slog.Warn("gallery: web input rejected", "type", in.Type, "err", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.kick() + w.WriteHeader(http.StatusNoContent) +} + +// apply sends one event to the tester; the caller holds mu. +func (m *mirror) apply(in input) error { + mods := modifiers(in.Mods) + switch in.Type { + case "down": + switch { + case in.Button == 2: + m.tt.RightClickAt(in.X, in.Y) + case mods != 0: + m.down = &in + default: + m.tt.Press(in.X, in.Y) + } + case "up": + if d := m.down; d != nil { + m.down = nil + if dx, dy := in.X-d.X, in.Y-d.Y; dx*dx+dy*dy <= 16 { + m.tt.ClickAtWith(modifiers(d.Mods), d.X, d.Y) + } else { + slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods)) + } + } else if in.Button == 0 { + m.tt.Release(in.X, in.Y) + } + case "move": + m.tt.Move(in.X, in.Y) + case "wheel": + m.tt.Scroll(in.X, in.Y, in.DX, in.DY) + case "key": + k, ok := keyCodes[in.Code] + if !ok { + return fmt.Errorf("unmapped key code %q", in.Code) + } + if in.Text != "" && mods&^(ui.Shift|ui.Alt) == 0 { + m.tt.TypeKey(mods, k, in.Text) + } else { + m.tt.Key(mods, k) + } + case "text": + m.tt.Type(in.Text) + case "compose": + m.tt.Compose(in.Text, in.Caret) + case "paste": + m.tt.SetClipboard(in.Text) + m.clip = in.Text + m.tt.Command("paste") + case "command": + m.tt.Command(in.Text) + m.copied = in.Text == "copy" || in.Text == "cut" + case "resize": + if in.W < 320 || in.H < 240 || in.Scale <= 0 { + return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale) + } + m.tt.SetSize(in.W, in.H) + m.tt.SetScale(in.Scale) + m.tt.SetDark(in.Dark) + m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1}) + case "focus": + m.tt.SetFocused(in.Text == "in") + default: + return fmt.Errorf("unknown input type %q", in.Type) + } + return nil +} + +func modifiers(m []bool) ui.Modifiers { + var out ui.Modifiers + for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} { + if i < len(m) && m[i] { + out |= mod + } + } + return out +} diff --git a/examples/gallery/web_keys.go b/examples/gallery/web_keys.go index ae987ad..d57b520 100644 --- a/examples/gallery/web_keys.go +++ b/examples/gallery/web_keys.go @@ -4,6 +4,10 @@ import "github.com/egoist/mygo/ui" // keyCodes maps KeyboardEvent.code to MyGo keys. var keyCodes = map[string]ui.Key{ + "Numpad0": ui.Key0, "Numpad1": ui.Key1, "Numpad2": ui.Key2, "Numpad3": ui.Key3, "Numpad4": ui.Key4, + "Numpad5": ui.Key5, "Numpad6": ui.Key6, "Numpad7": ui.Key7, "Numpad8": ui.Key8, "Numpad9": ui.Key9, + "NumpadDecimal": ui.KeyPeriod, "NumpadSubtract": ui.KeyMinus, "NumpadAdd": ui.KeyEqual, + "NumpadMultiply": ui.Key8, "NumpadDivide": ui.KeySlash, "NumpadEqual": ui.KeyEqual, "NumpadComma": ui.KeyComma, "Enter": ui.KeyEnter, "NumpadEnter": ui.KeyEnter, "Escape": ui.KeyEscape, diff --git a/examples/gallery/web_test.go b/examples/gallery/web_test.go index d3d61c5..66e5b35 100644 --- a/examples/gallery/web_test.go +++ b/examples/gallery/web_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/egoist/mygo/ui" @@ -133,3 +134,129 @@ func TestMirrorRejectsBadInput(t *testing.T) { t.Fatalf("bad json: %d", res.StatusCode) } } + +// mirrorWith serves view through a mirror for one test. +func mirrorWith(t *testing.T, view func(c *ui.Context)) (*mirror, *httptest.Server) { + m, h := newMirror(view) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return m, srv +} + +func postInput(t *testing.T, srv *httptest.Server, in input) { + t.Helper() + body, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + res, err := http.Post(srv.URL+"/input", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusNoContent { + t.Fatalf("%s: %d", in.Type, res.StatusCode) + } +} + +func TestMirrorTimersFireWithoutInput(t *testing.T) { + start := time.Now() + var late bool + m, _ := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + late = c.Now().Sub(start) > 300*time.Millisecond + if !late { + c.After(350 * time.Millisecond) + } + }) + time.Sleep(900 * time.Millisecond) + m.mu.Lock() + defer m.mu.Unlock() + if !late { + t.Fatal("the view was not rebuilt after its timer with no input") + } +} + +func TestMirrorModifiedClickNeedsReleaseInPlace(t *testing.T) { + clicks := 0 + _, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Seal", mujica.ButtonOptions{}).Clicked() { + clicks++ + } + }) + }) + shift := []bool{true} + postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift}) + postInput(t, srv, input{Type: "up", X: 400, Y: 400, Mods: shift}) + postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift}) + postInput(t, srv, input{Type: "up", X: 41, Y: 35, Mods: shift}) + if clicks != 1 { + t.Fatalf("clicks %d, want 1 (released elsewhere must not click)", clicks) + } +} + +func TestMirrorForwardsRepeatedCopy(t *testing.T) { + m, _ := mirrorWith(t, func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) }) + ch := make(chan []byte, 8) + m.mu.Lock() + m.subs[ch] = struct{}{} + notices := 0 + for range 2 { + m.tt.SetClipboard("same") + m.copied = true + if m.capture() { + notices++ + } + } + m.mu.Unlock() + if notices != 2 { + t.Fatalf("repeated copy of the same text notified %d times, want 2", notices) + } + for range 2 { + msg := <-ch + if !bytes.Contains(msg, []byte(`"clipboard":"same"`)) { + t.Fatalf("notice %s", msg) + } + } +} + +func TestMirrorMapsNumpad(t *testing.T) { + r := newMirrorRig(t) + r.post(input{Type: "key", Code: "Tab"}) + if code := r.post(input{Type: "key", Code: "Numpad7", Text: "7"}); code != http.StatusNoContent { + t.Fatalf("numpad: %d", code) + } + if got := r.value(); got != "7" { + t.Fatalf("value %q", got) + } +} + +func TestMirrorForwardsEveryAnnouncement(t *testing.T) { + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Ring", mujica.ButtonOptions{}).Clicked() { + c.Announce("bell rung") + } + }) + }) + ch := make(chan []byte, 64) + m.mu.Lock() + m.subs[ch] = struct{}{} + m.mu.Unlock() + for range 2 { + postInput(t, srv, input{Type: "down", X: 40, Y: 35}) + postInput(t, srv, input{Type: "up", X: 40, Y: 35}) + } + heard := 0 + for len(ch) > 0 { + if bytes.Contains(<-ch, []byte("bell rung")) { + heard++ + } + } + if heard != 2 { + t.Fatalf("announcements forwarded %d times, want 2", heard) + } +}