This commit is contained in:
2026-10-12 23:39:40 +07:00
parent 7fe8c6a5f0
commit 8101d21d7f
7 changed files with 580 additions and 71 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

+141 -49
View File
@@ -2,6 +2,7 @@ package game_print_service
import ( import (
"bytes" "bytes"
_ "embed"
"fmt" "fmt"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -14,17 +15,40 @@ import (
"github.com/go-pdf/fpdf" "github.com/go-pdf/fpdf"
) )
func newPDF(orientation string) *fpdf.Fpdf { //go:embed assets/a_OldTyper.ttf
var typewriterFont []byte
//go:embed assets/logo.jpg
var printLogo []byte
func newPDF(orientation string, isPlayer bool, scenarioName string) *fpdf.Fpdf {
pdf := fpdf.New(orientation, "mm", "A4", "") pdf := fpdf.New(orientation, "mm", "A4", "")
pdf.SetMargins(15, 15, 15) pdf.SetMargins(15, 15, 15)
pdf.SetAutoPageBreak(true, 15) pdf.SetAutoPageBreak(true, 15)
team_qr_pdf.RegisterFont(pdf) if isPlayer {
pdf.SetFont("teams", "", 11) pdf.SetTopMargin(43)
pdf.SetFooterFunc(func() { pdf.AddUTF8FontFromBytes("typewriter", "", typewriterFont)
pdf.SetY(-12) pdf.SetFont("typewriter", "", 11)
pdf.SetFont("teams", "", 8) pdf.RegisterImageOptionsReader("game-logo", fpdf.ImageOptions{ImageType: "JPG"}, bytes.NewReader(printLogo))
pdf.CellFormat(0, 5, fmt.Sprint(pdf.PageNo()), "", 0, "C", false, 0, "") pdf.SetHeaderFuncMode(func() {
}) pdf.ImageOptions("game-logo", 15, 15, 18, 0, false, fpdf.ImageOptions{ImageType: "JPG"}, 0, "")
pdf.SetXY(40, 19)
pdf.SetFont("typewriter", "", 16)
pdf.CellFormat(0, 8, "Вечерний детектив", "", 0, "L", false, 0, "")
pdf.SetXY(40, 28)
pdf.SetFont("typewriter", "", 18)
name := printText(pdf, strings.Join(strings.Fields(scenarioName), " "))
if width := pdf.GetStringWidth(name); width > 153 {
pdf.SetFont("typewriter", "", 18*153/width)
}
pdf.CellFormat(155, 6, name, "", 0, "L", false, 0, "")
pdf.Line(15, 38, 195, 38)
pdf.SetFont("typewriter", "", 11)
}, true)
} else {
team_qr_pdf.RegisterFont(pdf)
pdf.SetFont("teams", "", 11)
}
pdf.AddPage() pdf.AddPage()
return pdf return pdf
} }
@@ -45,10 +69,10 @@ func printText(pdf *fpdf.Fpdf, text string) string {
} }
func heading(pdf *fpdf.Fpdf, text string) { func heading(pdf *fpdf.Fpdf, text string) {
pdf.SetFont("teams", "", 15) pdf.SetFont("", "", 15)
pdf.MultiCell(0, 7, printText(pdf, text), "", "L", false) pdf.MultiCell(0, 7, printText(pdf, text), "", "L", false)
pdf.Ln(3) pdf.Ln(3)
pdf.SetFont("teams", "", 11) pdf.SetFont("", "", 11)
} }
func body(pdf *fpdf.Fpdf, text string) { func body(pdf *fpdf.Fpdf, text string) {
@@ -64,57 +88,126 @@ func pdfBytes(pdf *fpdf.Fpdf) ([]byte, error) {
return output.Bytes(), nil return output.Bytes(), nil
} }
func textPDF(game, title, text string) ([]byte, error) { func textPDF(scenarioName, title, text string, isPlayer bool) ([]byte, error) {
pdf := newPDF("P") pdf := newPDF("P", isPlayer, scenarioName)
heading(pdf, title) heading(pdf, title)
body(pdf, game) if !isPlayer {
body(pdf, scenarioName)
}
body(pdf, text) body(pdf, text)
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func rulesPDF(rules string) ([]byte, error) { func rulesPDF(rules string) ([]byte, error) {
pdf := newPDF("P") pdf := newPDF("P", true, "")
for _, paragraph := range strings.Split(rules, "\n\n") { pdf.SetAutoPageBreak(false, 0)
if strings.HasPrefix(paragraph, "#") { type paragraph struct {
if pdf.GetY() > 245 { text string
pdf.AddPage() isHeading bool
} }
heading(pdf, strings.TrimSpace(strings.TrimLeft(paragraph, "#"))) var paragraphs []paragraph
} else { for _, raw := range strings.Split(rules, "\n\n") {
body(pdf, paragraph) isHeading := strings.HasPrefix(raw, "#")
text := raw
if isHeading {
text = strings.TrimSpace(strings.TrimLeft(raw, "#"))
} }
paragraphs = append(paragraphs, paragraph{
text: printText(pdf, text),
isHeading: isHeading,
})
}
if err := pdf.Error(); err != nil {
return nil, err
}
heightAt := func(size float64) float64 {
scale := size / 11
height := 0.0
for _, paragraph := range paragraphs {
fontSize, lineHeight := size, 6*scale
if paragraph.isHeading {
fontSize, lineHeight = 15*scale, 7*scale
}
pdf.SetFont("", "", fontSize)
height += float64(len(pdf.SplitText(paragraph.text, 180)))*lineHeight + 3*scale
}
return height
}
size := fitFontSize(heightAt, 277-pdf.GetY())
scale := size / 11
for _, paragraph := range paragraphs {
fontSize, lineHeight := size, 6*scale
if paragraph.isHeading {
fontSize, lineHeight = 15*scale, 7*scale
}
pdf.SetFont("", "", fontSize)
for _, line := range pdf.SplitText(paragraph.text, 180) {
pdf.CellFormat(180, lineHeight, line, "", 1, "L", false, 0, "")
}
pdf.Ln(3 * scale)
} }
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func questionsPDF(game string, questions []*storytelling.Question) ([]byte, error) { func questionsPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) {
pdf := newPDF("P") pdf := newPDF("P", true, scenarioName)
pdf.SetAutoPageBreak(false, 0)
heading(pdf, "Вопросы дела") heading(pdf, "Вопросы дела")
body(pdf, game) pdf.Ln(3)
body(pdf, "Команда: __________________________________________________") body(pdf, "Команда: __________________________________________________")
textCleaner := cleaner.NewCleaner() textCleaner := cleaner.NewCleaner()
texts := make([]string, len(questions))
for i, question := range questions { for i, question := range questions {
if pdf.GetY() > 235 { texts[i] = printText(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
pdf.AddPage() }
if err := pdf.Error(); err != nil {
return nil, err
}
size := fitFontSize(func(size float64) float64 {
pdf.SetFont("", "", size)
height := 0.0
for _, text := range texts {
height += (float64(len(pdf.SplitText(text, 180)))*6 + 37) * size / 11
} }
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text))) return height
if pdf.GetY() > 247 { }, 277-pdf.GetY())
pdf.AddPage() pdf.SetFont("", "", size)
scale := size / 11
for _, text := range texts {
for _, line := range pdf.SplitText(text, 180) {
pdf.CellFormat(180, 6*scale, line, "", 1, "L", false, 0, "")
} }
pdf.Ln(3 * scale)
for range 4 { for range 4 {
y := pdf.GetY() + 7 y := pdf.GetY() + 7*scale
pdf.Line(15, y, 195, y) pdf.Line(15, y, 195, y)
pdf.SetY(y) pdf.SetY(y)
} }
pdf.Ln(6) pdf.Ln(6 * scale)
} }
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func draftPDF(game, title string) ([]byte, error) { func fitFontSize(heightAt func(float64) float64, availableHeight float64) float64 {
pdf := newPDF("P") if heightAt(11) <= availableHeight {
return 11
}
low, high := 0.0, 11.0
for range 30 {
middle := (low + high) / 2
if heightAt(middle) <= availableHeight {
low = middle
} else {
high = middle
}
}
return low
}
func draftPDF(title string) ([]byte, error) {
pdf := newPDF("P", true, "")
heading(pdf, title) heading(pdf, title)
body(pdf, game) pdf.Ln(3)
body(pdf, "Команда: __________________________________________________") body(pdf, "Команда: __________________________________________________")
pdf.SetDrawColor(180, 180, 180) pdf.SetDrawColor(180, 180, 180)
for y := pdf.GetY() + 8; y < 277; y += 8 { for y := pdf.GetY() + 8; y < 277; y += 8 {
@@ -123,27 +216,25 @@ func draftPDF(game, title string) ([]byte, error) {
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func answersPDF(game string, story *storytelling.Story) ([]byte, error) { func answersPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) {
pdf := newPDF("P") pdf := newPDF("P", false, scenarioName)
heading(pdf, "Для ведущего: ответы и развязка") heading(pdf, "Для ведущего: ответы")
body(pdf, game) body(pdf, scenarioName)
textCleaner := cleaner.NewCleaner() textCleaner := cleaner.NewCleaner()
for i, question := range story.Questions { for i, question := range questions {
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text))) body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
body(pdf, "Ответ: "+question.Answer) body(pdf, "Ответ: "+question.Answer)
} }
if story.Conclusion != nil {
heading(pdf, "Развязка")
body(pdf, story.Conclusion.Text)
}
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) { func issuancePDF(scenarioName string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) {
pdf := newPDF("L") pdf := newPDF("L", false, scenarioName)
pdf.SetAutoPageBreak(false, 0) pdf.SetAutoPageBreak(false, 0)
const columnsPerPage = 8 const columnsPerPage = 8
const teamWidth, clueWidth, lineHeight = 51.0, 27.0, 4.0 const teamWidth, lineHeight = 51.0, 4.0
pageWidth, _ := pdf.GetPageSize()
textCleaner := cleaner.NewCleaner()
if len(clues) == 0 { if len(clues) == 0 {
clues = []*storytelling.Application{{Name: "Улика"}} clues = []*storytelling.Application{{Name: "Улика"}}
} }
@@ -154,16 +245,17 @@ func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Applica
} }
for start := 0; start < len(clues); start += columnsPerPage { for start := 0; start < len(clues); start += columnsPerPage {
end := min(start+columnsPerPage, len(clues)) end := min(start+columnsPerPage, len(clues))
clueWidth := (pageWidth - 30 - teamWidth) / float64(end-start)
labels := []string{"Команда / улика"} labels := []string{"Команда / улика"}
for _, clue := range clues[start:end] { for _, clue := range clues[start:end] {
labels = append(labels, clue.Name) labels = append(labels, textCleaner.ClearText(clue.Name))
} }
for row := 0; row < len(teams); { for row := 0; row < len(teams); {
if start > 0 || row > 0 { if start > 0 || row > 0 {
pdf.AddPage() pdf.AddPage()
} }
heading(pdf, "Лист выдачи улик") heading(pdf, "Лист выдачи улик")
body(pdf, game) body(pdf, scenarioName)
pdf.SetFont("teams", "", 9) pdf.SetFont("teams", "", 9)
widths := []float64{teamWidth} widths := []float64{teamWidth}
for range labels[1:] { for range labels[1:] {
@@ -0,0 +1,369 @@
package game_print_service
import (
"bytes"
"compress/zlib"
"errors"
"fmt"
"io"
"os/exec"
"regexp"
"strconv"
"strings"
"testing"
"evening_detective_server/docs"
"evening_detective_server/internal/modules/storytelling"
)
func TestRulesFitOnePage(t *testing.T) {
for _, tc := range []struct {
name string
rules string
wantError bool
}{
{"offline", docs.GameRules("offline"), false},
{"online", docs.GameRules("online"), false},
{"long rules", "# Правила\n\n" + strings.Repeat("## Раздел\n\nПроверьте показания свидетелей и сопоставьте время событий.\n\n", 80), false},
{"empty", "", false},
{"unsupported text", "# Правила 🕵", true},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := rulesPDF(tc.rules)
if tc.wantError {
if !errors.Is(err, ErrInvalidPrintText) {
t.Fatalf("error = %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if pages := bytes.Count(data, []byte("/Type /Page\n")); pages != 1 {
t.Fatalf("page count = %d", pages)
}
t.Run("contents", func(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
text := strings.Join(strings.Fields(extractedPDFText(t, data)), " ")
want := strings.Join(strings.Fields(strings.ReplaceAll(tc.rules, "#", "")), " ")
if !strings.Contains(text, want) {
t.Fatal("rules text or headings were lost")
}
})
})
}
}
func TestScenarioTitleStartsAtEighteenPoints(t *testing.T) {
for _, title := range []string{"Короткий сценарий"} {
t.Run(title, func(t *testing.T) {
pdf := newPDF("P", true, title)
pdf.SetCompression(false)
data, err := pdfBytes(pdf)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("18.00 Tf")) {
t.Fatal("short scenario title did not use eighteen points")
}
})
}
}
func TestPDFDoesNotPrintPageNumbers(t *testing.T) {
for _, isPlayer := range []bool{true, false} {
name := "organizer"
if isPlayer {
name = "player"
}
t.Run(name, func(t *testing.T) {
pdf := newPDF("P", isPlayer, "Сценарий")
pdf.SetCompression(false)
body(pdf, "Текст без номеров")
pdf.AddPage()
body(pdf, "Продолжение текста")
data, err := pdfBytes(pdf)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte("(\x001) Tj")) || bytes.Contains(data, []byte("(\x002) Tj")) {
t.Fatal("page number printed")
}
t.Run("contents", func(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
if regexp.MustCompile(`(?m)^\s*[12]\s*$`).MatchString(extractedPDFText(t, data)) {
t.Fatal("page number visible in extracted text")
}
})
})
}
}
func decodedPDFStreams(t *testing.T, data []byte) [][]byte {
t.Helper()
streams := regexp.MustCompile(`(?s)\nstream\n(.*?)\nendstream`).FindAllSubmatch(data, -1)
var contents [][]byte
for _, stream := range streams {
reader, err := zlib.NewReader(bytes.NewReader(stream[1]))
if err != nil {
continue
}
content, err := io.ReadAll(reader)
reader.Close()
if err != nil {
t.Fatal(err)
}
contents = append(contents, content)
}
return contents
}
func drawingLines(t *testing.T, data []byte) [][][]byte {
t.Helper()
pattern := regexp.MustCompile(`([\d.]+) ([\d.]+) m ([\d.]+) ([\d.]+) l S`)
var lines [][][]byte
for _, content := range decodedPDFStreams(t, data) {
lines = append(lines, pattern.FindAllSubmatch(content, -1)...)
}
return lines
}
func TestIssuanceTableUsesFullWidth(t *testing.T) {
for _, count := range []int{0, 1, 8, 9, 17} {
t.Run(fmt.Sprint(count), func(t *testing.T) {
var clues []*storytelling.Application
for i := range count {
clues = append(clues, &storytelling.Application{Name: fmt.Sprintf("Улика %d", i+1)})
}
data, err := issuancePDF("Сценарий", nil, clues)
if err != nil {
t.Fatal(err)
}
pattern := regexp.MustCompile(`([\d.]+) ([\d.]+) ([\d.]+) -[\d.]+ re S`)
pages := 0
for _, content := range decodedPDFStreams(t, data) {
rectangles := pattern.FindAllSubmatch(content, -1)
if len(rectangles) == 0 {
continue
}
pages++
right := 0.0
for _, rectangle := range rectangles {
x, err := strconv.ParseFloat(string(rectangle[1]), 64)
if err != nil {
t.Fatal(err)
}
width, err := strconv.ParseFloat(string(rectangle[3]), 64)
if err != nil {
t.Fatal(err)
}
right = max(right, x+width)
}
if right < 799.3 || right > 799.45 {
t.Fatalf("table right edge = %f points, want A4 landscape minus 15 mm", right)
}
}
if pages != max(1, (count+7)/8) {
t.Fatalf("table pages = %d", pages)
}
})
}
}
func TestIssuanceClueLabelsMatchPlayers(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
for _, name := range []string{"Письмо ([код-1])", " Письмо "} {
t.Run(name, func(t *testing.T) {
clue := &storytelling.Application{Name: name}
data, err := issuancePDF("Сценарий", nil, []*storytelling.Application{clue})
if err != nil {
t.Fatal(err)
}
text := extractedPDFText(t, data)
if !strings.Contains(text, "Письмо") || strings.Contains(text, "([") || strings.Contains(text, "код-1") {
t.Fatal("clue label was not cleaned")
}
if clue.Name != name {
t.Fatal("original clue name was changed")
}
})
}
}
func TestQuestionsAlwaysUseOnePage(t *testing.T) {
for _, tc := range []struct {
name string
count int
text string
}{
{"empty", 0, ""},
{"ordinary", 3, "Кто виновен и какие улики это подтверждают?"},
{"many", 40, "Кто виновен и какие улики это подтверждают?"},
{"very many", 200, "Проверьте показания свидетелей."},
{"long paragraphs", 4, strings.Repeat("Сопоставьте показания свидетелей и время событий. ", 200)},
{"explicit line breaks", 2, strings.Repeat("Проверьте показания.\n", 80)},
} {
t.Run(tc.name, func(t *testing.T) {
questions := make([]*storytelling.Question, tc.count)
for i := range tc.count {
questions[i] = &storytelling.Question{Text: tc.text}
}
data, err := questionsPDF("Название сценария", questions)
if err != nil {
t.Fatal(err)
}
if pages := bytes.Count(data, []byte("/Type /Page\n")); pages != 1 {
t.Fatalf("page count = %d", pages)
}
lines := drawingLines(t, data)
if len(lines) != 1+4*tc.count {
t.Fatalf("drawing lines = %d, expected header and four lines per question", len(lines))
}
for _, line := range lines {
y, err := strconv.ParseFloat(string(line[2]), 64)
if err != nil || y < 56.68 {
t.Fatalf("answer line crosses bottom margin: %s", line[0])
}
}
})
}
}
func TestLongScenarioTitleFitsHeader(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
for _, title := range []string{"Дело №17 «Тайна поезда»", strings.Repeat("Очень длинное название сценария ", 10)} {
t.Run(title, func(t *testing.T) {
data, err := textPDF(title, "Введение", strings.Repeat("История расследования.\n", 100), true)
if err != nil {
t.Fatal(err)
}
text := extractedPDFText(t, data)
pages := bytes.Count(data, []byte("/Type /Page\n"))
if strings.Count(text, strings.TrimSpace(title)) != pages {
t.Fatalf("title is missing, truncated or duplicated in %d pages", pages)
}
})
}
}
func TestPlayerPDFUsesBrandAssets(t *testing.T) {
for _, tc := range []struct {
name string
generate func() ([]byte, error)
}{
{"rules", func() ([]byte, error) { return rulesPDF(docs.GameRules("offline")) }},
{"introduction", func() ([]byte, error) {
return textPDF("Дело №1", "Введение", "История расследования", true)
}},
{"questions", func() ([]byte, error) {
return questionsPDF("Дело №1", []*storytelling.Question{{Text: "Кто виновен?"}})
}},
{"draft", func() ([]byte, error) {
return draftPDF("Черновик расследования")
}},
{"list draft", func() ([]byte, error) { return draftPDF("Список пидарасов") }},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.generate()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) || bytes.Contains(data, []byte("/BaseFont /utf8teams")) {
t.Fatal("player document does not use the frontend font")
}
if !bytes.Contains(data, printLogo) || bytes.Count(data, []byte("/Subtype /Image")) != 1 {
t.Fatal("original logo was not embedded once")
}
})
}
}
func TestPlayerHeaderOnEveryPage(t *testing.T) {
for _, count := range []int{1, 3} {
t.Run(strings.Repeat("page ", count), func(t *testing.T) {
pdf := newPDF("P", true, "Дело №1")
pdf.SetCompression(false)
for page := range count {
if page > 0 {
pdf.AddPage()
}
if pdf.GetY() != 43 {
t.Fatalf("content starts at %f mm", pdf.GetY())
}
body(pdf, "Текст под фирменной шапкой")
}
data, err := pdfBytes(pdf)
if err != nil {
t.Fatal(err)
}
if bytes.Count(data, []byte(" Do Q\n")) != count {
t.Fatal("logo not drawn on every page")
}
})
}
}
func TestOrganizerPDFKeepsExistingStyle(t *testing.T) {
for _, tc := range []struct {
name string
generate func() ([]byte, error)
}{
{"answers", func() ([]byte, error) { return answersPDF("Дело №1", nil) }},
{"issuance", func() ([]byte, error) { return issuancePDF("Дело №1", nil, nil) }},
{"external files", func() ([]byte, error) {
return textPDF("Дело №1", "Материалы для ручного скачивания", "https://example.test", false)
}},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.generate()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("/BaseFont /utf8teams")) || bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) || bytes.Contains(data, printLogo) {
t.Fatal("organizer document style changed")
}
})
}
}
func TestPlayerLongTextKeepsHeadersAndContents(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
for _, tc := range []struct {
name string
generate func() ([]byte, error)
end string
isSinglePage bool
}{
{"introduction", func() ([]byte, error) {
return textPDF("Дело №1", "Введение", strings.Repeat("Следователь изучает улики и проверяет показания свидетелей.\n", 100)+"Конец введения", true)
}, "Конец введения", false},
{"questions", func() ([]byte, error) {
return questionsPDF("Дело №1", []*storytelling.Question{
{Text: strings.Repeat("Сопоставьте показания свидетелей и время событий. ", 200)},
{Text: "Последний вопрос"},
})
}, "Последний вопрос", true},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.generate()
if err != nil {
t.Fatal(err)
}
text := extractedPDFText(t, data)
pages := bytes.Count(data, []byte("/Type /Page\n"))
if (tc.isSinglePage && pages != 1) || (!tc.isSinglePage && pages < 2) || strings.Count(text, "Вечерний детектив") != pages || strings.Count(text, "Дело №1") != pages || !strings.Contains(text, tc.end) {
t.Fatalf("missing headers or content in %d pages", pages)
}
})
}
}
@@ -128,12 +128,12 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st
generate func() ([]byte, error) generate func() ([]byte, error)
}{ }{
{playerFolder + "Правила.pdf", func() ([]byte, error) { return rulesPDF(docs.GameRules(game.Format)) }}, {playerFolder + "Правила.pdf", func() ([]byte, error) { return rulesPDF(docs.GameRules(game.Format)) }},
{playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(game.Name, "Введение", intro) }}, {playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(scenario.Name, "Введение", intro, true) }},
{playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(game.Name, story.Questions) }}, {playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(scenario.Name, story.Questions) }},
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Список пидарасов") }}, {playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF("Список пидарасов") }},
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Черновик расследования") }}, {playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF("Черновик расследования") }},
{hostFolder + "Ответы и развязка.pdf", func() ([]byte, error) { return answersPDF(game.Name, story) }}, {hostFolder + "Ответы.pdf", func() ([]byte, error) { return answersPDF(scenario.Name, story.Questions) }},
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(game.Name, teams, clues) }}, {hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(scenario.Name, teams, clues) }},
} }
for _, document := range documents { for _, document := range documents {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -183,7 +183,7 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st
} }
} }
if len(external) > 0 { if len(external) > 0 {
data, err := textPDF(game.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n")) data, err := textPDF(scenario.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"), false)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -22,15 +22,16 @@ import (
type printFixture struct { type printFixture struct {
file_storage.IFileStorage file_storage.IFileStorage
game *repos.Game game *repos.Game
story *storytelling.Story scenarioName string
teams []*repos.Team story *storytelling.Story
files map[string][]byte teams []*repos.Team
gameErr error files map[string][]byte
scenarioErr error gameErr error
teamsErr error scenarioErr error
fileErr error teamsErr error
qrCalls int fileErr error
qrCalls int
} }
func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) { func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) {
@@ -38,7 +39,7 @@ func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) {
} }
func (f *printFixture) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) { func (f *printFixture) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) {
return &scenarios_service.Scenario{Story: f.story}, f.scenarioErr return &scenarios_service.Scenario{Name: f.scenarioName, Story: f.story}, f.scenarioErr
} }
func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) { func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) {
@@ -73,7 +74,8 @@ func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File
func newPrintFixture() *printFixture { func newPrintFixture() *printFixture {
clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"} clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"}
return &printFixture{ return &printFixture{
game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2}, game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2},
scenarioName: "Сценарий №2",
story: &storytelling.Story{ story: &storytelling.Story{
Introduction: &storytelling.Introduction{Text: "Начало расследования"}, Introduction: &storytelling.Introduction{Text: "Начало расследования"},
Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}}, Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}},
@@ -148,7 +150,7 @@ func TestDownloadPrintMaterials(t *testing.T) {
for _, name := range []string{ for _, name := range []string{
playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf", playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf",
playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf", playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf",
hostFolder + "Ответы и развязка.pdf", hostFolder + "Лист выдачи улик.pdf", hostFolder + "Ответы.pdf", hostFolder + "Лист выдачи улик.pdf",
} { } {
if !bytes.HasPrefix(entries[name], []byte("%PDF-")) { if !bytes.HasPrefix(entries[name], []byte("%PDF-")) {
t.Errorf("missing PDF %s", name) t.Errorf("missing PDF %s", name)
@@ -190,7 +192,7 @@ func TestDownloadPrintErrors(t *testing.T) {
{"team read error", func(f *printFixture) { f.teamsErr = broken }, broken}, {"team read error", func(f *printFixture) { f.teamsErr = broken }, broken},
{"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound}, {"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound},
{"file read error", func(f *printFixture) { f.fileErr = broken }, broken}, {"file read error", func(f *printFixture) { f.fileErr = broken }, broken},
{"unsupported text", func(f *printFixture) { f.game.Name = "Дело 🕵️" }, ErrInvalidPrintText}, {"unsupported text", func(f *printFixture) { f.scenarioName = "Дело 🕵️" }, ErrInvalidPrintText},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
f := newPrintFixture() f := newPrintFixture()
@@ -261,8 +263,11 @@ func TestPrintedContents(t *testing.T) {
if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") { if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") {
t.Fatalf("organizer secrets in %s", name) t.Fatalf("organizer secrets in %s", name)
} }
if strings.Contains(name, "Ответы и развязка") && (!strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") || !strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА")) { if strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА") {
t.Fatal("missing organizer secrets") t.Fatalf("conclusion included in %s", name)
}
if name == hostFolder+"Ответы.pdf" && !strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") {
t.Fatal("missing organizer answers")
} }
if strings.Contains(name, "QR команд") || strings.Contains(name, "Лист выдачи") { if strings.Contains(name, "QR команд") || strings.Contains(name, "Лист выдачи") {
if strings.Contains(text, "Отменённая") || !strings.Contains(text, "Детективы") || !strings.Contains(text, "Ожидающая") { if strings.Contains(text, "Отменённая") || !strings.Contains(text, "Детективы") || !strings.Contains(text, "Ожидающая") {
@@ -283,6 +288,49 @@ func TestPrintedContents(t *testing.T) {
} }
} }
func TestPrintArchiveUsesScenarioTitle(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
for _, gameName := range []string{"Название игры не для печати", "Игра с эмодзи 🕵"} {
t.Run(gameName, func(t *testing.T) {
f := newPrintFixture()
f.game.Name = gameName
f.scenarioName = "Настоящий сценарий"
f.story.Places[0].Applications = append(f.story.Places[0].Applications,
&storytelling.Application{Name: "Внешняя улика", Image: "https://external.test/clue.pdf"})
data, _, err := f.service().Download(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
for name, data := range readPrintArchive(t, data) {
if !strings.HasSuffix(name, ".pdf") || strings.Contains(name, "Улики/") {
continue
}
text := extractedPDFText(t, data)
if strings.Contains(text, gameName) {
t.Fatalf("game title printed in %s", name)
}
if strings.Contains(name, "QR команд") {
continue
}
if name == playerFolder+"Правила.pdf" || name == playerFolder+"Черновик расследования.pdf" || name == playerFolder+"Список пидарасов.pdf" {
if strings.Contains(text, f.scenarioName) {
t.Fatalf("scenario title printed in %s", name)
}
continue
}
if !strings.Contains(text, f.scenarioName) {
t.Fatalf("scenario title missing in %s", name)
}
if strings.HasPrefix(name, playerFolder) && strings.Count(text, f.scenarioName) != bytes.Count(data, []byte("/Type /Page\n")) {
t.Fatalf("scenario title not repeated in header or duplicated in body in %s", name)
}
}
})
}
}
func TestPrintCluesPreserveOriginalFiles(t *testing.T) { func TestPrintCluesPreserveOriginalFiles(t *testing.T) {
for _, extension := range []string{"pdf", "png", "webp", "mp3"} { for _, extension := range []string{"pdf", "png", "webp", "mp3"} {
t.Run(extension, func(t *testing.T) { t.Run(extension, func(t *testing.T) {