generated from VLADIMIR/template
up
This commit is contained in:
@@ -34,7 +34,8 @@ func TestAnswerAccessPostgres(t *testing.T) {
|
||||
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
|
||||
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
|
||||
status TEXT, password TEXT, game_id INT);
|
||||
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
|
||||
ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
|
||||
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
|
||||
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
|
||||
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
|
||||
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
|
||||
t.Run(format+"/"+state, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newServiceFixture(t)
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format))
|
||||
wifiName, wifiPassword := "Детектив", " secret "
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format, &wifiName, &wifiPassword))
|
||||
if state != "draft" {
|
||||
check(t, f.games.StartGame(ctx, f.gameID))
|
||||
if state == "pause" {
|
||||
@@ -102,7 +103,7 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
|
||||
check(t, err)
|
||||
local, err := venue.games.GetGameByID(ctx, imported.GameID)
|
||||
check(t, err)
|
||||
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil {
|
||||
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil || local.WiFiName != wifiName || local.WiFiPassword != wifiPassword {
|
||||
t.Fatalf("import did not start fresh: %+v", local)
|
||||
}
|
||||
actions, err = actions_repo.NewActionsRepo(venue.pool).GetActionsByTeamID(ctx, f.teamID)
|
||||
|
||||
@@ -42,7 +42,7 @@ func newReturnFixture(t *testing.T, format string) returnFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
f := newServiceFixture(t)
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format))
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format, nil, nil))
|
||||
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`))
|
||||
_, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr")
|
||||
check(t, err)
|
||||
@@ -125,7 +125,7 @@ func TestReturnAtomicLifecycle(t *testing.T) {
|
||||
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
|
||||
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0]))
|
||||
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID))
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format))
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format, nil, nil))
|
||||
token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32))
|
||||
check(t, f.teams.SetParticipationToken(ctx, f.teamID, token))
|
||||
raw := f.pack(t)
|
||||
@@ -454,7 +454,7 @@ func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) {
|
||||
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory))
|
||||
other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline")
|
||||
check(t, err)
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline"))
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline", nil, nil))
|
||||
check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name"))
|
||||
extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr")
|
||||
check(t, err)
|
||||
|
||||
@@ -9,16 +9,18 @@ import (
|
||||
|
||||
// Game describes a game exposed by the service layer.
|
||||
type Game struct {
|
||||
Export *repos.GameExport
|
||||
Format string
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Location *string
|
||||
StartAt time.Time
|
||||
Status string
|
||||
Scenario *scenarios_service.Scenario
|
||||
Teams []*Team
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
Export *repos.GameExport
|
||||
Format string
|
||||
ID int
|
||||
Name string
|
||||
WiFiName string
|
||||
WiFiPassword string
|
||||
Description string
|
||||
Location *string
|
||||
StartAt time.Time
|
||||
Status string
|
||||
Scenario *scenarios_service.Scenario
|
||||
Teams []*Team
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
@@ -11,18 +11,20 @@ func MapGame(
|
||||
domain string,
|
||||
) *Game {
|
||||
return &Game{
|
||||
ID: o.ID,
|
||||
Export: o.Export,
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
Location: o.Location,
|
||||
StartAt: o.StartAt,
|
||||
Status: o.Status,
|
||||
Format: o.Format,
|
||||
Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain),
|
||||
Teams: MapTeams(o.Teams),
|
||||
StartedAt: o.StartedAt,
|
||||
EndedAt: o.EndedAt,
|
||||
ID: o.ID,
|
||||
Export: o.Export,
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
WiFiName: o.WiFiName,
|
||||
WiFiPassword: o.WiFiPassword,
|
||||
Location: o.Location,
|
||||
StartAt: o.StartAt,
|
||||
Status: o.Status,
|
||||
Format: o.Format,
|
||||
Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain),
|
||||
Teams: MapTeams(o.Teams),
|
||||
StartedAt: o.StartedAt,
|
||||
EndedAt: o.EndedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,17 +51,19 @@ type Snapshot struct {
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
Status string `json:"status"`
|
||||
Format string `json:"format"`
|
||||
ScenarioID int `json:"scenario_id"`
|
||||
PlaylistID *int `json:"playlist_id,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
WiFiName string `json:"wifi_name,omitempty"`
|
||||
WiFiPassword string `json:"wifi_password,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
Status string `json:"status"`
|
||||
Format string `json:"format"`
|
||||
ScenarioID int `json:"scenario_id"`
|
||||
PlaylistID *int `json:"playlist_id,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
type Scenario struct {
|
||||
|
||||
@@ -90,6 +90,10 @@ func TestPackagePreservesPregameData(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
snapshot, files := fixture()
|
||||
snapshot.Data.Game.Format = tc.format
|
||||
if tc.format == "offline" {
|
||||
snapshot.Data.Game.WiFiName = "Детектив"
|
||||
snapshot.Data.Game.WiFiPassword = " secret "
|
||||
}
|
||||
snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: "019a1234-5678-7abc-8def-000000000063"})
|
||||
before, _ := json.Marshal(snapshot)
|
||||
calls := map[string]int{}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"evening_detective_server/internal/modules/team_qr_pdf"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,9 @@ func invalid(reason string) error {
|
||||
|
||||
func validateData(data *Data) error {
|
||||
g := data.Game
|
||||
if err := team_qr_pdf.ValidateWiFi(g.WiFiName, g.WiFiPassword); err != nil {
|
||||
return invalid("некорректные параметры Wi-Fi")
|
||||
}
|
||||
if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() ||
|
||||
g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil ||
|
||||
(g.Format != "online" && g.Format != "offline") {
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestProdFlowReturns(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
games := games_repo.NewGamesRepo(pool)
|
||||
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format); err != nil {
|
||||
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
||||
CREATE TEMP TABLE team_answers(team_id UUID, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code));
|
||||
INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`)
|
||||
check(err)
|
||||
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} {
|
||||
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql", "20261023120000_game_wifi.sql"} {
|
||||
data, err := os.ReadFile("../../../migrations/" + name)
|
||||
check(err)
|
||||
_, err = pool.Exec(ctx, strings.Split(string(data), "-- +goose Down")[0])
|
||||
@@ -77,7 +77,23 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
||||
start := time.Now().Add(48 * time.Hour)
|
||||
id, err := game.AddGame(ctx, "Game", "", nil, start, 1, "online")
|
||||
check(err)
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, ""))
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "", nil, nil))
|
||||
wifiName, wifiPassword := "Детектив", " secret "
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", nil, nil))
|
||||
stored, err := game.GetGame(ctx, id)
|
||||
check(err)
|
||||
if stored.WiFiName != wifiName || stored.WiFiPassword != wifiPassword {
|
||||
t.Fatal("old client erased Wi-Fi settings")
|
||||
}
|
||||
empty := ""
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &empty, &empty))
|
||||
stored, err = game.GetGame(ctx, id)
|
||||
check(err)
|
||||
if stored.WiFiName != "" || stored.WiFiPassword != "" {
|
||||
t.Fatal("Wi-Fi settings not cleared")
|
||||
}
|
||||
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
|
||||
registration := team_service.Registration{Name: "Team", Email: "captain@example.org", ParticipantsCount: 3}
|
||||
teamID, err := team.RegisterTeam(ctx, id, "delivery", registration)
|
||||
check(err)
|
||||
|
||||
@@ -3,6 +3,9 @@ package game_service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/team_qr_pdf"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
@@ -10,7 +13,6 @@ import (
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/services/game_model"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GameService handles game lifecycle operations.
|
||||
@@ -174,7 +176,18 @@ func (s *GameService) UpdateGame(
|
||||
startAt time.Time,
|
||||
scenarioID int32,
|
||||
format string,
|
||||
wifiName, wifiPassword *string,
|
||||
) error {
|
||||
nameValue, passwordValue := "", ""
|
||||
if wifiName != nil {
|
||||
nameValue = *wifiName
|
||||
}
|
||||
if wifiPassword != nil {
|
||||
passwordValue = *wifiPassword
|
||||
}
|
||||
if err := team_qr_pdf.ValidateWiFi(nameValue, passwordValue); err != nil {
|
||||
return err
|
||||
}
|
||||
if format == "" {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -185,7 +198,7 @@ func (s *GameService) UpdateGame(
|
||||
if format != "online" && format != "offline" {
|
||||
return ErrInvalidGameFormat
|
||||
}
|
||||
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format))
|
||||
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format, wifiName, wifiPassword))
|
||||
}
|
||||
|
||||
// StartGame marks a game as started.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/team_qr_pdf"
|
||||
)
|
||||
|
||||
func TestUpdateGameRejectsInvalidWiFiBeforeWriting(t *testing.T) {
|
||||
s := &GameService{}
|
||||
for _, tc := range []struct{ name, password string }{
|
||||
{name: strings.Repeat("x", 33)},
|
||||
{password: "bad\n"},
|
||||
} {
|
||||
err := s.UpdateGame(context.Background(), 1, "Game", "", nil, time.Now(), 1, "online", &tc.name, &tc.password)
|
||||
if !errors.Is(err, team_qr_pdf.ErrInvalidWiFi) {
|
||||
t.Fatal("invalid settings reached repository")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ func TestFinishedTeamStoryPostgres(t *testing.T) {
|
||||
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
|
||||
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
|
||||
status TEXT, password TEXT, game_id INT);
|
||||
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
|
||||
ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
|
||||
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
|
||||
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
|
||||
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
|
||||
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
|
||||
|
||||
@@ -103,6 +103,9 @@ func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string
|
||||
}
|
||||
|
||||
func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
|
||||
if err := team_qr_pdf.ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidReceipt, err)
|
||||
}
|
||||
name := strings.Join(strings.Fields(card.Name), " ")
|
||||
if name == "" || len([]rune(name)) > 255 {
|
||||
return nil, ErrInvalidReceipt
|
||||
@@ -111,17 +114,34 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse receipt font: %w", err)
|
||||
}
|
||||
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull})
|
||||
nameSize, nameLineHeight := 26.0, 34
|
||||
if card.WiFiName != "" {
|
||||
nameSize, nameLineHeight = 20, 26
|
||||
}
|
||||
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open receipt font: %w", err)
|
||||
}
|
||||
defer face.Close()
|
||||
defer func() {
|
||||
if face != nil {
|
||||
_ = face.Close()
|
||||
}
|
||||
}()
|
||||
for _, r := range name {
|
||||
if _, ok := face.GlyphAdvance(r); !ok {
|
||||
return nil, ErrInvalidReceipt
|
||||
}
|
||||
}
|
||||
lines := wrapReceiptName(name, face)
|
||||
instructionFace, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Wi-Fi receipt font: %w", err)
|
||||
}
|
||||
defer instructionFace.Close()
|
||||
var instructions []string
|
||||
for _, paragraph := range team_qr_pdf.WiFiInstructions(card.WiFiName, card.WiFiPassword) {
|
||||
instructions = append(instructions, wrapReceiptText(paragraph, instructionFace)...)
|
||||
}
|
||||
qr, err := qrcode.New(card.URL, qrcode.Medium)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidReceipt
|
||||
@@ -132,8 +152,20 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
|
||||
return nil, ErrInvalidReceipt
|
||||
}
|
||||
qrSize := len(bitmap) * scale
|
||||
qrY := 16 + len(lines)*34 + 16
|
||||
height := qrY + qrSize + 24
|
||||
qrY := 16 + len(lines)*nameLineHeight + 16
|
||||
height := qrY + qrSize + 24 + len(instructions)*26
|
||||
for card.WiFiName != "" && height > receiptMaxHeight && nameSize > 16 {
|
||||
_ = face.Close()
|
||||
nameSize -= 2
|
||||
nameLineHeight = int(nameSize) + 6
|
||||
face, err = opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resize receipt font: %w", err)
|
||||
}
|
||||
lines = wrapReceiptName(name, face)
|
||||
qrY = 16 + len(lines)*nameLineHeight + 16
|
||||
height = qrY + qrSize + 24 + len(instructions)*26
|
||||
}
|
||||
if height > receiptMaxHeight {
|
||||
return nil, ErrInvalidReceipt
|
||||
}
|
||||
@@ -142,7 +174,7 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
|
||||
drawer := font.Drawer{Dst: img, Src: image.Black, Face: face}
|
||||
for i, line := range lines {
|
||||
width := drawer.MeasureString(line).Ceil()
|
||||
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*34)
|
||||
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*nameLineHeight)
|
||||
drawer.DrawString(line)
|
||||
}
|
||||
x := (receiptWidth - qrSize) / 2
|
||||
@@ -153,6 +185,11 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
drawer.Face = instructionFace
|
||||
for i, line := range instructions {
|
||||
drawer.Dot = fixed.P(16, qrY+qrSize+instructionFace.Metrics().Ascent.Ceil()+i*26)
|
||||
drawer.DrawString(line)
|
||||
}
|
||||
for i, pixel := range img.Pix {
|
||||
if pixel < 128 {
|
||||
img.Pix[i] = 0
|
||||
@@ -211,3 +248,25 @@ func validateReceiptRaster(data []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preserve spaces in credentials when wrapping the printed instructions.
|
||||
func wrapReceiptText(text string, face font.Face) []string {
|
||||
var lines []string
|
||||
line := ""
|
||||
for _, r := range text {
|
||||
for line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
|
||||
if space := strings.LastIndexByte(line, ' '); space >= 0 {
|
||||
lines = append(lines, line[:space+1])
|
||||
line = line[space+1:]
|
||||
} else {
|
||||
lines = append(lines, line)
|
||||
line = ""
|
||||
}
|
||||
}
|
||||
line += string(r)
|
||||
}
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -14,18 +14,21 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
)
|
||||
|
||||
const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007"
|
||||
|
||||
type receiptReposStub struct {
|
||||
ITeamsRepository
|
||||
team *repos.Team
|
||||
gameErr, teamErr error
|
||||
team *repos.Team
|
||||
gameErr, teamErr error
|
||||
wifiName, wifiPassword string
|
||||
}
|
||||
|
||||
func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) {
|
||||
return &repos.Game{ID: 1, Name: "Проверка чеков"}, s.gameErr
|
||||
return &repos.Game{ID: 1, Name: "Проверка чеков", WiFiName: s.wifiName, WiFiPassword: s.wifiPassword}, s.gameErr
|
||||
}
|
||||
|
||||
func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) {
|
||||
@@ -81,7 +84,7 @@ func TestTeamReceiptValidation(t *testing.T) {
|
||||
func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) {
|
||||
for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repos := &receiptReposStub{team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
|
||||
repos := &receiptReposStub{wifiName: "Детектив", wifiPassword: " secret ", team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
|
||||
printer := &receiptPrinterStub{}
|
||||
s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer)
|
||||
data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID)
|
||||
@@ -149,3 +152,49 @@ func TestReceiptPrinterErrorsRoundTrip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptWiFiFitsAndPreservesCredentials(t *testing.T) {
|
||||
for _, name := range []string{"Команда", strings.Repeat("Ш", 255), strings.Repeat("W", 255)} {
|
||||
for _, password := range []string{"", " secret ", strings.Repeat("W", 64)} {
|
||||
card := team_qr_pdf.Card{
|
||||
Name: name,
|
||||
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
|
||||
WiFiName: strings.Repeat("W", 32),
|
||||
WiFiPassword: password,
|
||||
}
|
||||
img, err := renderReceipt(card)
|
||||
if err != nil {
|
||||
t.Fatalf("name runes=%d, password bytes=%d: %v", len([]rune(name)), len(password), err)
|
||||
}
|
||||
if img.Bounds().Dy() > receiptMaxHeight {
|
||||
t.Fatal("receipt exceeds printer limit")
|
||||
}
|
||||
if err := validateReceiptRaster(packReceipt(img)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapReceiptTextPreservesCredentials(t *testing.T) {
|
||||
ttf, err := opentype.Parse(team_qr_pdf.FontData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer face.Close()
|
||||
for _, text := range []string{" secret password ", " " + strings.Repeat("W", 64), "Пароль: «" + strings.Repeat("W", 64) + "»", "Подключитесь к Wi-Fi организатора."} {
|
||||
lines := wrapReceiptText(text, face)
|
||||
if strings.Join(lines, "") != text {
|
||||
t.Fatal("wrapping changed credentials")
|
||||
}
|
||||
for _, line := range lines {
|
||||
if font.MeasureString(face, line).Ceil() > receiptWidth-32 {
|
||||
t.Fatal("text clipped at receipt edge")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_q
|
||||
link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID
|
||||
link.RawPath = ""
|
||||
link.RawQuery = url.Values{"password": {team.Password}}.Encode()
|
||||
cards = append(cards, team_qr_pdf.Card{Name: team.Name, URL: link.String()})
|
||||
cards = append(cards, team_qr_pdf.Card{
|
||||
Name: team.Name,
|
||||
URL: link.String(),
|
||||
WiFiName: game.WiFiName,
|
||||
WiFiPassword: game.WiFiPassword,
|
||||
})
|
||||
}
|
||||
name := strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestTeamQRData(t *testing.T) {
|
||||
game := &repos.Game{Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
|
||||
game := &repos.Game{WiFiName: "Детектив", WiFiPassword: " secret ", Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
|
||||
teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}}
|
||||
cards, filename, err := teamQRData("https://example.org/app/", game, teams)
|
||||
if err != nil {
|
||||
@@ -18,6 +18,9 @@ func TestTeamQRData(t *testing.T) {
|
||||
if len(cards) != 1 || cards[0].Name != "Знатоки" {
|
||||
t.Fatalf("cards: %+v", cards)
|
||||
}
|
||||
if cards[0].WiFiName != game.WiFiName || cards[0].WiFiPassword != game.WiFiPassword {
|
||||
t.Fatal("Wi-Fi settings missing from print card")
|
||||
}
|
||||
u, err := url.Parse(cards[0].URL)
|
||||
if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password {
|
||||
t.Fatalf("wrong team link: %s", cards[0].URL)
|
||||
|
||||
Reference in New Issue
Block a user