using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using Shouldly; using Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// /// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок, /// строки-разделители по объектам, двойная нумерация (сквозная + внутри /// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё /// заведены круговые диаграммы. Файл собирается в памяти и тут же читается /// обратно тем же NPOI — проверяется именно то, что увидит 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 XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups) { var report = new VacancyReport(from, to, groups); var bytes = VacancyReportWorkbook.Build(report); return new XSSFWorkbook(new MemoryStream(bytes)); } private static ISheet BuildListSheet(params VacancyReportGroup[] groups) => BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups) .GetSheet("Список вакансий (все)"); /// Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E». private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) => sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1); private static string StringAt(ISheet sheet, int excelRow, int excelCol) => CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? ""; private static int IntAt(ISheet sheet, int excelRow, int excelCol) => (int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста")); private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) => CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value; private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol) { var cell = CellAt(sheet, excelRow, excelCol); return cell is null || cell.CellType == CellType.Blank; } /// Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе). private static string FormulaAt(ISheet sheet, int excelRow, int excelCol) { var cell = CellAt(sheet, excelRow, excelCol); return cell?.CellType == CellType.Formula ? cell.CellFormula : ""; } [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] public void Header_MatchesSample() { var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); StringAt(sheet, 1, 2).ShouldBe("№"); StringAt(sheet, 1, 3).ShouldBe("Вакансия"); StringAt(sheet, 1, 4).ShouldBe("Подразделение"); StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий"); StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки"); StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии"); StringAt(sheet, 1, 11).ShouldBe("Дата выхода"); StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки"); StringAt(sheet, 1, 14).ShouldBe("Комментарии"); StringAt(sheet, 1, 16).ShouldBe("Рекрутер"); IsEmptyAt(sheet, 1, 17).ShouldBeTrue(); } [Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")] public void DataRow_Layout() { var sheet = BuildListSheet(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)) ])); IntAt(sheet, 2, 1).ShouldBe(1); StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела"); IntAt(sheet, 2, 5).ShouldBe(1); IntAt(sheet, 2, 6).ShouldBe(1); DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30)); DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8)); DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17)); StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. IsEmptyAt(sheet, 2, 14).ShouldBeTrue(); StringAt(sheet, 2, 15).ShouldBe("да"); StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна"); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] public void DerivedColumns_AreFormulas() { var sheet = BuildListSheet(new VacancyReportGroup("", [ Row("Логист", vacancies: 3, closed: 1, submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15)) ])); FormulaAt(sheet, 2, 7).ShouldBe("E2-F2"); FormulaAt(sheet, 2, 10).ShouldBe("I2-H2"); } [Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")] public void LeadTime_EmptyWithoutClosingDate() { var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); FormulaAt(sheet, 2, 10).ShouldBeEmpty(); IsEmptyAt(sheet, 2, 10).ShouldBeTrue(); } [Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")] public void GroupSeparator_RestartsNumbering() { var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")])); // Верхний блок без объекта — сразу после шапки, без разделителя. StringAt(sheet, 2, 3).ShouldBe("Логист"); StringAt(sheet, 3, 3).ShouldBe("Юрист"); StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ"); IsEmptyAt(sheet, 4, 3).ShouldBeTrue(); // Сквозная нумерация продолжается, нумерация внутри объекта — с единицы. IntAt(sheet, 5, 1).ShouldBe(3); IntAt(sheet, 5, 2).ShouldBe(1); IntAt(sheet, 6, 1).ShouldBe(4); IntAt(sheet, 6, 2).ShouldBe(2); } [Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")] public void Totals_SumWholeDataRange() { var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")])); // Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке. FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)"); FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)"); FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)"); } [Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")] public void EmptyReport_HeaderOnly() { var sheet = BuildListSheet(); StringAt(sheet, 1, 3).ShouldBe("Вакансия"); IsEmptyAt(sheet, 2, 5).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"); } // ==================== Лист «Результаты» ==================== private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) => Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter); [Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")] public void SummarySheet_HorizontalTable() { var workbook = BuildWorkbook( new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), new VacancyReportGroup("", [ RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40% RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100% ])); var sheet = workbook.GetSheet("Результаты"); StringAt(sheet, 1, 1).ShouldBe("результаты"); // Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5). StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий"); StringAt(sheet, 2, 2).ShouldBe("Надежда"); StringAt(sheet, 2, 3).ShouldBe("Алтынай"); StringAt(sheet, 3, 1).ShouldBe("Всего"); IntAt(sheet, 3, 2).ShouldBe(10); IntAt(sheet, 3, 3).ShouldBe(5); StringAt(sheet, 4, 1).ShouldBe("в работе (в%)"); CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001); CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001); StringAt(sheet, 5, 1).ShouldBe("закрыто"); CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001); CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001); } [Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")] public void SummarySheet_UnassignedRecruiterLast() { var workbook = BuildWorkbook( new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), new VacancyReportGroup("", [ RowFor("Алтынай", vacancies: 1, closed: 0), Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null), ])); var sheet = workbook.GetSheet("Результаты"); // «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на // то, что её объём (100) больше, чем у Алтынай (1): это не участник // рейтинга, а технический хвост. StringAt(sheet, 2, 2).ShouldBe("Алтынай"); StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel); } [Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")] public void SummarySheet_VerticalTables() { var workbook = BuildWorkbook( new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), new VacancyReportGroup("", [ RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт ])); var sheet = workbook.GetSheet("Результаты"); // Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк // горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%. StringAt(sheet, 7, 1).ShouldBe("Общий объем работ"); IntAt(sheet, 7, 2).ShouldBe(4); StringAt(sheet, 8, 1).ShouldBe("Надежда"); CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001); StringAt(sheet, 9, 1).ShouldBe("Алтынай"); CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001); // Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда // не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%. StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе"); IntAt(sheet, 11, 2).ShouldBe(3); StringAt(sheet, 12, 1).ShouldBe("Надежда"); CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001); StringAt(sheet, 13, 1).ShouldBe("Алтынай"); CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001); } [Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")] public void SummarySheet_HasTwoPieCharts() { var workbook = BuildWorkbook( new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), new VacancyReportGroup("", [ RowFor("Надежда", vacancies: 3, closed: 1), RowFor("Алтынай", vacancies: 2, closed: 0), ])); var sheet = (XSSFSheet)workbook.GetSheet("Результаты"); var charts = sheet.GetDrawingPatriarch()?.GetCharts(); charts.ShouldNotBeNull(); charts!.Count.ShouldBe(2); } [Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")] public void SummarySheet_EmptyReport_NoTablesOrCharts() { var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31)); var sheet = (XSSFSheet)workbook.GetSheet("Результаты"); sheet.GetDrawingPatriarch().ShouldBeNull(); } }