From 6c3950953e6ed2bf75fb632db02c72f4201d04dd Mon Sep 17 00:00:00 2001 From: SERTY Date: Fri, 22 Aug 2025 03:38:57 +0300 Subject: [PATCH] 0.1.3-prod reduce timeouts added tests and mock interface --- main.go | 154 ++++++++++++++++++------------- main_test.go | 187 ++++++++++++++++++++++++++++++++++++++ pkg/shtrih/driver_test.go | 49 ++++++++++ 3 files changed, 327 insertions(+), 63 deletions(-) create mode 100644 main_test.go diff --git a/main.go b/main.go index 289c9aa..aa621b6 100644 --- a/main.go +++ b/main.go @@ -18,22 +18,22 @@ import ( const ( configFileName = "connect.json" serviceConfigName = "service.json" - outputDir = "date" logsDir = "logs" - comSearchTimeout = 200 * time.Millisecond // Уменьшенный таймаут - tcpSearchTimeout = 150 * time.Millisecond + comSearchTimeout = 200 * time.Millisecond + tcpSearchTimeout = 200 * time.Millisecond ) +// Глобальная переменная для пути вывода. Это позволяет подменять ее в тестах. +var outputDir = "date" + // --- СТРУКТУРЫ ДЛЯ ПАРСИНГА КОНФИГУРАЦИОННЫХ ФАЙЛОВ --- -// ConfigFile соответствует структуре файла connect.json type ConfigFile struct { Timeout int `json:"timeout_to_ip_port"` Shtrih []ConnectionSettings `json:"shtrih"` Atol []interface{} `json:"atol"` } -// ConnectionSettings описывает один блок настроек подключения для Штрих-М type ConnectionSettings struct { TypeConnect int32 `json:"type_connect"` ComPort string `json:"com_port"` @@ -42,19 +42,15 @@ type ConnectionSettings struct { IPPort string `json:"ip_port"` } -// ServiceFile используется для чтения настроек логирования из service.json type ServiceFile struct { Service ServiceConfig `json:"service"` } -// ServiceConfig содержит параметры логирования type ServiceConfig struct { LogLevel string `json:"log_level"` LogDays int `json:"log_days"` } -// PolledDevice связывает конфигурацию, использованную для подключения, -// с фискальной информацией, полученной от устройства. type PolledDevice struct { Config shtrih.Config Info *shtrih.FiscalInfo @@ -81,7 +77,6 @@ func main() { log.Println("Работа приложения завершена.") } -// setupLogger настраивает запись логов в файл для стационарного режима. func setupLogger() { data, err := os.ReadFile(serviceConfigName) if err != nil { @@ -95,13 +90,11 @@ func setupLogger() { return } - // Устанавливаем значения по умолчанию, если в файле их нет logDays := serviceFile.Service.LogDays if logDays <= 0 { logDays = 7 } - // Создаем папку для логов, если ее нет if err := os.MkdirAll(logsDir, 0755); err != nil { log.Printf("Ошибка создания директории для логов '%s': %v. Логирование продолжится в консоль.", logsDir, err) return @@ -109,28 +102,25 @@ func setupLogger() { logFilePath := filepath.Join(logsDir, "shtrih-scanner.log") - // Настраиваем ротацию логов lumberjackLogger := &lumberjack.Logger{ Filename: logFilePath, - MaxSize: 5, // мегабайты + MaxSize: 5, MaxBackups: 10, - MaxAge: logDays, // дни - Compress: true, // сжимать старые файлы + MaxAge: logDays, + Compress: true, } - // Устанавливаем вывод логов и в файл, и в консоль log.SetOutput(io.MultiWriter(os.Stdout, lumberjackLogger)) log.Printf("Логирование настроено. Уровень: %s, ротация: %d дней. Файл: %s", serviceFile.Service.LogLevel, logDays, logFilePath) } func runConfigMode(data []byte) { - // Первым делом настраиваем логирование для стационарного режима! setupLogger() var configFile ConfigFile if err := json.Unmarshal(data, &configFile); err != nil { log.Printf("Ошибка парсинга JSON из '%s': %v. Переключаюсь на режим автопоиска.", configFileName, err) - runDiscoveryMode() // В случае ошибки автопоиск будет логировать только в консоль + runDiscoveryMode() return } @@ -147,7 +137,8 @@ func runConfigMode(data []byte) { return } - processDevices(configs) + // Передаем конструктор реального драйвера shtrih.New + processDevices(configs, shtrih.New) } func runDiscoveryMode() { @@ -162,18 +153,22 @@ func runDiscoveryMode() { } log.Printf("Найдено %d устройств. Начинаю сбор информации...", len(configs)) - polledDevices := processDevices(configs) + // Передаем конструктор реального драйвера shtrih.New + polledDevices := processDevices(configs, shtrih.New) if len(polledDevices) > 0 { saveConfiguration(polledDevices) } } -func processDevices(configs []shtrih.Config) []PolledDevice { +// processDevices принимает функцию-фабрику `newDriverFunc` для создания драйвера. +// Это позволяет подменять реальный драйвер на мок-драйвер в тестах. +func processDevices(configs []shtrih.Config, newDriverFunc func(shtrih.Config) shtrih.Driver) []PolledDevice { var polledDevices []PolledDevice for _, config := range configs { log.Printf("--- Опрашиваю устройство: %+v ---", config) - driver := shtrih.New(config) + // Используем переданную функцию-фабрику для создания драйвера + driver := newDriverFunc(config) if err := driver.Connect(); err != nil { log.Printf("Не удалось подключиться к устройству: %v", err) @@ -242,36 +237,67 @@ func processDevices(configs []shtrih.Config) []PolledDevice { // --- ФУНКЦИИ ДЛЯ РАБОТЫ С ФАЙЛАМИ --- +// findSourceWorkstationData ищет в папке /date файл с данными о рабочей станции. +// Логика поиска: +// 1. Ищет "идеальный" донор: файл с "hostname", но без "modelName". Если находит - сразу возвращает его. +// 2. Если идеальный не найден, ищет "первый подходящий": любой файл с "hostname", даже если там есть "modelName". func findSourceWorkstationData() map[string]interface{} { files, err := os.ReadDir(outputDir) if err != nil { return nil } + var firstCandidate map[string]interface{} // Переменная для хранения "первого подходящего" кандидата + for _, file := range files { - if !file.IsDir() && filepath.Ext(file.Name()) == ".json" { - filePath := filepath.Join(outputDir, file.Name()) - data, err := os.ReadFile(filePath) - if err != nil { - log.Printf("Предупреждение: не удалось прочитать файл-донор %s: %v", filePath, err) - continue - } + if file.IsDir() || filepath.Ext(file.Name()) != ".json" { + continue + } - var content map[string]interface{} - if err := json.Unmarshal(data, &content); err != nil { - log.Printf("Предупреждение: не удалось распарсить JSON из файла-донора %s: %v", filePath, err) - continue - } + filePath := filepath.Join(outputDir, file.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + log.Printf("Предупреждение: не удалось прочитать файл-донор %s: %v", filePath, err) + continue + } - _, hasModelName := content["modelName"] - _, hasHostname := content["hostname"] - if !hasModelName && hasHostname { - log.Printf("Найден файл-донор с данными о рабочей станции: %s", filePath) - return content - } + var content map[string]interface{} + if err := json.Unmarshal(data, &content); err != nil { + log.Printf("Предупреждение: не удалось распарсить JSON из файла-донора %s: %v", filePath, err) + continue + } + + // Проверяем наличие ключевых полей + _, hasModelName := content["modelName"] + _, hasHostname := content["hostname"] + + // Если у файла нет hostname, он нам точно не интересен + if !hasHostname { + continue + } + + // Сценарий 1: Найден "идеальный" донор (без modelName) + if !hasModelName { + log.Printf("Найден идеальный файл-донор с данными о рабочей станции: %s", filePath) + return content // Сразу возвращаем его + } + + // Сценарий 2: Файл не идеальный, но подходит как кандидат (есть и hostname, и modelName) + // Сохраняем только самого первого кандидата из списка файлов. + if firstCandidate == nil { + firstCandidate = content + log.Printf("Найден файл-кандидат на роль донора (будет использован, если не найдется идеальный): %s", filePath) } } + // После проверки всех файлов, если мы так и не вернули идеального донора, + // используем первого подходящего кандидата, которого нашли. + if firstCandidate != nil { + log.Println("Идеальный донор не найден, используется первый подходящий файл-кандидат.") + return firstCandidate + } + + // Если мы дошли до сюда, значит не было найдено ни одного файла с полем "hostname". log.Println("В папке /date не найдено файлов-доноров. Будут использованы базовые данные.") return nil } @@ -281,47 +307,57 @@ func updateTimestampInFile(filePath string) error { if err != nil { return fmt.Errorf("ошибка чтения файла: %w", err) } - var content map[string]interface{} if err := json.Unmarshal(data, &content); err != nil { return fmt.Errorf("ошибка парсинга JSON: %w", err) } - - // Обновляем оба поля времени currentTime := time.Now().Format("2006-01-02 15:04:05") content["current_time"] = currentTime content["v_time"] = currentTime - updatedData, err := json.MarshalIndent(content, "", " ") if err != nil { return fmt.Errorf("ошибка маршалинга JSON: %w", err) } - return os.WriteFile(filePath, updatedData, 0644) } +// saveNewMergedInfo объединяет данные ККТ и данные рабочей станции (в виде map) и сохраняет в новый JSON-файл. +// Данные от ККТ имеют приоритет и перезаписывают одноименные поля из данных донора. func saveNewMergedInfo(kktInfo *shtrih.FiscalInfo, wsData map[string]interface{}, filePath string) error { + // Шаг 1: Преобразуем данные от нашего ККТ (Штрих) в map. var kktMap map[string]interface{} kktJSON, _ := json.Marshal(kktInfo) json.Unmarshal(kktJSON, &kktMap) - // Добавляем актуальные временные метки в данные рабочей станции - currentTime := time.Now().Format("2006-01-02 15:04:05") - wsData["current_time"] = currentTime - wsData["v_time"] = currentTime - + // Шаг 2: Создаем итоговую карту. Начинаем с данных донора, чтобы они были "внизу". + // Мы делаем копию wsData, чтобы не изменять оригинальную карту, которая может быть использована в других итерациях. + finalMap := make(map[string]interface{}) for key, value := range wsData { - kktMap[key] = value + finalMap[key] = value } - delete(kktMap, "serialNumber") - kktMap["serialNumber"] = kktInfo.SerialNumber + // Шаг 3: "Накладываем" данные от нашего ККТ поверх. + // Все совпадающие ключи будут перезаписаны значениями от Штриха. + for key, value := range kktMap { + // Пропускаем пустые значения от ККТ, чтобы случайно не затереть + // хорошее значение из донора пустым. + if s, ok := value.(string); ok && s == "" { + continue + } + finalMap[key] = value + } + // Шаг 4: Устанавливаем актуальные временные метки. Они всегда должны быть свежими. + currentTime := time.Now().Format("2006-01-02 15:04:05") + finalMap["current_time"] = currentTime + finalMap["v_time"] = currentTime + + // Шаг 5: Создаем директорию и сохраняем файл. if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return fmt.Errorf("не удалось создать директорию '%s': %w", outputDir, err) } - finalJSON, err := json.MarshalIndent(kktMap, "", " ") + finalJSON, err := json.MarshalIndent(finalMap, "", " ") if err != nil { return fmt.Errorf("ошибка маршалинга итогового JSON: %w", err) } @@ -344,25 +380,20 @@ func saveConfiguration(polledDevices []PolledDevice) { configFile = ConfigFile{} } } - var newShtrihSettings []ConnectionSettings for _, pd := range polledDevices { newShtrihSettings = append(newShtrihSettings, convertConfigToSettings(pd.Config)) } - configFile.Shtrih = newShtrihSettings - updatedData, err := json.MarshalIndent(configFile, "", " ") if err != nil { log.Printf("Ошибка: не удалось преобразовать конфигурацию в JSON: %v", err) return } - if err := os.WriteFile(configFileName, updatedData, 0644); err != nil { log.Printf("Ошибка: не удалось записать конфигурацию в файл '%s': %v", configFileName, err) return } - log.Printf("Конфигурация успешно сохранена в '%s'.", configFileName) } @@ -373,7 +404,6 @@ func convertSettingsToConfigs(settings []ConnectionSettings) []shtrih.Config { baudRateMap := map[string]int32{ "115200": 6, "57600": 5, "38400": 4, "19200": 3, "9600": 2, "4800": 1, } - for _, s := range settings { config := shtrih.Config{ConnectionType: s.TypeConnect, Password: 30} switch s.TypeConnect { @@ -412,9 +442,7 @@ func convertConfigToSettings(config shtrih.Config) ConnectionSettings { baudRateReverseMap := map[int32]string{ 6: "115200", 5: "57600", 4: "38400", 3: "19200", 2: "9600", 1: "4800", } - settings := ConnectionSettings{TypeConnect: config.ConnectionType} - switch config.ConnectionType { case 0: settings.ComPort = config.ComName diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d917644 --- /dev/null +++ b/main_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "shtrih-kkt/pkg/shtrih" + "testing" +) + +// loadCanonicalKKTData загружает эталонные данные ККТ из файла для тестов. +// Путь к файлу указывается относительно корня проекта. +func loadCanonicalKKTData(t *testing.T, path string) *shtrih.FiscalInfo { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Критическая ошибка: не удалось прочитать канонический файл данных '%s': %v", path, err) + } + var info shtrih.FiscalInfo + if err := json.Unmarshal(data, &info); err != nil { + t.Fatalf("Критическая ошибка: не удалось распарсить JSON из канонического файла '%s': %v", path, err) + } + return &info +} + +// TestProcessDevices_NewFileFromDonor проверяет сценарий, когда для ККТ еще нет +// файла, но есть файл-донор с данными о рабочей станции. +func TestProcessDevices_NewFileFromDonor(t *testing.T) { + // --- Arrange (Подготовка) --- + + // 1. Создаем временную директорию для теста, чтобы не засорять реальную папку `date`. + tempDir := t.TempDir() + originalOutputDir := outputDir + outputDir = tempDir + defer func() { outputDir = originalOutputDir }() + + // 2. Загружаем канонические данные для мок-драйвера из файла. + // Этот файл должен быть создан заранее и помещен в pkg/shtrih/testdata/ + mockKKTData := loadCanonicalKKTData(t, "pkg/shtrih/testdata/canonical_kkt_data.json") + + // 3. Создаем файл-донор с уникальными данными о рабочей станции во временной папке. + donorData := map[string]interface{}{ + "hostname": "DONOR-PC", + "teamviewer_id": "999888777", + "vc": "3.0-donor-test", + } + donorBytes, _ := json.Marshal(donorData) + donorFilePath := filepath.Join(tempDir, "donor.json") + if err := os.WriteFile(donorFilePath, donorBytes, 0644); err != nil { + t.Fatalf("Не удалось создать тестовый файл-донор: %v", err) + } + + // 4. Создаем "фабрику", которая вернет мок-драйвер с нашими каноническими данными. + mockDriverFactory := func(config shtrih.Config) shtrih.Driver { + return shtrih.NewMockDriver(mockKKTData, nil, nil) + } + + // 5. Готовим входные данные для processDevices. + testConfigs := []shtrih.Config{{ConnectionType: 6, IPAddress: "127.0.0.1"}} + + // --- Act (Действие) --- + + processDevices(testConfigs, mockDriverFactory) + + // --- Assert (Проверка) --- + + // 1. Проверяем, что был создан правильный JSON-файл. + expectedFileName := mockKKTData.SerialNumber + ".json" + resultFilePath := filepath.Join(tempDir, expectedFileName) + if _, err := os.Stat(resultFilePath); os.IsNotExist(err) { + t.Fatalf("Ожидалось, что будет создан файл '%s', но он не найден.", resultFilePath) + } + + // 2. Читаем созданный файл и проверяем его содержимое. + resultBytes, err := os.ReadFile(resultFilePath) + if err != nil { + t.Fatalf("Не удалось прочитать результирующий файл '%s': %v", resultFilePath, err) + } + + var resultMap map[string]interface{} + if err := json.Unmarshal(resultBytes, &resultMap); err != nil { + t.Fatalf("Не удалось распарсить JSON из результирующего файла: %v", err) + } + + // 3. Проверяем, что данные из ККТ и донора корректно слились. + // Проверка поля от ККТ. + if resultMap["modelName"] != mockKKTData.ModelName { + t.Errorf("Поле 'modelName' неверно. Ожидалось '%s', получено '%v'", mockKKTData.ModelName, resultMap["modelName"]) + } + + // Проверка полей от донора. + if resultMap["hostname"] != donorData["hostname"] { + t.Errorf("Поле 'hostname' из донора не было добавлено. Ожидалось '%s', получено '%v'", donorData["hostname"], resultMap["hostname"]) + } + if resultMap["vc"] != donorData["vc"] { + t.Errorf("Поле 'vc' из донора не было добавлено. Ожидалось '%s', получено '%v'", donorData["vc"], resultMap["vc"]) + } + + // Проверка автоматически сгенерированных полей времени. + if _, ok := resultMap["current_time"]; !ok { + t.Error("Отсутствует обязательное поле 'current_time'.") + } + if _, ok := resultMap["v_time"]; !ok { + t.Error("Отсутствует обязательное поле 'v_time'.") + } +} + +// TestFindSourceWorkstationData_FileHandling проверяет непосредственно логику +// поиска и чтения донор-файла в смоделированной файловой структуре. +func TestFindSourceWorkstationData_FileHandling(t *testing.T) { + + // --- Сценарий 1: В папке /date есть правильный донор-файл --- + t.Run("when valid donor file exists", func(t *testing.T) { + // Arrange: Готовим файловую систему + tempDir := t.TempDir() + originalOutputDir := outputDir + outputDir = tempDir // Указываем, что наша папка "date" находится во временной директории + defer func() { outputDir = originalOutputDir }() + + // Создаем донор-файл с уникальными данными для проверки + donorData := map[string]interface{}{ + "hostname": "REAL-DONOR-PC", + "vc": "v_from_real_file", + } + donorBytes, _ := json.Marshal(donorData) + if err := os.WriteFile(filepath.Join(tempDir, "donor_to_find.json"), donorBytes, 0644); err != nil { + t.Fatalf("Не удалось создать тестовый файл-донор: %v", err) + } + + // Создаем "файл-ловушку" от другого ККТ, который должен быть проигнорирован + kktTrapData := map[string]interface{}{ + "modelName": "SOME-OTHER-KKT", + "serialNumber": "TRAP000001", + "hostname": "FAKE-HOSTNAME", + } + trapBytes, _ := json.Marshal(kktTrapData) + if err := os.WriteFile(filepath.Join(tempDir, "000001.json"), trapBytes, 0644); err != nil { + t.Fatalf("Не удалось создать файл-ловушку: %v", err) + } + + // Act: Вызываем тестируемую функцию + resultMap := findSourceWorkstationData() + + // Assert: Проверяем результат + if resultMap == nil { + t.Fatal("findSourceWorkstationData() вернула nil, хотя ожидались данные из донора.") + } + + if resultMap["hostname"] != "REAL-DONOR-PC" { + t.Errorf("Hostname из донора прочитан неверно. Ожидалось 'REAL-DONOR-PC', получено '%v'", resultMap["hostname"]) + } + if resultMap["vc"] != "v_from_real_file" { + t.Errorf("Поле 'vc' из донора прочитано неверно. Ожидалось 'v_from_real_file', получено '%v'", resultMap["vc"]) + } + }) + + // --- Сценарий 2: В папке /date нет подходящих файлов --- + t.Run("when no valid donor file exists", func(t *testing.T) { + // Arrange: Готовим файловую систему + tempDir := t.TempDir() + originalOutputDir := outputDir + outputDir = tempDir + defer func() { outputDir = originalOutputDir }() + + // Создаем только "файл-ловушку", который не должен считаться донором + kktTrapData := map[string]interface{}{ + "modelName": "SOME-OTHER-KKT", + "serialNumber": "TRAP000001", + "hostname": "FAKE-HOSTNAME", + } + trapBytes, _ := json.Marshal(kktTrapData) + if err := os.WriteFile(filepath.Join(tempDir, "000001.json"), trapBytes, 0644); err != nil { + t.Fatalf("Не удалось создать файл-ловушку: %v", err) + } + // Создаем текстовый файл, который тоже должен быть проигнорирован + if err := os.WriteFile(filepath.Join(tempDir, "readme.txt"), []byte("info"), 0644); err != nil { + t.Fatalf("Не удалось создать текстовый файл: %v", err) + } + + // Act: Вызываем тестируемую функцию + resultMap := findSourceWorkstationData() + + // Assert: Проверяем, что функция ничего не нашла + if resultMap != nil { + t.Fatal("findSourceWorkstationData() вернула данные, хотя в папке не было валидных доноров.") + } + }) +} diff --git a/pkg/shtrih/driver_test.go b/pkg/shtrih/driver_test.go index 0b46d4c..b3160ed 100644 --- a/pkg/shtrih/driver_test.go +++ b/pkg/shtrih/driver_test.go @@ -2,7 +2,9 @@ package shtrih import ( + "encoding/json" "fmt" + "os" "reflect" "testing" ) @@ -127,3 +129,50 @@ func TestMockDriver_GetInfoError(t *testing.T) { t.Error("GetFiscalInfo() вернул данные вместе с ошибкой, хотя должен был вернуть nil.") } } + +// loadMockDataFromFile читает "канонический" JSON-файл и преобразует его +// в структуру FiscalInfo для использования в мок-драйвере. +func loadMockDataFromFile(filePath string) (*FiscalInfo, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("не удалось прочитать файл с мок-данными '%s': %w", filePath, err) + } + + var info FiscalInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("не удалось распарсить JSON из файла '%s': %w", filePath, err) + } + + return &info, nil +} + +// TestMockDriver_WithDataFromFile проверяет полный цикл работы мок-драйвера, +// используя для этого данные, загруженные из реального JSON-файла. +func TestMockDriver_WithDataFromFile(t *testing.T) { + // Arrange 1: Загружаем данные из нашего "золотого" файла. + // Путь указывается относительно корня пакета. + canonicalData, err := loadMockDataFromFile("testdata/canonical_kkt_data.json") + if err != nil { + // Если файл не найден или поврежден, тест должен провалиться. + t.Fatalf("Подготовка теста провалилась: не удалось загрузить мок-данные: %v", err) + } + + // Arrange 2: Создаем мок-драйвер, передавая ему загруженные данные. + driver := NewMockDriver(canonicalData, nil, nil) + + // Act + if err := driver.Connect(); err != nil { + t.Fatalf("Connect() вернул неожиданную ошибку: %v", err) + } + defer driver.Disconnect() + + info, err := driver.GetFiscalInfo() + if err != nil { + t.Fatalf("GetFiscalInfo() вернул неожиданную ошибку: %v", err) + } + + // Assert: Сверяем, что драйвер вернул в точности те данные, что были в файле. + if !reflect.DeepEqual(info, canonicalData) { + t.Errorf("Данные, возвращенные мок-драйвером, не совпадают с данными из файла.\nПолучено: %+v\nОжидалось: %+v", info, canonicalData) + } +}