e2b6227015
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/20
387 lines
21 KiB
C#
387 lines
21 KiB
C#
using NPOI.SS.UserModel;
|
||
using NPOI.XSSF.UserModel;
|
||
using Shouldly;
|
||
using Tnsu.ApprovalRoute.Application.Reports;
|
||
|
||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||
|
||
/// <summary>
|
||
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||
/// </summary>
|
||
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 = "Сергеева Надежда Ивановна",
|
||
IReadOnlyList<VacancyReportLine>? lines = null) =>
|
||
new(
|
||
position,
|
||
"офис",
|
||
vacancies,
|
||
closed,
|
||
submitDate ?? new DateOnly(2026, 7, 8),
|
||
closingDate,
|
||
"Ким Н.Ю.",
|
||
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||
recruiter,
|
||
// По умолчанию одна строка — вакансия без приёмов либо с одним;
|
||
// тесты многострочности передают lines явно.
|
||
lines ?? [new VacancyReportLine(null, startDate, null)]);
|
||
|
||
/// <summary>Строка листа в пределах вакансии — то, что различается между приёмами.</summary>
|
||
private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) =>
|
||
new(candidate, startDate, replaced);
|
||
|
||
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("Список вакансий (все)");
|
||
|
||
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
|
||
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, 13).ShouldBe("Инициатор заявки");
|
||
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
|
||
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
|
||
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
||
public void Header_DateColumns_SameStyleAsRestOfHeader()
|
||
{
|
||
// Регрессия: даты когда-то получали формат через SetDefaultColumnStyle,
|
||
// а он в NPOI переписывает стиль УЖЕ существующих ячеек колонки —
|
||
// включая шапку, которая от этого лишалась заливки (H1/I1/K1 получали
|
||
// DataDate вместо Header). Формат даты теперь ставится только на сами
|
||
// ячейки с датами (см. SetDate), шапки колонок с датами он не касается.
|
||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||
|
||
// NPOI отдаёт на каждый вызов CellStyle новую обёртку — сравнивать
|
||
// нужно индекс стиля в книге, а не ссылки на объекты.
|
||
var headerStyleIndex = CellAt(sheet, 1, 3)!.CellStyle.Index; // «Вакансия» — заведомо обычная шапка.
|
||
foreach (var col in (int[])[8, 9, 11]) // H «Дата подачи заявки», I «Дата закрытия», K «Дата выхода»
|
||
CellAt(sheet, 1, col)!.CellStyle.Index.ShouldBe(headerStyleIndex);
|
||
}
|
||
|
||
[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, 13).ShouldBe("Ким Н.Ю.");
|
||
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
|
||
StringAt(sheet, 2, 16).ShouldBe("да");
|
||
StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна");
|
||
}
|
||
|
||
[Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")]
|
||
public void SeveralHires_OneRowEachWithMergedCommonCells()
|
||
{
|
||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||
Row("Разнорабочий", vacancies: 4, closed: 4,
|
||
submitDate: new DateOnly(2026, 7, 10),
|
||
lines: [
|
||
Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)),
|
||
Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)),
|
||
Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.")
|
||
])
|
||
]));
|
||
|
||
// Построчные колонки — своё значение в каждой строке.
|
||
StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович");
|
||
StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич");
|
||
StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович");
|
||
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19));
|
||
DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21));
|
||
DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25));
|
||
StringAt(sheet, 4, 14).ShouldBe("Оспанов К.");
|
||
|
||
// Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel
|
||
// хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах.
|
||
StringAt(sheet, 2, 3).ShouldBe("Разнорабочий");
|
||
IsEmptyAt(sheet, 3, 3).ShouldBeTrue();
|
||
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||
IntAt(sheet, 2, 5).ShouldBe(4);
|
||
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||
|
||
// И сами объединения заведены — иначе продолжения выглядели бы пустыми.
|
||
var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList();
|
||
merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2);
|
||
// Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы.
|
||
merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName
|
||
merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate
|
||
}
|
||
|
||
[Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")]
|
||
public void SingleLine_NoMergedRegions()
|
||
{
|
||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||
|
||
sheet.NumMergedRegions.ShouldBe(0);
|
||
}
|
||
|
||
[Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")]
|
||
public void Totals_CountVacancyOnce_NotPerHire()
|
||
{
|
||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||
Row("Разнорабочий", vacancies: 4, closed: 4, lines: [
|
||
Line("Первый", new DateOnly(2026, 8, 19)),
|
||
Line("Второй", new DateOnly(2026, 8, 21))
|
||
]),
|
||
Row("Логист", vacancies: 1, closed: 0)
|
||
]));
|
||
|
||
// Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й.
|
||
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||
// Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение),
|
||
// поэтому SUM даёт 4+1=5, а не 4+4+1.
|
||
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||
IntAt(sheet, 4, 5).ShouldBe(1);
|
||
}
|
||
|
||
[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();
|
||
}
|
||
}
|