Files
tiennm99 d84bc3d1fb feat(noitu): make /noitu the group game and add /noitutop
/noitu now posts the group play card that /noitupvp used to, and the solo
game against the bot moves to /noitubot. In a private chat /noitu points
the user to a group, or to /noitubot.

/noitutop ranks a group's players across all of its cards by wins, best
game and games played.

A one-time startup migration moves /stats history to the new names in
order: noitu to noitubot first, then noitupvp to noitu.
2026-10-09 16:22:54 +07:00

249 lines
6.4 KiB
Go

package noitu
import (
"bytes"
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
var testKey = []byte("0123456789abcdef0123456789abcdef")
const testBase = "https://game.example"
// testCorpus is a tiny word graph. The opening falls back to the best
// connected word, "hoa hồng":
//
// hồng → hồng hào → hào hoa → hoa lá (dead) | hoa hồng (opening)
// hồng → hồng tâm → tâm sự → sự cố (dead)
const testCorpus = "hoa hồng\tdanh từ|loài hoa\nhồng hào\t\nhồng tâm\t\ntâm sự\t\nsự cố\t\nhào hoa\t\nhoa lá\t\n"
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
type reportCall struct {
claims claims
score int
}
type fakeReporter struct {
mu sync.Mutex
calls []reportCall
err error
announced []string
cards []claims
// beforeReport, when set before any game ends, runs at the start of
// every Report, outside the mutex, so a test can hold a report.
beforeReport func(claims)
}
func (r *fakeReporter) Announce(_ context.Context, card claims, text string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.announced = append(r.announced, text)
r.cards = append(r.cards, card)
return r.err
}
// announcedOn counts the announcements made on the card with message msg.
func (r *fakeReporter) announcedOn(msg int) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, c := range r.cards {
if c.MessageID == msg {
n++
}
}
return n
}
func (r *fakeReporter) announcements() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.announced...)
}
func (r *fakeReporter) Report(_ context.Context, c claims, score int) error {
if r.beforeReport != nil {
r.beforeReport(c)
}
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, reportCall{c, score})
return r.err
}
func (r *fakeReporter) snapshot() []reportCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]reportCall(nil), r.calls...)
}
type harness struct {
t *testing.T
svc *service
mod modules.Module
clock *fakeClock
reporter *fakeReporter
handler http.Handler
}
func newHarness(t *testing.T, corpus string) *harness {
t.Helper()
store, err := dict.Parse(corpus)
if err != nil {
t.Fatalf("Parse: %v", err)
}
return newHarnessWithStore(t, store)
}
func newHarnessWithStore(t *testing.T, store *dict.Store) *harness {
t.Helper()
h := &harness{t: t, clock: &fakeClock{t: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)}, reporter: &fakeReporter{}}
seed := uint64(0)
coll := storage.NewMemoryProvider().Collection("noitu")
cfg := config{
baseURL: testBase,
key: testKey,
now: h.clock.now,
newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) },
reporter: h.reporter,
announcer: h.reporter,
cards: storage.Typed[pvpCard](coll),
top: storage.Typed[topEntry](coll),
loadDict: func() (*dict.Store, error) { return store, nil },
}
h.svc = newService(cfg)
h.mod = h.svc.module()
if len(h.mod.HTTP) != 1 {
t.Fatalf("enabled module has %d routes, want 1", len(h.mod.HTTP))
}
h.handler = h.mod.HTTP[0].Handler
return h
}
// token signs a chat-message token for user valid from the harness clock.
func (h *harness) token(user int64) string {
h.t.Helper()
return h.tokenFor(user, 5)
}
// tokenFor signs a token for user on game message msg in the user's chat.
func (h *harness) tokenFor(user int64, msg int) string {
h.t.Helper()
tok, err := signToken(testKey, claims{UserID: user, Name: "Tí", ChatID: user, MessageID: msg, Expiry: h.clock.now().Add(tokenTTL).Unix()})
if err != nil {
h.t.Fatal(err)
}
return tok
}
// post sends a raw body and returns the recorder.
func (h *harness) post(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
return rec
}
// postJSON marshals v and posts it.
func (h *harness) postJSON(path string, v any) *httptest.ResponseRecorder {
h.t.Helper()
body, err := json.Marshal(v)
if err != nil {
h.t.Fatal(err)
}
return h.post(path, string(body))
}
// call posts v and decodes the answer into out, failing on an unexpected status.
func (h *harness) call(path string, v any, wantStatus int, out any) {
h.t.Helper()
body, _ := json.Marshal(v)
rec := h.post(path, string(body))
if rec.Code != wantStatus {
h.t.Fatalf("%s: status %d, want %d; body %s", path, rec.Code, wantStatus, rec.Body.String())
}
if out != nil {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
h.t.Fatalf("%s: decode %q: %v", path, rec.Body.String(), err)
}
}
}
func (h *harness) start(user int64, difficulty string) sessionView {
h.t.Helper()
var v sessionView
h.call("start", map[string]string{"token": h.token(user), "difficulty": difficulty}, http.StatusOK, &v)
return v
}
// move advances the clock past the per-move rate limit, then plays word.
func (h *harness) move(session, word string) moveResponse {
h.t.Helper()
h.clock.advance(time.Second)
var r moveResponse
h.call("move", map[string]string{"session": session, "word": word}, http.StatusOK, &r)
return r
}
func (h *harness) state(session string) sessionView {
h.t.Helper()
var v sessionView
h.call("state", map[string]string{"session": session}, http.StatusOK, &v)
return v
}
// waitReport polls the state until the asynchronous score report settles.
func (h *harness) waitReport(session string) sessionView {
h.t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
v := h.state(session)
if v.ScoreReported != reportPending || time.Now().After(deadline) {
return v
}
time.Sleep(5 * time.Millisecond)
}
}
func (h *harness) apiError(rec *httptest.ResponseRecorder) string {
h.t.Helper()
var e struct {
Code string `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
h.t.Fatalf("decode error body %q: %v", rec.Body.String(), err)
}
if e.Message == "" {
h.t.Errorf("error %q has no message", e.Code)
}
return e.Code
}