generated from VLADIMIR/template
up
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -2,6 +2,7 @@ package game_print_service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -14,17 +15,40 @@ import (
|
||||
"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.SetMargins(15, 15, 15)
|
||||
pdf.SetAutoPageBreak(true, 15)
|
||||
team_qr_pdf.RegisterFont(pdf)
|
||||
pdf.SetFont("teams", "", 11)
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-12)
|
||||
pdf.SetFont("teams", "", 8)
|
||||
pdf.CellFormat(0, 5, fmt.Sprint(pdf.PageNo()), "", 0, "C", false, 0, "")
|
||||
})
|
||||
if isPlayer {
|
||||
pdf.SetTopMargin(43)
|
||||
pdf.AddUTF8FontFromBytes("typewriter", "", typewriterFont)
|
||||
pdf.SetFont("typewriter", "", 11)
|
||||
pdf.RegisterImageOptionsReader("game-logo", fpdf.ImageOptions{ImageType: "JPG"}, bytes.NewReader(printLogo))
|
||||
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()
|
||||
return pdf
|
||||
}
|
||||
@@ -45,10 +69,10 @@ func printText(pdf *fpdf.Fpdf, text string) 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.Ln(3)
|
||||
pdf.SetFont("teams", "", 11)
|
||||
pdf.SetFont("", "", 11)
|
||||
}
|
||||
|
||||
func body(pdf *fpdf.Fpdf, text string) {
|
||||
@@ -64,57 +88,126 @@ func pdfBytes(pdf *fpdf.Fpdf) ([]byte, error) {
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func textPDF(game, title, text string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
func textPDF(scenarioName, title, text string, isPlayer bool) ([]byte, error) {
|
||||
pdf := newPDF("P", isPlayer, scenarioName)
|
||||
heading(pdf, title)
|
||||
body(pdf, game)
|
||||
if !isPlayer {
|
||||
body(pdf, scenarioName)
|
||||
}
|
||||
body(pdf, text)
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func rulesPDF(rules string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
for _, paragraph := range strings.Split(rules, "\n\n") {
|
||||
if strings.HasPrefix(paragraph, "#") {
|
||||
if pdf.GetY() > 245 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
heading(pdf, strings.TrimSpace(strings.TrimLeft(paragraph, "#")))
|
||||
} else {
|
||||
body(pdf, paragraph)
|
||||
pdf := newPDF("P", true, "")
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
type paragraph struct {
|
||||
text string
|
||||
isHeading bool
|
||||
}
|
||||
var paragraphs []paragraph
|
||||
for _, raw := range strings.Split(rules, "\n\n") {
|
||||
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)
|
||||
}
|
||||
|
||||
func questionsPDF(game string, questions []*storytelling.Question) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
func questionsPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) {
|
||||
pdf := newPDF("P", true, scenarioName)
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
heading(pdf, "Вопросы дела")
|
||||
body(pdf, game)
|
||||
pdf.Ln(3)
|
||||
body(pdf, "Команда: __________________________________________________")
|
||||
textCleaner := cleaner.NewCleaner()
|
||||
texts := make([]string, len(questions))
|
||||
for i, question := range questions {
|
||||
if pdf.GetY() > 235 {
|
||||
pdf.AddPage()
|
||||
texts[i] = printText(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
|
||||
}
|
||||
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)))
|
||||
if pdf.GetY() > 247 {
|
||||
pdf.AddPage()
|
||||
return height
|
||||
}, 277-pdf.GetY())
|
||||
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 {
|
||||
y := pdf.GetY() + 7
|
||||
y := pdf.GetY() + 7*scale
|
||||
pdf.Line(15, y, 195, y)
|
||||
pdf.SetY(y)
|
||||
}
|
||||
pdf.Ln(6)
|
||||
pdf.Ln(6 * scale)
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func draftPDF(game, title string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
func fitFontSize(heightAt func(float64) float64, availableHeight float64) float64 {
|
||||
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)
|
||||
body(pdf, game)
|
||||
pdf.Ln(3)
|
||||
body(pdf, "Команда: __________________________________________________")
|
||||
pdf.SetDrawColor(180, 180, 180)
|
||||
for y := pdf.GetY() + 8; y < 277; y += 8 {
|
||||
@@ -123,27 +216,25 @@ func draftPDF(game, title string) ([]byte, error) {
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func answersPDF(game string, story *storytelling.Story) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
heading(pdf, "Для ведущего: ответы и развязка")
|
||||
body(pdf, game)
|
||||
func answersPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) {
|
||||
pdf := newPDF("P", false, scenarioName)
|
||||
heading(pdf, "Для ведущего: ответы")
|
||||
body(pdf, scenarioName)
|
||||
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, "Ответ: "+question.Answer)
|
||||
}
|
||||
if story.Conclusion != nil {
|
||||
heading(pdf, "Развязка")
|
||||
body(pdf, story.Conclusion.Text)
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) {
|
||||
pdf := newPDF("L")
|
||||
func issuancePDF(scenarioName string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) {
|
||||
pdf := newPDF("L", false, scenarioName)
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
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 {
|
||||
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 {
|
||||
end := min(start+columnsPerPage, len(clues))
|
||||
clueWidth := (pageWidth - 30 - teamWidth) / float64(end-start)
|
||||
labels := []string{"Команда / улика"}
|
||||
for _, clue := range clues[start:end] {
|
||||
labels = append(labels, clue.Name)
|
||||
labels = append(labels, textCleaner.ClearText(clue.Name))
|
||||
}
|
||||
for row := 0; row < len(teams); {
|
||||
if start > 0 || row > 0 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
heading(pdf, "Лист выдачи улик")
|
||||
body(pdf, game)
|
||||
body(pdf, scenarioName)
|
||||
pdf.SetFont("teams", "", 9)
|
||||
widths := []float64{teamWidth}
|
||||
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)
|
||||
}{
|
||||
{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 questionsPDF(game.Name, story.Questions) }},
|
||||
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Список пидарасов") }},
|
||||
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Черновик расследования") }},
|
||||
{hostFolder + "Ответы и развязка.pdf", func() ([]byte, error) { return answersPDF(game.Name, story) }},
|
||||
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(game.Name, teams, clues) }},
|
||||
{playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(scenario.Name, "Введение", intro, true) }},
|
||||
{playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(scenario.Name, story.Questions) }},
|
||||
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF("Список пидарасов") }},
|
||||
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF("Черновик расследования") }},
|
||||
{hostFolder + "Ответы.pdf", func() ([]byte, error) { return answersPDF(scenario.Name, story.Questions) }},
|
||||
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(scenario.Name, teams, clues) }},
|
||||
}
|
||||
for _, document := range documents {
|
||||
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 {
|
||||
data, err := textPDF(game.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"))
|
||||
data, err := textPDF(scenario.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"), false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -22,15 +22,16 @@ import (
|
||||
|
||||
type printFixture struct {
|
||||
file_storage.IFileStorage
|
||||
game *repos.Game
|
||||
story *storytelling.Story
|
||||
teams []*repos.Team
|
||||
files map[string][]byte
|
||||
gameErr error
|
||||
scenarioErr error
|
||||
teamsErr error
|
||||
fileErr error
|
||||
qrCalls int
|
||||
game *repos.Game
|
||||
scenarioName string
|
||||
story *storytelling.Story
|
||||
teams []*repos.Team
|
||||
files map[string][]byte
|
||||
gameErr error
|
||||
scenarioErr error
|
||||
teamsErr error
|
||||
fileErr error
|
||||
qrCalls int
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -73,7 +74,8 @@ func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File
|
||||
func newPrintFixture() *printFixture {
|
||||
clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"}
|
||||
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{
|
||||
Introduction: &storytelling.Introduction{Text: "Начало расследования"},
|
||||
Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}},
|
||||
@@ -148,7 +150,7 @@ func TestDownloadPrintMaterials(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
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-")) {
|
||||
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},
|
||||
{"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound},
|
||||
{"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) {
|
||||
f := newPrintFixture()
|
||||
@@ -261,8 +263,11 @@ func TestPrintedContents(t *testing.T) {
|
||||
if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") {
|
||||
t.Fatalf("organizer secrets in %s", name)
|
||||
}
|
||||
if strings.Contains(name, "Ответы и развязка") && (!strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") || !strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА")) {
|
||||
t.Fatal("missing organizer secrets")
|
||||
if strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА") {
|
||||
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(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) {
|
||||
for _, extension := range []string{"pdf", "png", "webp", "mp3"} {
|
||||
t.Run(extension, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user