package app
import (
"context"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"chirami/internal/analyze"
"chirami/internal/card"
"chirami/internal/dict"
"chirami/internal/morph"
"chirami/internal/morph/mkmorph"
"chirami/internal/platform"
)
type fakeBackend struct {
sel chan string
events chan platform.Event
shots chan []byte
asked atomic.Bool // Screenshots was called
mu sync.Mutex
shows []platform.Popup
hides int
px, py float64
}
func newFake() *fakeBackend {
return &fakeBackend{sel: make(chan string, 8), events: make(chan platform.Event, 8), shots: make(chan []byte, 1)}
}
func (f *fakeBackend) Screenshots() <-chan []byte {
f.asked.Store(true)
return f.shots
}
func (f *fakeBackend) Selections() <-chan string { return f.sel }
func (f *fakeBackend) Events() <-chan platform.Event { return f.events }
func (f *fakeBackend) Locate() (platform.Anchor, error) {
return platform.Anchor{X: 100, Y: 100, Screen: platform.Rect{W: 1920, H: 1080}, Scale: 1}, nil
}
func (f *fakeBackend) Pointer() (float64, float64, bool) {
f.mu.Lock()
defer f.mu.Unlock()
return f.px, f.py, true
}
func (f *fakeBackend) Show(p platform.Popup) error {
f.mu.Lock()
f.shows = append(f.shows, p)
f.mu.Unlock()
return nil
}
func (f *fakeBackend) Hide() {
f.mu.Lock()
f.hides++
f.mu.Unlock()
}
func (f *fakeBackend) counts() (int, int) {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.shows), f.hides
}
// waitFor polls cond for up to a second.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
if cond() {
return
}
}
t.Fatalf("timed out waiting for %s", what)
}
func start(t *testing.T, jisho func(context.Context, string) ([]dict.Entry, error)) (*fakeBackend, *App) {
t.Helper()
return startWith(t, Deps{Jisho: jisho})
}
func startWith(t *testing.T, d Deps) (*fakeBackend, *App) {
t.Helper()
return startFake(t, newFake(), DefaultConfig, d)
}
func startFake(t *testing.T, f *fakeBackend, cfg Config, d Deps) (*fakeBackend, *App) {
t.Helper()
fonts, err := card.LoadFonts()
if err != nil {
t.Skip(err)
}
cfg.Debounce = 10 * time.Millisecond
d.Analyzer = &analyze.Analyzer{}
d.Renderer = &card.Renderer{Fonts: fonts, Theme: card.Night}
a := New(cfg, d)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
a.Run(ctx, f, "")
close(done)
}()
t.Cleanup(func() {
cancel()
<-done
})
return f, a
}
func TestShowAndHide(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
// the same text again does not redraw
f.sel <- "ありがとう"
time.Sleep(40 * time.Millisecond)
if s, _ := f.counts(); s != 1 {
t.Fatalf("shows = %d after repeating the selection", s)
}
f.sel <- "hello"
waitFor(t, "hide", func() bool { _, h := f.counts(); return h == 1 })
}
func TestDebounceKeepsLast(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "あ"
f.sel <- "あり"
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s >= 1 })
time.Sleep(40 * time.Millisecond)
if s, _ := f.counts(); s != 1 {
t.Fatalf("shows = %d, want one card for the settled selection", s)
}
}
func TestJishoNeverForKana(t *testing.T) {
calls := make(chan string, 4)
f, _ := start(t, func(_ context.Context, w string) ([]dict.Entry, error) {
calls <- w
return nil, nil
})
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s >= 1 })
f.sel <- "日本"
select {
case w := <-calls:
if w != "日本" {
t.Fatalf("jisho asked for %q", w)
}
case <-time.After(time.Second):
t.Fatal("jisho not asked for a kanji word the offline dictionary lacks")
}
select {
case w := <-calls:
t.Fatalf("unexpected extra jisho call for %q", w)
case <-time.After(50 * time.Millisecond):
}
}
func TestPlace(t *testing.T) {
screen := platform.Rect{X: -1920, Y: 0, W: 1920, H: 1080}
onScreen := func(c platform.Rect) bool {
return c.X >= screen.X && c.Y >= screen.Y && c.X+c.W <= screen.X+screen.W && c.Y+c.H <= screen.Y+screen.H
}
pointers := [][2]float64{{-1900, 10}, {-10, 1070}, {-960, 500}, {-10, 10}, {-1900, 1070}, {-960, 300}, {-960, 800}}
for _, hgt := range []float64{120, 420, 700, 1200} {
r := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 316, H: hgt}}
for _, p := range pointers {
for _, above := range []bool{true, false} {
x, y, _ := place(platform.Anchor{X: p[0], Y: p[1], Screen: screen, Scale: 1}, r, above)
c := platform.Rect{X: x + r.Hit.X, Y: y + r.Hit.Y, W: r.Hit.W, H: hgt}
if hgt < screen.H && !onScreen(c) {
t.Errorf("h=%v pointer %v: card %v leaves the screen", hgt, p, c)
}
if hgt <= 420 && c.Contains(p[0], p[1]) {
t.Errorf("h=%v pointer %v: card %v covers the pointer", hgt, p, c)
}
}
}
}
}
// Growing the card (expanding it, opening a kanji page) must keep it on
// screen, flipping to the other side of the pointer when needed.
func TestGrowStaysOnScreen(t *testing.T) {
screen := platform.Rect{W: 1920, H: 1080}
an := platform.Anchor{X: 900, Y: 200, Screen: screen, Scale: 1}
small := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 200, H: 90}}
big := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 316, H: 480}}
_, _, above := place(an, small, true)
if !above {
t.Fatal("a small card near the top should still fit above")
}
_, y, above := place(an, big, above)
if above || y+big.Hit.Y < an.Y {
t.Fatalf("the grown card should flip below the pointer, got above=%v top=%v", above, y+big.Hit.Y)
}
}
type idiomDict struct{}
func (idiomDict) Lookup(w string) []dict.Entry {
if w == "猫に小判" {
return []dict.Entry{{Kanji: []dict.Form{{Text: "猫に小判"}}, Senses: []dict.Sense{{Gloss: []string{"pearls before swine"}}}}}
}
return nil
}
func (d idiomDict) LookupReading(w, r string) []dict.Entry { return d.Lookup(w) }
func (idiomDict) LongestPrefix(s string, n int) (int, []dict.Entry) { return 0, nil }
func (idiomDict) Kanji(r rune) (dict.Kanji, bool) { return dict.Kanji{}, false }
func (idiomDict) Furigana(w, r string) ([]dict.Ruby, bool) { return nil, false }
// A selection the dictionary knows as a whole opens on its own entry, and
// its words stay clickable, with a way back to the whole.
func TestPhraseFocus(t *testing.T) {
fonts, err := card.LoadFonts()
if err != nil {
t.Skip(err)
}
a := New(DefaultConfig, Deps{
Analyzer: &analyze.Analyzer{Morph: tokenizer(t), Dict: idiomDict{}},
Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night},
})
f := newFake()
a.b = f
a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
a.tick = time.NewTicker(time.Hour)
ctx := context.Background()
a.selected(ctx, "猫に小判")
v := a.top().(*view)
if v.focus != wholePhrase || len(v.words) != 3 || len(v.entries) != 1 {
t.Fatalf("focus %d, %d words, %d entries", v.focus, len(v.words), len(v.entries))
}
a.click(ctx, card.Region{Action: card.Expand})
a.click(ctx, card.Region{Action: card.FocusWord, Index: 0})
if v.focus != 0 {
t.Fatalf("clicking 猫 focused %d", v.focus)
}
var back bool
for _, r := range a.res.Regions {
back = back || r.Action == card.FocusPhrase
}
if !back {
t.Fatal("no way back to the whole phrase")
}
a.click(ctx, card.Region{Action: card.FocusPhrase})
if v.focus != wholePhrase || len(v.entries) != 1 {
t.Fatalf("back to the phrase: focus %d, %d entries", v.focus, len(v.entries))
}
}
var (
morphOnce sync.Once
morphDict *morph.Dict
morphErr error
)
// tokenizer builds morph.bin once for the package's tests.
func tokenizer(t *testing.T) *morph.Dict {
morphOnce.Do(func() {
dir, err := os.MkdirTemp("", "app-morph")
if err != nil {
morphErr = err
return
}
path := filepath.Join(dir, "morph.bin")
if morphErr = mkmorph.Build(path); morphErr == nil {
morphDict, morphErr = morph.Open(path)
}
})
if morphErr != nil {
t.Fatal(morphErr)
}
return morphDict
}
// A screenshot is read by the OCR helper and its text shown as a card; with
// no OCR configured the backend is never asked for screenshots, so macOS
// never prompts for Input Monitoring.
func TestScreenshotOCR(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "ねこ"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
if f.asked.Load() {
t.Fatal("asked for screenshots without OCR")
}
var got atomic.Value
f, _ = startWith(t, Deps{OCR: func(_ context.Context, png []byte) (string, error) {
got.Store(string(png))
return "ありがとう", nil
}})
waitFor(t, "screenshots asked for", f.asked.Load)
f.shots <- []byte("png")
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
if got.Load() != "png" {
t.Fatalf("OCR got %v", got.Load())
}
}
func TestClip(t *testing.T) {
for _, c := range []struct {
in string
n int
want string
}{
{"短い。", 10, "短い。"},
{"一つ目の文。二つ目の文はとても長い", 10, "一つ目の文。"},
{"句点のないとても長い文章です", 5, "句点のない"},
} {
if got := clip(c.in, c.n); got != c.want {
t.Errorf("clip(%q, %d) = %q, want %q", c.in, c.n, got, c.want)
}
}
}
// A glance card closes when the pointer leaves it; an expanded one stays
// until Escape or a click outside (Dismiss).
func TestGlanceLeavesExpandedStays(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
f.events <- platform.Event{Kind: platform.Leave}
f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
time.Sleep(500 * time.Millisecond)
if _, h := f.counts(); h != 0 {
t.Fatal("the glance card closed although the pointer came back within the grace period")
}
f.events <- platform.Event{Kind: platform.Leave}
waitFor(t, "glance card closing", func() bool { _, h := f.counts(); return h == 1 })
f.sel <- "こんにちは"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 2 })
f.events <- platform.Event{Kind: platform.Click, X: 40, Y: 40} // a glance card expands wherever clicked
f.events <- platform.Event{Kind: platform.Leave}
time.Sleep(500 * time.Millisecond)
if _, h := f.counts(); h != 1 {
t.Fatal("the expanded card closed when the pointer left it")
}
f.events <- platform.Event{Kind: platform.Dismiss}
waitFor(t, "dismiss", func() bool { _, h := f.counts(); return h == 2 })
}
// A glance card the pointer never came to closes once the pointer is well
// away from it.
func TestGlancePointerAway(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
f.mu.Lock()
f.px, f.py = 1500, 900
f.mu.Unlock()
waitFor(t, "hide", func() bool { _, h := f.counts(); return h == 1 })
}
// The × closes an expanded card.
func TestCloseButton(t *testing.T) {
fonts, err := card.LoadFonts()
if err != nil {
t.Skip(err)
}
a := New(DefaultConfig, Deps{Analyzer: &analyze.Analyzer{}, Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night}})
f := newFake()
a.b = f
a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
a.tick = time.NewTicker(time.Hour)
ctx := context.Background()
a.selected(ctx, "ありがとう")
if hasClose(a.res) {
t.Fatal("a glance card has a ×")
}
a.click(ctx, card.Region{Action: card.Expand})
if !hasClose(a.res) {
t.Fatal("the expanded card has no ×")
}
a.click(ctx, card.Region{Action: card.Close})
if a.shown() {
t.Fatal("the × did not close the card")
}
}
func hasClose(r *card.Result) bool {
for _, reg := range r.Regions {
if reg.Action == card.Close {
return true
}
}
return false
}
// A glance card's leave timer must not close the next card.
func TestLeaveTimerDoesNotCloseNextCard(t *testing.T) {
f, _ := start(t, nil)
f.sel <- "ありがとう"
waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
f.events <- platform.Event{Kind: platform.Leave}
f.sel <- "こんにちは"
waitFor(t, "second card", func() bool { s, _ := f.counts(); return s == 2 })
time.Sleep(450 * time.Millisecond)
if _, h := f.counts(); h != 0 {
t.Fatal("the first card's leave timer closed the second card")
}
}
// Show mode with no Japanese in the text ends at once instead of waiting
// for its timeout.
func TestShowNonJapaneseEndsAtOnce(t *testing.T) {
fonts, err := card.LoadFonts()
if err != nil {
t.Skip(err)
}
a := New(DefaultConfig, Deps{Analyzer: &analyze.Analyzer{}, Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night}})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
a.OnHide = cancel
start := time.Now()
a.Run(ctx, newFake(), "hello")
if time.Since(start) > time.Second {
t.Fatal("show mode waited for its timeout on text with no Japanese")
}
}
type particleDict struct{}
func (particleDict) Lookup(w string) []dict.Entry {
switch w {
case "とは":
return []dict.Entry{{Kana: []dict.Form{{Text: "とは", Common: true}}, Senses: []dict.Sense{{POS: []string{"exp", "prt"}, Gloss: []string{"indicates word or phrase being defined"}}}}}
case "と":
return []dict.Entry{{Kana: []dict.Form{{Text: "と", Common: true}}, Senses: []dict.Sense{{POS: []string{"prt"}, Gloss: []string{"if; when"}}}}}
case "は":
return []dict.Entry{{Kana: []dict.Form{{Text: "は", Common: true}}, Senses: []dict.Sense{{POS: []string{"prt"}, Gloss: []string{"indicates sentence topic"}}}}}
}
return nil
}
func (d particleDict) LookupReading(w, r string) []dict.Entry { return d.Lookup(w) }
func (particleDict) LongestPrefix(s string, n int) (int, []dict.Entry) { return 0, nil }
func (particleDict) Kanji(r rune) (dict.Kanji, bool) { return dict.Kanji{}, false }
func (particleDict) Furigana(w, r string) ([]dict.Ruby, bool) { return nil, false }
func TestCompoundParticlePartsAndLatinRomaji(t *testing.T) {
fonts, err := card.LoadFonts()
if err != nil {
t.Skip(err)
}
a := New(DefaultConfig, Deps{
Analyzer: &analyze.Analyzer{Morph: tokenizer(t), Dict: particleDict{}},
Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night},
})
v := a.newView("ミックスリストとは、YouTube が作成した")
v.expanded = true
v.focus = 2 // とは
a.lookup(v)
wv := a.wordModel(v)
if wv.Words[3].Romaji != "" {
t.Fatalf("punctuation romaji = %q, want empty", wv.Words[3].Romaji)
}
if wv.Words[4].Romaji != "YouTube" || wv.Words[4].Lookup {
t.Fatalf("YouTube word = %+v, want Romaji=YouTube Lookup=false", wv.Words[4])
}
parts := wv.Words[2].Parts
if len(parts) != 2 || parts[0].Gloss != "if" || parts[1].Gloss != "topic" {
t.Fatalf("とは parts = %+v, want [if, topic]", parts)
}
if len(wv.Entries) == 0 || wv.Entries[0].Head != "とは" || wv.Entries[0].Same {
t.Fatalf("とは entry = %+v, want Head=とは Same=false", wv.Entries)
}
}