using ClosedXML.Excel; using Shouldly; using Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// /// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители /// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл /// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется /// именно то, что увидит Excel. /// public sealed class VacancyReportWorkbookTests { private static VacancyReportRow Row( string position, int vacancies = 1, int closed = 0, DateOnly? submitDate = null, DateOnly? closingDate = null, DateOnly? startDate = null, string? recruiter = "Сергеева Надежда Ивановна") => new( position, "офис", vacancies, closed, submitDate ?? new DateOnly(2026, 7, 8), closingDate, startDate, "Ким Н.Ю.", null, closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, recruiter); private static IXLWorksheet Build(params VacancyReportGroup[] groups) { var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups); var bytes = VacancyReportWorkbook.Build(report); using var stream = new MemoryStream(bytes); // Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML // держит уже разобранную модель в памяти, а не ленивую ссылку на поток. var workbook = new XLWorkbook(stream); return workbook.Worksheet("Список вакансий (все)"); } [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] public void Header_MatchesSample() { var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); sheet.Cell(1, 2).GetString().ShouldBe("№"); sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); sheet.Cell(1, 4).GetString().ShouldBe("Подразделение"); sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий"); sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки"); sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии"); sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода"); sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки"); sheet.Cell(1, 14).GetString().ShouldBe("Комментарии"); sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер"); sheet.Cell(1, 17).IsEmpty().ShouldBeTrue(); } [Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")] public void DataRow_Layout() { var sheet = Build(new VacancyReportGroup("", [ Row("Зам нач.юр.отдела", vacancies: 1, closed: 1, submitDate: new DateOnly(2026, 3, 30), closingDate: new DateOnly(2026, 7, 8), startDate: new DateOnly(2026, 8, 17)) ])); sheet.Cell(2, 1).GetValue().ShouldBe(1); sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела"); sheet.Cell(2, 5).GetValue().ShouldBe(1); sheet.Cell(2, 6).GetValue().ShouldBe(1); sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30)); sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8)); sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17)); sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. sheet.Cell(2, 14).IsEmpty().ShouldBeTrue(); sheet.Cell(2, 15).GetString().ShouldBe("да"); sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна"); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] public void DerivedColumns_AreFormulas() { var sheet = Build(new VacancyReportGroup("", [ Row("Логист", vacancies: 3, closed: 1, submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15)) ])); sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2"); sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2"); } [Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")] public void LeadTime_EmptyWithoutClosingDate() { var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty(); sheet.Cell(2, 10).IsEmpty().ShouldBeTrue(); } [Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")] public void GroupSeparator_RestartsNumbering() { var sheet = Build( new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")])); // Верхний блок без объекта — сразу после шапки, без разделителя. sheet.Cell(2, 3).GetString().ShouldBe("Логист"); sheet.Cell(3, 3).GetString().ShouldBe("Юрист"); sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ"); sheet.Cell(4, 3).IsEmpty().ShouldBeTrue(); // Сквозная нумерация продолжается, нумерация внутри объекта — с единицы. sheet.Cell(5, 1).GetValue().ShouldBe(3); sheet.Cell(5, 2).GetValue().ShouldBe(1); sheet.Cell(6, 1).GetValue().ShouldBe(4); sheet.Cell(6, 2).GetValue().ShouldBe(2); } [Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")] public void Totals_SumWholeDataRange() { var sheet = Build( new VacancyReportGroup("", [Row("Логист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")])); // Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке. sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)"); sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)"); sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)"); } [Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")] public void EmptyReport_HeaderOnly() { var sheet = Build(); sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); sheet.Cell(2, 5).IsEmpty().ShouldBeTrue(); } [Fact(DisplayName = "Имя файла — с периодом отчёта")] public void FileName_CarriesPeriod() { var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []); VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx"); } }