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();
}
}