291 lines
8.8 KiB
Go
291 lines
8.8 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|