feat(m2m): доменная модель сообщений + парсер windows-1251 + round-trip тесты
- internal/m2m/types.go: enum'ы и simple-типы из XSD НРД (M2MSchemas_260408) - internal/m2m/validators.go: pattern-валидаторы ReferenceID/ISIN/INN/UUID/SecurityCode/IdentityDocSerial/AccountID + перечисления - internal/m2m/messages.go: структуры 6 типов сообщений M2M, choice-типы через указатели, IsM2M=true автоматически в MarshalXML - internal/nsdxml/datetime.go: тип NSDDateTime (формат "YYYY-MM-DDThh:mm:ss(МСК+N)") - internal/nsdxml/codec.go: Marshal/Unmarshal XML в windows-1251 (собственный кодек CP1251, без внешних зависимостей) - internal/m2m/messages_test.go: round-trip тесты на 6 примерах + 2 эталонах из DOC/ Покрытие: m2m 73.9%, nsdxml 92.5%. make ci зелёный. Отклонение от спеки: вместо golang.org/x/text/encoding/charmap собственная таблица CP1251 на ~60 строк, потому что прокси zetit блокирует proxy.golang.org, goproxy.cn и redirect-хосты Go-модулей. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,39 @@
|
||||
# internal/nsdxml — сериализация и парсинг XML по правилам НРД
|
||||
|
||||
Особенности, которые этот пакет обязан учитывать:
|
||||
## Что реализовано
|
||||
|
||||
- **Кодировка `windows-1251`** на чтение и запись (XML-объявление и тело).
|
||||
Для конвертации — `golang.org/x/text/encoding/charmap.Windows1251`.
|
||||
- **Тип `NSDDateTime`** — формат `YYYY-MM-DDThh:mm:ss(МСК[+/-N])`,
|
||||
pattern из XSD:
|
||||
- **`Marshal(v) ([]byte, error)` / `Unmarshal(data, v) error`** —
|
||||
сериализация и разбор XML с прологом
|
||||
`<?xml version="1.0" encoding="windows-1251"?>`. `Unmarshal` понимает
|
||||
как windows-1251, так и UTF-8 на входе через `CharsetReader`.
|
||||
- **Кодек windows-1251** (собственный, без внешних зависимостей):
|
||||
`EncodeWindows1251`, `DecodeWindows1251`. Таблица соответствия
|
||||
CP1251 ↔ Unicode для байтов 0x80..0xFF. Руны, не выразимые в CP1251,
|
||||
приводят к ошибке `ErrUnmappable`.
|
||||
- **`CharsetReader(charset, input)`** — пригодно к использованию в
|
||||
`xml.Decoder.CharsetReader`. Поддерживает `windows-1251`, `cp1251`,
|
||||
`utf-8`, отсутствие charset; на другие — ошибка.
|
||||
- **`NSDDateTime`** — отметка времени НРД формата
|
||||
`YYYY-MM-DDThh:mm:ss(МСК[+-N])`, regex из XSD:
|
||||
`[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\(МСК([+-][0-9]{1,2})?\)`.
|
||||
Только зона МСК, опциональный сдвиг.
|
||||
- **Каноникализация `xml-exc-c14n`** для проверки совпадения с эталоном.
|
||||
- **Round-trip**: `Marshal(Unmarshal(x)) == x` после канонизации.
|
||||
Реализованы `MarshalXML`, `UnmarshalXML`, `MarshalText`,
|
||||
`UnmarshalText`, `String`, `Now`. Сохраняет различие между «(МСК)»
|
||||
без сдвига и «(МСК+0)» через `OffsetSpecified`.
|
||||
|
||||
Реализация — задача M1 (см. план).
|
||||
## Зачем свой кодек windows-1251
|
||||
|
||||
Сетевая политика dev-стенда блокирует
|
||||
`proxy.golang.org`, `goproxy.cn` и redirect-хосты Go-модулей
|
||||
(`golang.org/x/*`, `google.golang.org/*` и др.), поэтому штатный
|
||||
`golang.org/x/text/encoding/charmap` пакетным менеджером не
|
||||
устанавливается. Внутренняя реализация занимает ~50 строк и не вносит
|
||||
внешних зависимостей.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Round-trip на ASCII и кириллице, включая cp1251-пунктуацию
|
||||
(`№`, `©`, `«»`, `„"`).
|
||||
- Негативный кейс — недостижимая руна (эмодзи) даёт `ErrUnmappable`.
|
||||
- `NSDDateTime` на всех вариантах смещения: `(МСК)`, `(МСК+2)`,
|
||||
`(МСК-1)`, `(МСК+12)`.
|
||||
- Покрытие — 92.5%.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package nsdxml
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// xmlProlog — пролог windows-1251 XML, который мы пишем при сериализации.
|
||||
const xmlProlog = `<?xml version="1.0" encoding="windows-1251"?>` + "\n"
|
||||
|
||||
// cp1251High — таблица соответствия байтов 0x80..0xFF из windows-1251
|
||||
// в Unicode rune. Индекс таблицы — (byte - 0x80).
|
||||
//
|
||||
// Источник: стандартное соответствие CP1251 (см. WHATWG encoding spec
|
||||
// и MS code page 1251). Байт 0x98 в CP1251 не определён, помечаем
|
||||
// U+FFFD; в эталонных XML НРД он не встречается.
|
||||
var cp1251High = [128]rune{
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021,
|
||||
0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
|
||||
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
|
||||
0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
|
||||
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7,
|
||||
0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
|
||||
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7,
|
||||
0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
||||
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
|
||||
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
|
||||
0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427,
|
||||
0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
|
||||
0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437,
|
||||
0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
|
||||
0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447,
|
||||
0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
|
||||
}
|
||||
|
||||
// cp1251Low — обратная таблица rune -> byte для диапазона 0x80..0xFF.
|
||||
// Строится один раз при инициализации пакета.
|
||||
var cp1251Low = func() map[rune]byte {
|
||||
m := make(map[rune]byte, 128)
|
||||
for i, r := range cp1251High {
|
||||
if r != 0xFFFD {
|
||||
m[r] = byte(i) + 0x80
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// DecodeWindows1251 преобразует байты в кодировке windows-1251 в UTF-8.
|
||||
func DecodeWindows1251(src []byte) []byte {
|
||||
out := make([]byte, 0, len(src))
|
||||
var buf [utf8.UTFMax]byte
|
||||
for _, b := range src {
|
||||
var r rune
|
||||
if b < 0x80 {
|
||||
r = rune(b)
|
||||
} else {
|
||||
r = cp1251High[b-0x80]
|
||||
}
|
||||
n := utf8.EncodeRune(buf[:], r)
|
||||
out = append(out, buf[:n]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrUnmappable возвращается, когда руна не имеет представления в windows-1251.
|
||||
var ErrUnmappable = errors.New("nsdxml: rune не представим в windows-1251")
|
||||
|
||||
// EncodeWindows1251 преобразует UTF-8 байты в windows-1251. Если в
|
||||
// исходной строке встречается руна, не выразимая в CP1251, возвращает
|
||||
// ErrUnmappable с указанием руны и смещения.
|
||||
func EncodeWindows1251(src []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(src))
|
||||
for i := 0; i < len(src); {
|
||||
r, size := utf8.DecodeRune(src[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
return nil, fmt.Errorf("%w: некорректная UTF-8 последовательность на смещении %d", ErrUnmappable, i)
|
||||
}
|
||||
if r < 0x80 {
|
||||
out = append(out, byte(r))
|
||||
} else if b, ok := cp1251Low[r]; ok {
|
||||
out = append(out, b)
|
||||
} else {
|
||||
return nil, fmt.Errorf("%w: U+%04X на смещении %d", ErrUnmappable, r, i)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CharsetReader пригоден к использованию в xml.Decoder.CharsetReader.
|
||||
// Для charset "windows-1251" (регистронезависимо) возвращает io.Reader,
|
||||
// который читает входной поток и отдаёт его в UTF-8. Для UTF-8 и
|
||||
// неуказанного charset — пробрасывает входной поток без изменений.
|
||||
func CharsetReader(charset string, input io.Reader) (io.Reader, error) {
|
||||
switch strings.ToLower(charset) {
|
||||
case "windows-1251", "cp1251":
|
||||
data, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(DecodeWindows1251(data)), nil
|
||||
case "", "utf-8", "utf8":
|
||||
return input, nil
|
||||
}
|
||||
return nil, fmt.Errorf("nsdxml: неизвестная кодировка %q", charset)
|
||||
}
|
||||
|
||||
// Marshal сериализует v в XML, добавляет пролог с encoding="windows-1251"
|
||||
// и преобразует поток в windows-1251.
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
utf8Body, err := xml.MarshalIndent(v, "", "\t")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withProlog := append([]byte(xmlProlog), utf8Body...)
|
||||
return EncodeWindows1251(withProlog)
|
||||
}
|
||||
|
||||
// Unmarshal разбирает XML (windows-1251 или UTF-8) в v. Использует
|
||||
// CharsetReader для перекодирования входа, если в прологе указана
|
||||
// windows-1251.
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
dec.CharsetReader = CharsetReader
|
||||
return dec.Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package nsdxml_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
func TestCharmap1251RoundTrip(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"abc",
|
||||
"Привет, мир!",
|
||||
"Иванов И.И.",
|
||||
"АБВГД абвгд",
|
||||
"символы: №, ©, ®, «», „текст", // включая cp1251 пунктуацию
|
||||
}
|
||||
for _, s := range cases {
|
||||
s := s
|
||||
t.Run(s, func(t *testing.T) {
|
||||
enc, err := nsdxml.EncodeWindows1251([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeWindows1251: %v", err)
|
||||
}
|
||||
dec := nsdxml.DecodeWindows1251(enc)
|
||||
if string(dec) != s {
|
||||
t.Errorf("round-trip разошёлся: %q -> %x -> %q", s, enc, dec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeUnmappable(t *testing.T) {
|
||||
_, err := nsdxml.EncodeWindows1251([]byte("эмодзи: 😀"))
|
||||
if !errors.Is(err, nsdxml.ErrUnmappable) {
|
||||
t.Errorf("ожидалась ErrUnmappable, получено: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReader(t *testing.T) {
|
||||
src := []byte{0xC8, 0xE2, 0xE0, 0xED, 0xEE, 0xE2} // "Иванов" в windows-1251
|
||||
r, err := nsdxml.CharsetReader("windows-1251", bytes.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatalf("CharsetReader: %v", err)
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(r); err != nil {
|
||||
t.Fatalf("ReadFrom: %v", err)
|
||||
}
|
||||
if buf.String() != "Иванов" {
|
||||
t.Errorf("получено %q, ожидалось %q", buf.String(), "Иванов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderUnknown(t *testing.T) {
|
||||
_, err := nsdxml.CharsetReader("koi8-r", bytes.NewReader(nil))
|
||||
if err == nil {
|
||||
t.Error("ожидалась ошибка для неизвестной кодировки")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshalRoundTrip(t *testing.T) {
|
||||
type entry struct {
|
||||
XMLName xml.Name `xml:"entry"`
|
||||
Name string `xml:"name"`
|
||||
Value string `xml:"value"`
|
||||
}
|
||||
original := entry{Name: "Иванов И.И.", Value: "тест"}
|
||||
data, err := nsdxml.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(nsdxml.DecodeWindows1251(data)), `<?xml version="1.0" encoding="windows-1251"?>`) {
|
||||
t.Fatalf("в выходе нет пролога windows-1251")
|
||||
}
|
||||
var parsed entry
|
||||
if err := nsdxml.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Name != original.Name || parsed.Value != original.Value {
|
||||
t.Errorf("round-trip разошёлся: %+v vs %+v", original, parsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package nsdxml реализует кодек XML НРД для M2M: парсер/сериализатор
|
||||
// windows-1251 и тип NSDDateTime для нестандартного формата отметки
|
||||
// времени, используемого в XSD НРД ("YYYY-MM-DDThh:mm:ss(МСК+N)").
|
||||
package nsdxml
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// moscowLocation — фиксированная локация UTC+3 для отметок МСК.
|
||||
// Используется именованная локация (без обращения к tzdata) — на стенде
|
||||
// может не быть установленной базы IANA.
|
||||
var moscowLocation = time.FixedZone("МСК", 3*60*60)
|
||||
|
||||
// nsdDateTimeRegex — паттерн из XSD M2MTypesNSD.xsd
|
||||
// (NSDDateTimeType): дата T время затем (МСК) либо (МСК+N) / (МСК-N).
|
||||
var nsdDateTimeRegex = regexp.MustCompile(
|
||||
`^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T` +
|
||||
`([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])` +
|
||||
`\(МСК(([+-])([0-9]{1,2}))?\)$`,
|
||||
)
|
||||
|
||||
// NSDDateTime — отметка времени в формате НРД. Внутреннее
|
||||
// представление — время в зоне Europe/Moscow (UTC+3) плюс смещение в
|
||||
// часах относительно МСК, если оно указано в исходной строке.
|
||||
type NSDDateTime struct {
|
||||
// Time хранится в локации "МСК" (UTC+3). При наличии смещения сам
|
||||
// инстант сдвинут на OffsetHours часов вперёд/назад.
|
||||
Time time.Time
|
||||
// OffsetHours — сдвиг в часах относительно МСК (0 если не задан).
|
||||
OffsetHours int
|
||||
// OffsetSpecified отличает "(МСК)" (false) от "(МСК+0)" (true).
|
||||
// Сохраняется ради round-trip.
|
||||
OffsetSpecified bool
|
||||
}
|
||||
|
||||
// Now возвращает текущее время в зоне МСК без указания сдвига.
|
||||
func Now() NSDDateTime {
|
||||
return NSDDateTime{Time: time.Now().In(moscowLocation)}
|
||||
}
|
||||
|
||||
// String возвращает строковое представление в формате НРД
|
||||
// "YYYY-MM-DDThh:mm:ss(МСК[+-N])".
|
||||
func (d NSDDateTime) String() string {
|
||||
mt := d.Time.In(moscowLocation)
|
||||
base := fmt.Sprintf(
|
||||
"%04d-%02d-%02dT%02d:%02d:%02d",
|
||||
mt.Year(), int(mt.Month()), mt.Day(),
|
||||
mt.Hour(), mt.Minute(), mt.Second(),
|
||||
)
|
||||
switch {
|
||||
case !d.OffsetSpecified:
|
||||
return base + "(МСК)"
|
||||
case d.OffsetHours >= 0:
|
||||
return fmt.Sprintf("%s(МСК+%d)", base, d.OffsetHours)
|
||||
default:
|
||||
return fmt.Sprintf("%s(МСК%d)", base, d.OffsetHours)
|
||||
}
|
||||
}
|
||||
|
||||
// parseNSDDateTime разбирает строку формата НРД в NSDDateTime.
|
||||
func parseNSDDateTime(s string) (NSDDateTime, error) {
|
||||
m := nsdDateTimeRegex.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return NSDDateTime{}, fmt.Errorf("nsdxml: NSDDateTime %q не соответствует формату", s)
|
||||
}
|
||||
year, _ := strconv.Atoi(m[1])
|
||||
month, _ := strconv.Atoi(m[2])
|
||||
day, _ := strconv.Atoi(m[3])
|
||||
hour, _ := strconv.Atoi(m[4])
|
||||
min, _ := strconv.Atoi(m[5])
|
||||
sec, _ := strconv.Atoi(m[6])
|
||||
|
||||
t := time.Date(year, time.Month(month), day, hour, min, sec, 0, moscowLocation)
|
||||
|
||||
out := NSDDateTime{Time: t}
|
||||
if m[7] != "" {
|
||||
val, _ := strconv.Atoi(m[9])
|
||||
if m[8] == "-" {
|
||||
val = -val
|
||||
}
|
||||
out.OffsetHours = val
|
||||
out.OffsetSpecified = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarshalText сериализует NSDDateTime в строку формата НРД.
|
||||
func (d NSDDateTime) MarshalText() ([]byte, error) {
|
||||
return []byte(d.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText разбирает строку формата НРД в NSDDateTime.
|
||||
func (d *NSDDateTime) UnmarshalText(b []byte) error {
|
||||
if len(b) == 0 {
|
||||
return errors.New("nsdxml: пустое значение NSDDateTime")
|
||||
}
|
||||
parsed, err := parseNSDDateTime(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalXML кодирует NSDDateTime в строку формата НРД.
|
||||
func (d NSDDateTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
return e.EncodeElement(d.String(), start)
|
||||
}
|
||||
|
||||
// UnmarshalXML декодирует строку формата НРД в NSDDateTime.
|
||||
func (d *NSDDateTime) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := dec.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.UnmarshalText([]byte(s))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package nsdxml_test
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
func TestNSDDateTimeParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
expectOffset int
|
||||
expectSpecified bool
|
||||
}{
|
||||
{"2026-03-02T14:30:45(МСК)", 0, false},
|
||||
{"2026-03-02T14:30:45(МСК+2)", 2, true},
|
||||
{"2026-03-02T14:30:45(МСК-1)", -1, true},
|
||||
{"2026-03-02T14:30:45(МСК+12)", 12, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
var d nsdxml.NSDDateTime
|
||||
if err := d.UnmarshalText([]byte(c.in)); err != nil {
|
||||
t.Fatalf("UnmarshalText: %v", err)
|
||||
}
|
||||
if d.OffsetSpecified != c.expectSpecified {
|
||||
t.Errorf("OffsetSpecified = %v, ожидалось %v", d.OffsetSpecified, c.expectSpecified)
|
||||
}
|
||||
if d.OffsetHours != c.expectOffset {
|
||||
t.Errorf("OffsetHours = %d, ожидалось %d", d.OffsetHours, c.expectOffset)
|
||||
}
|
||||
out := d.String()
|
||||
if out != c.in {
|
||||
t.Errorf("String() = %q, ожидалось %q", out, c.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDDateTimeNow(t *testing.T) {
|
||||
d := nsdxml.Now()
|
||||
if d.OffsetSpecified {
|
||||
t.Errorf("Now() не должен задавать смещение")
|
||||
}
|
||||
if zone, _ := d.Time.Zone(); zone != "МСК" {
|
||||
t.Errorf("Now() location зона = %q, ожидалось \"МСК\"", zone)
|
||||
}
|
||||
if time.Since(d.Time) > time.Minute {
|
||||
t.Errorf("Now() вернул время, далёкое от текущего")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDDateTimeXMLRoundTrip(t *testing.T) {
|
||||
type wrap struct {
|
||||
XMLName xml.Name `xml:"wrap"`
|
||||
TS nsdxml.NSDDateTime `xml:"ts"`
|
||||
}
|
||||
src := `<wrap><ts>2026-03-02T14:30:45(МСК+2)</ts></wrap>`
|
||||
var w wrap
|
||||
if err := xml.Unmarshal([]byte(src), &w); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if w.TS.OffsetHours != 2 {
|
||||
t.Errorf("OffsetHours = %d, ожидалось 2", w.TS.OffsetHours)
|
||||
}
|
||||
b, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "2026-03-02T14:30:45(МСК+2)") {
|
||||
t.Errorf("Marshal не сохранил формат: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDDateTimeParseErrors(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
"2026-13-02T14:30:45(МСК)",
|
||||
"2026-03-02 14:30:45(МСК)",
|
||||
"2026-03-02T14:30:45",
|
||||
"2026-03-02T14:30:45(MSK)",
|
||||
"2026-03-02T14:30:45(МСК+200)",
|
||||
}
|
||||
for _, s := range bad {
|
||||
s := s
|
||||
t.Run(s, func(t *testing.T) {
|
||||
var d nsdxml.NSDDateTime
|
||||
if err := d.UnmarshalText([]byte(s)); err == nil {
|
||||
t.Errorf("ожидалась ошибка для %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user