Files
sanspie 768a59fdd6
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 25m31s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 9m28s
ci / release (push) Skipped
editor: show documentation navigation icons
2026-11-10 02:20:58 +03:00

310 lines
10 KiB
Go

package docs
import (
"slices"
"strings"
"testing"
"testing/fstest"
"aego/app/docicons"
"aego/app/docstool"
"aego/core/mathx"
"aego/editor/core/services"
regdocs "aego/engine/docs"
"aego/engine/i18n"
"aego/engine/module"
"aego/platform/input"
"aego/ui/core"
)
func testRegistry() *regdocs.Registry {
files := fstest.MapFS{
"guide.md": {Data: []byte("---\nid: quickstart\ngroup: start\norder: 1\n---\n# Quick start\n\nStart.\n\n---\nid: quickstart/child\nparent: quickstart\n---\n# First steps\n\nSteps.\n")},
"guide.ru.md": {Data: []byte("---\nid: quickstart\nsearch_names: [быстрый старт]\n---\n# Быстрый старт\n\nНачало.\n\n---\nid: quickstart/child\n---\n# Первые шаги\n\nШаги.\n")},
"flight.md": {Data: []byte("---\nid: flight\ngroup: flight\ngroup_title: Flight\ngroup_icon: cat.camera\ngroup_order: 45\nicon: file.prefab\norder: 1\n---\n# Flight\n\nFly.\n\n---\nid: flight/child\nparent: flight\n---\n# Wings\n\nFlap.\n")},
"flight.ru.md": {Data: []byte("---\nid: flight\ngroup: flight\ngroup_title: Полёт\n---\n# Полёт\n\nЛетать.\n")},
"animation.md": {Data: []byte("---\nid: animation\ngroup: flight\nicon: play.start\norder: 2\n---\n# Animation\n\nAnimate.\n")},
"animation.ru.md": {Data: []byte("---\nid: animation\nsearch_names: [анимация]\n---\n# Анимация\n\nАнимировать.\n")},
}
r := regdocs.Assemble(regdocs.Sources{Docs: []module.DocSource{{Owner: "test", FS: files}}})
r.SetNavigationIcons(docicons.Builtin())
return r
}
func findRow(p *Panel, id string) (row, bool) {
for _, r := range p.rows {
if r.id == id {
return r, true
}
}
return row{}, false
}
func TestLocalizedNavigationAndSearchKeepIcons(t *testing.T) {
bundle := i18n.Default()
old := bundle.Language()
defer bundle.SetLanguage(old)
p := New(testRegistry(), nil, nil)
p.Show("quickstart/child", "")
p.open[groupKey("flight")] = true
var order []string
for _, tc := range []struct{ lang, groupTitle, rootTitle, childTitle string }{
{"en", "Flight", "Quick start", "First steps"},
{"ru", "Полёт", "Быстрый старт", "Первые шаги"},
} {
t.Run(tc.lang, func(t *testing.T) {
bundle.SetLanguage(tc.lang)
p.query = ""
p.rebuild()
var ids []string
for _, rw := range p.rows {
if !rw.head {
ids = append(ids, rw.id)
}
}
if order == nil {
order = ids
} else if !slices.Equal(ids, order) {
t.Errorf("language changed navigation order: %v vs %v", ids, order)
}
for _, expected := range []struct{ id, title, icon string }{
{groupKey("flight"), tc.groupTitle, "cat.camera"},
{"quickstart", tc.rootTitle, "doc.project"},
{"quickstart/child", tc.childTitle, "doc.project"},
{"flight", map[string]string{"en": "Flight", "ru": "Полёт"}[tc.lang], "file.prefab"},
} {
got, ok := findRow(p, expected.id)
if !ok || got.title != expected.title || got.icon != expected.icon {
t.Errorf("row %q = %+v (%v), want title %q icon %q", expected.id, got, ok, expected.title, expected.icon)
}
}
p.query = "quickstart"
p.rebuild()
hits := p.reg.Search(p.query, 200)
if len(p.rows) != len(hits) {
t.Fatalf("search row count = %d, want %d", len(p.rows), len(hits))
}
for i, hit := range hits {
if p.rows[i].id != hit.ID {
t.Errorf("search order changed at %d: %q, want %q", i, p.rows[i].id, hit.ID)
}
}
got, ok := findRow(p, "quickstart/child")
if !ok || got.title != tc.childTitle+" · page" || got.icon != "doc.project" {
t.Errorf("search row = %+v (%v)", got, ok)
}
if p.Current() != "quickstart/child" {
t.Errorf("rebuild changed the current page: %s", p.Current())
}
if tc.lang == "ru" {
p.query = "быстрый старт"
p.rebuild()
if got, ok := findRow(p, "quickstart"); !ok || got.icon != "doc.project" {
t.Errorf("localized search row = %+v (%v)", got, ok)
}
}
})
}
}
func TestModuleIconPolicyInLocalizedTreeAndSearch(t *testing.T) {
bundle := i18n.Default()
old := bundle.Language()
defer bundle.SetLanguage(old)
p := New(testRegistry(), nil, nil)
p.Show("animation", "")
for _, tc := range []struct{ lang, title, query string }{
{"en", "Animation", "animation"},
{"ru", "Анимация", "анимация"},
} {
t.Run(tc.lang, func(t *testing.T) {
bundle.SetLanguage(tc.lang)
p.query = ""
p.rebuild()
rw, ok := findRow(p, "animation")
if !ok || rw.title != tc.title || rw.icon != "animation.player" {
t.Fatalf("localized navigation row = %+v (%v)", rw, ok)
}
p.query = tc.query
p.rebuild()
rw, ok = findRow(p, "animation")
if !ok || rw.title != tc.title+" · page" || rw.icon != "animation.player" || p.Current() != "animation" {
t.Fatalf("localized search row = %+v (%v), current %q", rw, ok, p.Current())
}
})
}
}
// Exercise the actual composed registry through the panel, not just a fixture
// policy: navigation, search and language changes must keep module icons.
func TestComposedRegistryModuleIconsInNavigationAndSearch(t *testing.T) {
reg, err := docstool.Registry(nil, nil)
if err != nil {
t.Fatal(err)
}
bundle := i18n.Default()
old := bundle.Language()
defer bundle.SetLanguage(old)
p := New(reg, nil, nil)
set := embeddedIcons(t)
for _, lang := range []string{"en", "ru"} {
t.Run(lang, func(t *testing.T) {
bundle.SetLanguage(lang)
for _, tc := range []struct{ id, icon string }{
{"animation", "animation.player"},
{"animation/animator", "animation.animator"},
{"render2d.PostProcess", "view.overlays"},
{"camera.CameraBrain2D", "view.split"},
{"aego/audio", "cat.audio"},
{"aego/timer", "code.timer"},
{"section/animation", "animation.player"},
{"platformer-effects", "render2d.point_light"},
} {
p.Show(tc.id, "")
if p.Current() != tc.id {
t.Errorf("Show(%q) selected %q", tc.id, p.Current())
continue
}
localized, ok := p.get(tc.id)
if !ok {
t.Fatalf("missing composed entry %q", tc.id)
}
p.query = ""
p.rebuild()
rw, ok := findRow(p, tc.id)
if !ok || rw.title != localized.Title || rw.icon != tc.icon {
t.Errorf("%s tree row %q = %+v (%v)", lang, tc.id, rw, ok)
}
p.query = tc.id
p.rebuild()
rw, ok = findRow(p, tc.id)
if !ok || rw.title != localized.Title+" · "+localized.Kind.String() || rw.icon != tc.icon {
t.Errorf("%s search row %q = %+v (%v)", lang, tc.id, rw, ok)
}
if tc.id == "animation" {
want := map[string]string{"en": "Animation", "ru": "Анимация"}[lang]
if localized.Title != want {
t.Errorf("%s animation title = %q, want %q", lang, localized.Title, want)
}
}
if tc.id == "aego/timer" && !set.Has(tc.icon) && availableIcon(rw.icon, set.Has) != "play.speed" {
t.Errorf("%s timer icon did not use its baked fallback", lang)
}
}
})
}
}
func TestPanelInputSearchRevealAndLanguageSwitch(t *testing.T) {
bundle := i18n.Default()
old := bundle.Language()
defer bundle.SetLanguage(old)
bundle.SetLanguage("en")
p := New(testRegistry(), nil, nil)
c := core.New(nil)
c.Icons = embeddedIcons(t)
in := &input.State{}
full := mathx.Rect{W: 800, H: 200}
frame := func(apply func()) {
in.BeginFrame()
if apply != nil {
apply()
}
c.Begin(in, mathx.Vec2{X: full.W, Y: full.H}, 1)
p.Draw(&services.Frame{UI: c}, full)
c.End()
}
// A distant current row is still opened and scrolled into view.
codes := p.reg.Children(regdocs.DiagnosticsID)
if len(codes) == 0 {
t.Fatal("fixture has no diagnostics")
}
last := codes[len(codes)-1].ID
p.Show(last, "")
frame(nil)
if p.Current() != last || p.scroll <= 0 || !p.open[groupKey(regdocs.GroupDiagnostics)] || !p.open[regdocs.DiagnosticsID] {
t.Fatalf("current page not revealed: %q scroll=%g open=%v", p.Current(), p.scroll, p.open)
}
beforeScroll := p.scroll
bundle.SetLanguage("ru")
frame(nil)
if p.Current() != last || p.scroll != beforeScroll {
t.Fatalf("language switch changed active row or tree scroll: %q %g (was %g)", p.Current(), p.scroll, beforeScroll)
}
bundle.SetLanguage("en")
frame(nil)
// Existing keyboard text input and result selection remain usable with icons.
at := mathx.Vec2{X: 20, Y: 10}
frame(func() { in.ApplyMouseDown(at, input.MouseLeft) })
frame(func() { in.ApplyMouseUp(at, input.MouseLeft) })
frame(func() { in.ApplyText([]byte("quickstart")) })
if p.query != "quickstart" || c.Focus() == 0 {
t.Fatalf("keyboard search lost focus or input: query=%q focus=%v", p.query, c.Focus())
}
rw, ok := findRow(p, "quickstart")
if !ok || rw.icon != "doc.project" {
t.Fatalf("search result missing icon: %+v", rw)
}
idx := -1
for i, r := range p.rows {
if r.id == "quickstart" {
idx = i
break
}
}
y := c.Theme.RowHeight + 4 + c.Theme.Spacing + (float32(idx)+0.5)*c.Theme.RowHeight - p.scroll
item := mathx.Vec2{X: 100, Y: y}
frame(func() { in.ApplyMouseDown(item, input.MouseLeft) })
frame(func() { in.ApplyMouseUp(item, input.MouseLeft) })
if p.Current() != "quickstart" {
t.Fatalf("search result click selected %q", p.Current())
}
p.text.Scroll = 37
bundle.SetLanguage("ru")
frame(nil)
rw, ok = findRow(p, "quickstart")
if !ok || !strings.HasPrefix(rw.title, "Быстрый старт") || rw.icon != "doc.project" || p.Current() != "quickstart" || p.text.Scroll != 37 {
t.Fatalf("language switch lost selection, scroll or icon: row=%+v current=%q scroll=%g", rw, p.Current(), p.text.Scroll)
}
}
func TestGroupIconDoesNotChangeRowToggle(t *testing.T) {
p := New(testRegistry(), nil, nil)
p.Show("flight", "")
c := core.New(nil)
c.Icons = embeddedIcons(t)
in := &input.State{}
full := mathx.Rect{W: 800, H: 600}
frame := func(apply func()) {
in.BeginFrame()
if apply != nil {
apply()
}
c.Begin(in, mathx.Vec2{X: full.W, Y: full.H}, 1)
p.Draw(&services.Frame{UI: c}, full)
c.End()
}
frame(nil)
idx := -1
for i, rw := range p.rows {
if rw.id == groupKey("flight") {
idx = i
break
}
}
if idx < 0 || !p.open[groupKey("flight")] {
t.Fatal("current group was not opened")
}
// Hit the icon/label, not the disclosure arrow.
y := c.Theme.RowHeight + 4 + c.Theme.Spacing + (float32(idx)+0.5)*c.Theme.RowHeight - p.scroll
at := mathx.Vec2{X: c.Theme.Indent + 10, Y: y}
frame(func() { in.ApplyMouseDown(at, input.MouseLeft) })
frame(func() { in.ApplyMouseUp(at, input.MouseLeft) })
if p.open[groupKey("flight")] || p.Current() != "flight" {
t.Fatalf("group click changed selection or failed to toggle: open=%v current=%q", p.open[groupKey("flight")], p.Current())
}
}