dana-medical-site/internal/sberhealth/biff_test.go
2026-10-14 13:33:13 +00:00

291 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sberhealth
import (
"encoding/binary"
"fmt"
"math"
"math/rand"
"reflect"
"strings"
"testing"
"unicode/utf16"
)
// ---- сборка синтетической книги BIFF8: без персональных данных ----
func biffRec(id uint16, data []byte) []byte {
out := make([]byte, 4, 4+len(data))
binary.LittleEndian.PutUint16(out, id)
binary.LittleEndian.PutUint16(out[2:], uint16(len(data)))
return append(out, data...)
}
func le16(v int) []byte { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, uint16(v)); return b }
func le32(v uint32) []byte { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); return b }
func latin1(s string) bool {
for _, r := range s {
if r > 0xFF {
return false
}
}
return true
}
type testString struct {
text string
runs int // блоки форматирования (по 4 байта)
ext int // байты фонетики
}
// sstRecords раскладывает строки по записям SST и CONTINUE так же, как это делает Excel:
// строка может прерваться на любой границе, а у продолжения первым байтом идёт флаг кодировки;
// на каждой границе кодировка может поменяться.
func sstRecords(strs []testString, maxData int) []byte {
var chunks [][]byte
cur := append(le32(uint32(len(strs))), le32(uint32(len(strs)))...)
flush := func() { chunks = append(chunks, cur); cur = nil }
space := func() int { return maxData - len(cur) }
for _, s := range strs {
units := utf16.Encode([]rune(s.text))
headLen := 3
if s.runs > 0 {
headLen += 2
}
if s.ext > 0 {
headLen += 4
}
if space() < headLen+1 { // заголовок не делится между записями
flush()
}
flags := byte(0)
if !latin1(s.text) {
flags |= 1
}
if s.runs > 0 {
flags |= 0x08
}
if s.ext > 0 {
flags |= 0x04
}
cur = append(cur, le16(len(units))...)
cur = append(cur, flags)
if s.runs > 0 {
cur = append(cur, le16(s.runs)...)
}
if s.ext > 0 {
cur = append(cur, le32(uint32(s.ext))...)
}
high := flags&1 != 0
for done := 0; done < len(units); {
width := 1
if high {
width = 2
}
n := min(len(units)-done, space()/width)
if n == 0 {
flush()
rest := units[done:]
high = false
for _, u := range rest {
if u > 0xFF {
high = true
}
}
flag := byte(0)
if high {
flag = 1
}
cur = append(cur, flag)
continue
}
for _, u := range units[done : done+n] {
if high {
cur = append(cur, byte(u), byte(u>>8))
} else {
cur = append(cur, byte(u))
}
}
done += n
}
for left := s.runs*4 + s.ext; left > 0; {
if space() == 0 {
flush()
}
n := min(left, space())
cur = append(cur, make([]byte, n)...)
left -= n
}
}
flush()
var out []byte
for i, c := range chunks {
id := uint16(recContinue)
if i == 0 {
id = recSST
}
out = append(out, biffRec(id, c)...)
}
return out
}
func boundSheetRec(name string, offset int) []byte {
d := append(le32(uint32(offset)), 0, 0, byte(len(name)), 0)
return biffRec(recBoundSheet, append(d, []byte(name)...))
}
func bofRec(kind int) []byte {
return biffRec(recBOF, append(append(le16(biff8Version), le16(kind)...), make([]byte, 12)...))
}
func labelSSTCell(row, col, idx int) []byte {
return biffRec(recLabelSST, append(append(append(le16(row), le16(col)...), le16(0)...), le32(uint32(idx))...))
}
func numberCell(row, col int, f float64) []byte {
d := append(append(le16(row), le16(col)...), le16(0)...)
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, math.Float64bits(f))
return biffRec(recNumber, append(d, b...))
}
func rkCell(row, col int, rk uint32) []byte {
return biffRec(recRK, append(append(append(le16(row), le16(col)...), le16(0)...), le32(rk)...))
}
func mulRKCells(row, first int, rks ...uint32) []byte {
d := append(le16(row), le16(first)...)
for _, rk := range rks {
d = append(d, le16(0)...)
d = append(d, le32(rk)...)
}
d = append(d, le16(first+len(rks)-1)...)
return biffRec(recMulRK, d)
}
// buildBook собирает книгу: общая часть (SST) и один лист с готовыми записями ячеек.
func buildBook(strs []testString, maxData int, name string, cells ...[]byte) []byte {
sst := sstRecords(strs, maxData)
globalsLen := len(bofRec(5)) + len(boundSheetRec(name, 0)) + len(sst) + len(biffRec(recEOF, nil))
var out []byte
out = append(out, bofRec(5)...)
out = append(out, boundSheetRec(name, globalsLen)...)
out = append(out, sst...)
out = append(out, biffRec(recEOF, nil)...)
out = append(out, bofRec(0x10)...)
for _, c := range cells {
out = append(out, c...)
}
out = append(out, biffRec(recEOF, nil)...)
return out
}
// Главный регрессионный тест: строки через границы CONTINUE при любых размерах записей.
func TestSSTStringsAcrossContinueBoundaries(t *testing.T) {
rng := rand.New(rand.NewSource(7))
words := []string{"Врач", "Невролог", "Лабораторная диагностика", "ул. Ляхова, д. 7", "Report", "Дата создания записи", "Привет, мир", "Qwerty"}
var strs []testString
var want []string
for i := 0; i < 220; i++ {
var parts []string
for k := rng.Intn(4); k >= 0; k-- {
parts = append(parts, words[rng.Intn(len(words))])
}
s := strings.Join(parts, " ")
if i%7 == 0 {
s = fmt.Sprintf("%02d.%02d.2026 %02d:%02d", i%28+1, i%12+1, i%24, i%60) // ASCII: сжатая форма
}
ts := testString{text: s}
if i%11 == 0 {
ts.runs = 2
}
if i%13 == 0 {
ts.ext = 9
}
strs = append(strs, ts)
want = append(want, s)
}
for _, maxData := range []int{24, 25, 31, 40, 63, 64, 97, 128, 255, 1000, 8224} {
cells := make([][]byte, len(strs))
for i := range strs {
cells[i] = labelSSTCell(i, 0, i)
}
sheets, err := parseBIFF8(buildBook(strs, maxData, "Report", cells...))
if err != nil {
t.Fatalf("maxData=%d: %v", maxData, err)
}
if len(sheets) != 1 || sheets[0].name != "Report" || len(sheets[0].rows) != len(want) {
t.Fatalf("maxData=%d: неверный состав листов", maxData)
}
for i, w := range want {
if got := sheets[0].rows[i][0]; got != w {
t.Fatalf("maxData=%d, строка %d: получили %q, ожидали %q", maxData, i, got, w)
}
}
}
}
func TestNumbersAndShape(t *testing.T) {
strs := []testString{{text: "a"}, {text: "b"}}
book := buildBook(strs, 100, "Report",
labelSSTCell(0, 1, 1), // ряд 0 начинается с пустого столбца 0
numberCell(1, 0, 59935608),
numberCell(1, 1, 2500.5),
rkCell(2, 0, uint32(7<<2)|2), // целое 7
rkCell(2, 1, uint32(12345<<2)|2|1), // 123,45
mulRKCells(3, 2, uint32(10<<2)|2, uint32(20<<2)|2),
labelSSTCell(4, 0, 0),
)
sheets, err := parseBIFF8(book)
if err != nil {
t.Fatal(err)
}
want := [][]string{{"", "b"}, {"59935608", "2500.5"}, {"7", "123.45"}, {"", "", "10", "20"}, {"a"}}
if !reflect.DeepEqual(sheets[0].rows, want) {
t.Fatalf("получили %q, ожидали %q", sheets[0].rows, want)
}
}
func TestUnsupportedAndBrokenInput(t *testing.T) {
strs := []testString{{text: "x"}}
formula := biffRec(recFormula, make([]byte, 20))
if _, err := parseBIFF8(buildBook(strs, 100, "Report", labelSSTCell(0, 0, 0), formula)); err == nil {
t.Error("формула должна давать явную ошибку, а не пропускаться")
}
if _, err := parseBIFF8(buildBook(strs, 100, "Report", labelSSTCell(0, 0, 5))); err == nil {
t.Error("ссылка на несуществующую строку должна давать ошибку")
}
good := buildBook(strs, 100, "Report", labelSSTCell(0, 0, 0))
for cut := 1; cut < len(good); cut += 7 {
func() {
defer func() {
if recover() != nil {
t.Fatalf("обрезанная книга (%d байт) вызвала панику", cut)
}
}()
_, _ = parseBIFF8(good[:cut])
}()
}
bad := append([]byte(nil), good...)
binary.LittleEndian.PutUint16(bad[4:], 0x0500) // BIFF5
if _, err := parseBIFF8(bad); err == nil {
t.Error("BIFF5 должен отклоняться")
}
huge := buildBook(strs, 100, "Report", labelSSTCell(maxSheetRows, 0, 0))
if _, err := parseBIFF8(huge); err == nil {
t.Error("слишком большой лист должен отклоняться")
}
}
func TestReadXLSRejectsNonExcel(t *testing.T) {
for name, data := range map[string][]byte{
"html": []byte("<html><table><tr><td>x</td></tr></table></html>" + strings.Repeat(" ", 600)),
"пусто": nil,
"zip": append([]byte{'P', 'K', 3, 4}, make([]byte, 600)...),
} {
if _, err := ReadXLS(data); err == nil {
t.Errorf("%s: ожидали ошибку", name)
}
}
}