Files
tiennm99bot/internal/modules/noitu/engine/engine_test.go
T
tiennm99 6f30d13b2d feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under
/games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play
answers with a signed, expiring link, and the server validates every
word against the embedded noitu dictionary (CC BY-SA 4.0), runs the
bot opponent and turn timer, and reports the score with setGameScore.

Modules can now register game-short-name callbacks and HTTP routes.
2026-10-09 14:32:57 +07:00

229 lines
6.9 KiB
Go

package engine
import (
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
)
const (
limit = 30 * time.Second
grace = 2 * time.Second
)
var t0 = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
// testDict is a tiny graph:
//
// hoa hồng → hồng hào → hào hoa (back to hoa)
// hồng tâm → tâm sự → sự cố → (cố is a dead end)
// sĩ quan → quan tâm
func testDict(t *testing.T) *dict.Store {
t.Helper()
s, err := dict.Parse("hoa hồng\nhồng hào\nhào hoa\nhồng tâm\ntâm sự\nsự cố\nsĩ quan\nquan tâm\nhào quang lấp lánh\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
return s
}
func newGame(t *testing.T) *Engine {
t.Helper()
e, err := New(testDict(t), "hoa hồng", limit, grace, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
return e
}
func TestNewRejectsDeadOpening(t *testing.T) {
if _, err := New(testDict(t), "sự cố", limit, grace, t0); err == nil {
t.Fatal("dead-end opening accepted")
}
if _, err := New(testDict(t), "không có", limit, grace, t0); err == nil {
t.Fatal("unknown opening accepted")
}
}
func TestSubmitReasons(t *testing.T) {
e := newGame(t)
if e.Current() != "hồng" || e.Turn() != Human {
t.Fatalf("start: current %q turn %v", e.Current(), e.Turn())
}
now := t0.Add(time.Second)
cases := []struct {
side Side
word string
want Reason
}{
{Bot, "hồng hào", ReasonNotYourTurn},
{Human, "hồng", ReasonTooFewSyllables},
{Human, " ", ReasonTooFewSyllables},
{Human, "hồng xyz", ReasonNotInDictionary},
{Human, "tâm sự", ReasonWrongLink},
}
for _, c := range cases {
if _, got := e.Submit(c.side, c.word, now); got != c.want {
t.Errorf("Submit(%v, %q) = %q, want %q", c.side, c.word, got, c.want)
}
}
if m, r := e.Submit(Human, "Hồng HÀO", now); r != ReasonNone || m.Word != "hồng hào" {
t.Fatalf("valid move = %+v %q", m, r)
}
if _, r := e.Submit(Bot, "hào hoa", now); r != ReasonNone {
t.Fatalf("bot move rejected: %q", r)
}
if _, r := e.Submit(Human, "hoa hồng", now); r != ReasonAlreadyUsed {
t.Fatalf("reused opening = %q, want already_used", r)
}
}
func TestVariantSpellingLinksOnCanonical(t *testing.T) {
e, err := New(testDict(t), "hào hoa", limit, grace, t0) // ends on "hoa"
if err != nil {
t.Fatal(err)
}
e.current = "sĩ" // force a position answered only by "sĩ quan"
if m, r := e.Submit(Human, "sỹ quan", t0); r != ReasonNone || m.Word != "sĩ quan" {
t.Fatalf("variant = %+v %q, want canonical sĩ quan", m, r)
}
}
func TestTimeoutAfterGrace(t *testing.T) {
e := newGame(t)
// Inside the grace window the move still counts, with no speed points.
if m, r := e.Submit(Human, "hồng hào", t0.Add(limit+grace)); r != ReasonNone {
t.Fatalf("move in grace = %q", r)
} else if m.Points != 10+2*1+0+0+rarity(e, "hồng") {
t.Fatalf("points in grace = %d", m.Points)
}
e.Submit(Bot, "hào hoa", t0.Add(limit+grace))
late := t0.Add(limit + grace).Add(limit + grace + time.Millisecond)
if e.Timeout(late.Add(-2 * time.Millisecond)) {
t.Fatal("timeout fired inside the grace window")
}
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonTimeout {
t.Fatalf("late move = %q, want timeout", r)
}
// "hoa" only starts the used opening, so the expired turn was a dead end.
if !e.Over() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("after timeout: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
}
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonGameOver {
t.Fatalf("move after end = %q", r)
}
}
func rarity(e *Engine, link string) int { return e.rarityPoints(link) }
func TestTimeoutSettlesWithoutMove(t *testing.T) {
e := newGame(t)
if e.Timeout(t0.Add(limit)) {
t.Fatal("timeout at the deadline itself")
}
if !e.Timeout(t0.Add(limit + grace + time.Nanosecond)) {
t.Fatal("timeout not settled after grace")
}
}
func TestBotStuckAndGiveUp(t *testing.T) {
e := newGame(t)
e.Submit(Human, "hồng tâm", t0)
if e.BotStuck() {
t.Fatal("bot stuck while it can answer tâm")
}
e.Submit(Bot, "tâm sự", t0)
e.Submit(Human, "sự cố", t0)
if !e.BotStuck() || e.Winner() != Human || e.EndReason() != EndBotStuck {
t.Fatalf("bot stuck: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
}
g := newGame(t)
if !g.GiveUp() || g.Winner() != Bot || g.EndReason() != EndGaveUp {
t.Fatal("give up did not end the game for the bot")
}
if g.GiveUp() {
t.Fatal("second give up reported a change")
}
}
func TestScoreSumsHumanPointsOnly(t *testing.T) {
e := newGame(t)
m1, _ := e.Submit(Human, "hồng tâm", t0) // instant: full speed bonus
e.Submit(Bot, "tâm sự", t0.Add(time.Second)) // bot points do not count
m2, _ := e.Submit(Human, "sự cố", t0.Add(16*time.Second)) // half the turn left
if e.Score() != m1.Points+m2.Points {
t.Fatalf("score %d, want %d + %d", e.Score(), m1.Points, m2.Points)
}
// hồng tâm: base 10 + chain 2*1 + syllables 0 + speed 10 + rarity for "hồng" (2 words → 12).
if m1.Points != 10+2+0+10+12 {
t.Fatalf("m1 points = %d", m1.Points)
}
// sự cố: chain 2*3, speed 10*15/30 = 5, rarity for "sự" (1 word → 15).
if m2.Points != 10+6+0+5+15 {
t.Fatalf("m2 points = %d", m2.Points)
}
}
func TestPointsCapped(t *testing.T) {
e := newGame(t)
e.history = make([]Move, 40) // chain bonus at its cap
if got := e.pointsFor(30, "sự", t0); got != MaxPointsPerWord {
t.Fatalf("points = %d, want cap %d", got, MaxPointsPerWord)
}
}
func TestSuggestions(t *testing.T) {
e := newGame(t)
got := e.Suggestions(3)
if len(got) != 2 || got[0] != "hồng hào" || got[1] != "hồng tâm" {
t.Fatalf("Suggestions = %v", got)
}
if e.Suggestions(0) != nil {
t.Fatal("Suggestions(0) not nil")
}
}
// deadEndGame leaves the human to answer "cố", which starts no word.
func deadEndGame(t *testing.T) *Engine {
t.Helper()
e := newGame(t)
e.current = "cố"
return e
}
func TestDeadEndExpiresAsNoLegalMove(t *testing.T) {
late := t0.Add(limit + grace + time.Millisecond)
e := deadEndGame(t)
if !e.Timeout(late) || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("Timeout in a dead end: winner %v reason %q", e.Winner(), e.EndReason())
}
e = deadEndGame(t)
if _, r := e.Submit(Human, "cố gắng", late); r != ReasonTimeout || e.EndReason() != EndNoLegalMove {
t.Fatalf("late Submit in a dead end: %q / %q", r, e.EndReason())
}
// A playable position still ends as an ordinary timeout.
e = newGame(t)
if !e.Timeout(late) || e.EndReason() != EndTimeout {
t.Fatalf("Timeout with moves left: reason %q", e.EndReason())
}
}
func TestNoMoveClaimsOnlyADeadEnd(t *testing.T) {
e := newGame(t)
if e.NoMove() || e.Over() {
t.Fatal("NoMove ended a playable position")
}
e = deadEndGame(t)
if !e.NoMove() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("NoMove in a dead end: over %v reason %q", e.Over(), e.EndReason())
}
if e.NoMove() || e.GiveUp() {
t.Fatal("a finished game ended a second time")
}
}