package card
import (
"image/png"
"os"
"path/filepath"
"testing"
)
func taberu(expanded bool) *Model {
return &Model{Hover: -1, Word: &WordView{
Words: []Word{{
Segs: []Seg{{"食", "た"}, {"べさせられなかった", ""}},
Romaji: "tabesaserarenakatta",
Lookup: true,
Parts: []Part{
{Segs: []Seg{{"食", "た"}, {"べ", ""}}, Romaji: "tabe", Gloss: "eat"},
{Segs: []Seg{{"させ", ""}}, Romaji: "sase", Gloss: "causative", Tip: "causative: make or let someone do"},
{Segs: []Seg{{"られ", ""}}, Romaji: "rare", Gloss: "passive", Tip: "passive: be done to"},
{Segs: []Seg{{"なかっ", ""}}, Romaji: "nakat", Gloss: "negative"},
{Segs: []Seg{{"た", ""}}, Romaji: "ta", Gloss: "past"},
},
}},
Focus: 0,
Expanded: expanded,
Entries: []Entry{
{Head: "食べる", Reading: "たべる", Romaji: "taberu", Downstep: 2, Common: true, JLPT: "N5", DictForm: true,
Meanings: []string{"to eat", "to live on (e.g. a salary); to live off", "to subsist on"}, Tip: "ichidan verb · transitive"},
},
Kanji: []Chip{{Text: "食", Tip: "eat, food"}},
}}
}
func hashi() *Model {
return &Model{Hover: -1, Word: &WordView{
Words: []Word{{Segs: []Seg{{"はし", ""}}, Romaji: "hashi", Lookup: true}},
Expanded: true,
Entries: []Entry{
{Head: "橋", Reading: "はし", Romaji: "hashi", Downstep: 2, Common: true, JLPT: "N5", Meanings: []string{"bridge"}},
{Head: "箸", Reading: "はし", Downstep: 1, Meanings: []string{"chopsticks"}},
{Head: "端", Reading: "はし", Downstep: 0, Meanings: []string{"end (e.g. of street); tip; point; edge; margin"}},
},
Kanji: []Chip{{Text: "橋"}},
}}
}
func sentence() *Model {
return &Model{Hover: -1, Word: &WordView{
Words: []Word{
{Segs: []Seg{{"今日", "きょう"}}, Romaji: "kyō", Lookup: true},
{Segs: []Seg{{"は", ""}}, Romaji: "wa", Lookup: true},
{Segs: []Seg{{"良", "よ"}, {"い", ""}}, Romaji: "yoi", Lookup: true},
{Segs: []Seg{{"天", "てん"}, {"気", "き"}}, Romaji: "tenki", Lookup: true},
{Segs: []Seg{{"ですね。", ""}}, Romaji: "desu ne."},
},
Focus: -1,
}}
}
func kanjiPage() *Model {
return &Model{Hover: -1, Kanji: &KanjiView{
Back: "食べさせられなかった", Char: "食", Meta: "9 strokes · grade 2 · N4",
Meanings: "eat, food", On: "ショク ジキ", Kun: "た.べる く.う く.らう",
Parts: []Chip{{Text: "人", Label: "person"}, {Text: "良", Label: "good"}},
Words: []Chip{{Text: "食事", Label: "meal"}, {Text: "食べ物", Label: "food"}, {Text: "和食", Label: "Japanese food"}, {Text: "食堂", Label: "cafeteria"}},
Similar: []Chip{{Text: "飲", Label: "drink"}, {Text: "館", Label: "building"}, {Text: "飯", Label: "meal"}},
SimilarOf: "良",
}}
}
func TestRender(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
cases := map[string]*Model{
"collapsed": taberu(false), "expanded": taberu(true), "hashi": hashi(),
"sentence": sentence(), "kanji": kanjiPage(),
}
for name, m := range cases {
for _, th := range []struct {
n string
t Theme
}{{"night", Night}, {"day", Day}} {
r := &Renderer{Fonts: f, Theme: th.t}
res := r.Render(m, 2)
if res.Image.Bounds().Dx() < 100 || len(res.Regions) == 0 {
t.Fatalf("%s: bad render %v regions=%d", name, res.Image.Bounds(), len(res.Regions))
}
if dir := os.Getenv("CHIRAMI_CARD_OUT"); dir != "" {
o, _ := os.Create(filepath.Join(dir, name+"-"+th.n+".png"))
png.Encode(o, res.Image)
o.Close()
}
}
}
}
// Hovering a region paints a chip behind its text; the text must stay on top.
func TestHoverKeepsText(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
r := &Renderer{Fonts: f, Theme: Night}
const scale = 2
for name, m := range map[string]*Model{"expanded": taberu(true), "hashi": hashi(), "kanji": kanjiPage()} {
for i, reg := range r.Render(m, scale).Regions {
if !reg.chip {
continue
}
hm := *m
hm.Hover = i
img := r.Render(&hm, scale).Image
chip := pm(Night.Hover)
ink := 0
// inside the chip's rounded corners
const in = 4
for y := int((reg.Rect.Y + in) * scale); y < int((reg.Rect.Y+reg.Rect.H-in)*scale); y++ {
for x := int((reg.Rect.X + in) * scale); x < int((reg.Rect.X+reg.Rect.W-in)*scale); x++ {
c := img.RGBAAt(x, y)
if absDiff(c.R, uint8(chip.r*255)) > 40 || absDiff(c.G, uint8(chip.g*255)) > 40 || absDiff(c.B, uint8(chip.b*255)) > 40 {
ink++
}
}
}
if ink < 20 {
t.Errorf("%s: region %d (%v) is a blank chip when hovered", name, i, reg.Action)
}
}
}
}
// In a sentence, the focused word drawn as parts still gets one chip
// behind all of them, gaps included, so the parts read as one word.
func TestFocusChipSpansParts(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
m := taberu(true)
m.Word.Words = append([]Word{{Segs: []Seg{{"昨日", "きのう"}}, Romaji: "kinō", Lookup: true}}, m.Word.Words...)
m.Word.Focus = 1
const scale = 2
res := (&Renderer{Fonts: f, Theme: Night}).Render(m, scale)
var parts []Region
for _, r := range res.Regions {
if r.Action == None && r.sound >= 0 {
parts = append(parts, r)
}
}
if len(parts) < 2 {
t.Fatalf("found %d part regions", len(parts))
}
a, b := parts[0].Rect, parts[1].Rect
x := (a.X + a.W + b.X) / 2 // the gap between the first two parts
y := a.Y + 8 // in the furigana row, which the chip now covers too
got := res.Image.RGBAAt(int(x*scale), int(y*scale))
want := pm(Night.Chip)
if absDiff(got.R, uint8(want.r*255)) > 6 || absDiff(got.G, uint8(want.g*255)) > 6 || absDiff(got.B, uint8(want.b*255)) > 6 {
t.Fatalf("gap between parts at (%v,%v) is %v, want the focus chip colour", x, y, got)
}
// ...and stops above the glosses
gy := a.Y + a.H - 4
got = res.Image.RGBAAt(int(x*scale), int(gy*scale))
if absDiff(got.R, uint8(want.r*255)) <= 2 && absDiff(got.G, uint8(want.g*255)) <= 2 && absDiff(got.B, uint8(want.b*255)) <= 2 {
t.Fatalf("the focus chip reaches the glosses at (%v,%v)", x, gy)
}
}
// Pointing at a word or part lights up its romaji: some accent-coloured ink
// appears in the romaji line that is not there otherwise.
func TestHoverLightsRomaji(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
r := &Renderer{Fonts: f, Theme: Night}
for name, m := range map[string]*Model{"parts": taberu(true), "sentence": sentence()} {
base := r.Render(m, 1)
lit := false
for i, reg := range base.Regions {
if reg.sound < 0 {
continue
}
hm := *m
hm.Hover = i
if accentInk(r.Render(&hm, 1)) > accentInk(base) {
lit = true
}
}
if !lit {
t.Errorf("%s: no region lights up any romaji", name)
}
}
}
// accentInk counts pixels close to the accent colour.
func accentInk(res *Result) int {
a := Night.Accent
n := 0
b := res.Image.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
c := res.Image.RGBAAt(x, y)
if absDiff(c.R, a.R) < 30 && absDiff(c.G, a.G) < 30 && absDiff(c.B, a.B) < 30 {
n++
}
}
}
return n
}
// Long headwords and readings stay inside the card: nothing bright is drawn
// right of its edge.
func TestLongEntryStaysInside(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
m := sentence()
m.Word.Expanded, m.Word.Focus = true, 0
m.Word.Entries = []Entry{{
Head: "鳴く猫は鼠を捕らぬ", Reading: "なくねこはねずみをとらぬ", Romaji: "nakunekowanezumiotoranu",
Downstep: 3, Common: true, JLPT: "N1", DictForm: true,
Meanings: []string{"empty vessels make the most sound; the meowing cat does not catch the mouse"},
}}
const scale = 2
res := (&Renderer{Fonts: f, Theme: Night}).Render(m, scale)
edge := int((res.Hit.X + res.Hit.W) * scale)
b := res.Image.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := edge + 2; x < b.Max.X; x++ {
if c := res.Image.RGBAAt(x, y); c.R > 150 || c.G > 150 || c.B > 150 {
t.Fatalf("ink at (%d,%d) right of the card edge %d", x, y, edge)
}
}
}
}
// A tip draws over the card; it must not change the card's size or move
// any region relative to the card. Above the card it may grow the image
// upwards, and then only by as much as it needs.
func TestTipMovesNothing(t *testing.T) {
f, err := LoadFonts()
if err != nil {
t.Skip(err)
}
r := &Renderer{Fonts: f, Theme: Night}
for _, room := range []float64{0, 200} {
m := taberu(true)
m.Room = room
base := r.Render(m, 2)
for i, reg := range base.Regions {
if reg.Tip == "" {
continue
}
tm := *m
tm.Hover, tm.Tip = i, true
got := r.Render(&tm, 2)
if got.Hit.W != base.Hit.W || got.Hit.H != base.Hit.H || len(got.Regions) != len(base.Regions) {
t.Fatalf("room %v tip %d changed the card: %v -> %v", room, i, base.Hit, got.Hit)
}
if room == 0 && got.Hit != base.Hit {
t.Fatalf("tip %d grew the image without room above: %v -> %v", i, base.Hit, got.Hit)
}
for j := range got.Regions {
if got.Regions[j].Rect.Y-got.Hit.Y != base.Regions[j].Rect.Y-base.Hit.Y {
t.Fatalf("room %v tip %d moved region %d", room, i, j)
}
}
}
}
}
func absDiff(a, b uint8) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
func BenchmarkRender(b *testing.B) {
f, err := LoadFonts()
if err != nil {
b.Skip(err)
}
r := &Renderer{Fonts: f, Theme: Night}
m := taberu(true)
for b.Loop() {
r.Render(m, 2)
}
}