diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..6f2699892 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Run .NET Tests + +on: + push: + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Restore dependencies + run: dotnet restore Library.sln + + - name: Build + run: dotnet build Library.sln --configuration Release --no-restore + + - name: Run tests + run: dotnet test Library.sln --configuration Release --no-build \ No newline at end of file diff --git a/.gitignore b/.gitignore index ce892922f..6ecb5f9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +**/bin +**/obj \ No newline at end of file diff --git a/Library.sln b/Library.sln new file mode 100644 index 000000000..6550f8d0c --- /dev/null +++ b/Library.sln @@ -0,0 +1,36 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Library", "Library", "{50A17DA1-3556-4046-CEDC-33EB466D9C32}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Domain", "Library\Library.Domain\Library.Domain.csproj", "{39E55976-5424-CEC7-0978-4D789AC54AC6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Tests", "Library\Library.Tests\Library.Tests.csproj", "{60F294C7-3D77-63D8-5514-C4AB9BB913BD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {39E55976-5424-CEC7-0978-4D789AC54AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39E55976-5424-CEC7-0978-4D789AC54AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39E55976-5424-CEC7-0978-4D789AC54AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39E55976-5424-CEC7-0978-4D789AC54AC6}.Release|Any CPU.Build.0 = Release|Any CPU + {60F294C7-3D77-63D8-5514-C4AB9BB913BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60F294C7-3D77-63D8-5514-C4AB9BB913BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60F294C7-3D77-63D8-5514-C4AB9BB913BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60F294C7-3D77-63D8-5514-C4AB9BB913BD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {39E55976-5424-CEC7-0978-4D789AC54AC6} = {50A17DA1-3556-4046-CEDC-33EB466D9C32} + {60F294C7-3D77-63D8-5514-C4AB9BB913BD} = {50A17DA1-3556-4046-CEDC-33EB466D9C32} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B2802E6A-D42A-4C1D-8949-77992D768DF6} + EndGlobalSection +EndGlobal diff --git a/Library/Library.Domain/Data/DataSeeder.cs b/Library/Library.Domain/Data/DataSeeder.cs new file mode 100644 index 000000000..226a31ba2 --- /dev/null +++ b/Library/Library.Domain/Data/DataSeeder.cs @@ -0,0 +1,84 @@ +using Library.Domain.Models; + +namespace Library.Domain.Data; + +/// +/// Класс, содержащий заранее подготовленные тестовые данные +/// +public class DataSeeder +{ + + private static readonly DateTime SeedNow = new(2026, 2, 19); + + public List EditionTypes { get; } = + [ + new EditionType { Id = 1, Name = "Монография" }, + new EditionType { Id = 2, Name = "Методическое пособие" }, + new EditionType { Id = 3, Name = "Энциклопедия" }, + new EditionType { Id = 4, Name = "Биография" }, + new EditionType { Id = 5, Name = "Фэнтези" }, + new EditionType { Id = 6, Name = "Техническая литература" }, + new EditionType { Id = 7, Name = "Публицистика" }, + new EditionType { Id = 8, Name = "Поэзия" }, + new EditionType { Id = 9, Name = "Психология" }, + new EditionType { Id = 10, Name = "Бизнес-литература" }, + ]; + + public List Publishers { get; } = + [ + new Publisher { Id = 1, Name = "Бином" }, + new Publisher { Id = 2, Name = "Инфра-М" }, + new Publisher { Id = 3, Name = "Юрайт" }, + new Publisher { Id = 4, Name = "ДМК Пресс" }, + new Publisher { Id = 5, Name = "Лань" }, + new Publisher { Id = 6, Name = "Альпина Паблишер" }, + new Publisher { Id = 7, Name = "МИФ" }, + new Publisher { Id = 8, Name = "Вильямс" }, + new Publisher { Id = 9, Name = "Самокат" }, + new Publisher { Id = 10, Name = "Энергия" }, + ]; + + public List Books { get; } = + [ + new Book { Id = 1, InventoryNumber = "BK-101", AlphabetCode = "И-101", Authors = "И. Ньютон", Title = "Математические начала", EditionTypeId = 1, PublisherId = 5, Year = 1687 }, + new Book { Id = 2, InventoryNumber = "BK-102", AlphabetCode = "Т-210", Authors = "А. Тьюринг", Title = "Вычислительные машины", EditionTypeId = 6, PublisherId = 4, Year = 1936 }, + new Book { Id = 3, InventoryNumber = "BK-103", AlphabetCode = "К-310", Authors = "И. Кант", Title = "Критика чистого разума", EditionTypeId = 7, PublisherId = 6, Year = 1781 }, + new Book { Id = 4, InventoryNumber = "BK-104", AlphabetCode = "Р-410", Authors = "Д. Роулинг", Title = "Тайная комната", EditionTypeId = 5, PublisherId = 9, Year = 1998 }, + new Book { Id = 5, InventoryNumber = "BK-105", AlphabetCode = "М-510", Authors = "М. Портер", Title = "Конкурентная стратегия", EditionTypeId = 10, PublisherId = 7, Year = 1980 }, + new Book { Id = 6, InventoryNumber = "BK-106", AlphabetCode = "С-610", Authors = "К. Саган", Title = "Космос", EditionTypeId = 3, PublisherId = 1, Year = 1980 }, + new Book { Id = 7, InventoryNumber = "BK-107", AlphabetCode = "Ф-710", Authors = "З. Фрейд", Title = "Толкование сновидений", EditionTypeId = 9, PublisherId = 6, Year = 1899 }, + new Book { Id = 8, InventoryNumber = "BK-108", AlphabetCode = "Л-810", Authors = "С. Лем", Title = "Солярис", EditionTypeId = 5, PublisherId = 2, Year = 1961 }, + new Book { Id = 9, InventoryNumber = "BK-109", AlphabetCode = "Х-910", Authors = "Ю. Харари", Title = "Sapiens", EditionTypeId = 4, PublisherId = 6, Year = 2011 }, + new Book { Id = 10, InventoryNumber = "BK-110", AlphabetCode = "Г-999", Authors = "А. Гауди", Title = "Архитектура форм", EditionTypeId = 1, PublisherId = 10, Year = 1925 }, + ]; + + public List Readers => new() + { + new Reader { Id = 1, FullName = "Орлов Денис Сергеевич", Address = "ул. Березовая, 12", Phone = "89110000001", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-3)) }, + new Reader { Id = 2, FullName = "Мельников Артем Игоревич", Address = "ул. Солнечная, 45", Phone = "89110000002", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddYears(-2)) }, + new Reader { Id = 3, FullName = "Белов Кирилл Андреевич", Address = "ул. Полевая, 7", Phone = "89110000003", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-18)) }, + new Reader { Id = 4, FullName = "Егорова Марина Олеговна", Address = "ул. Озерная, 21", Phone = "89110000004", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-12)) }, + new Reader { Id = 5, FullName = "Тарасов Максим Дмитриевич", Address = "ул. Лесная, 3", Phone = "89110000005", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-10)) }, + new Reader { Id = 6, FullName = "Крылова Анастасия Павловна", Address = "ул. Школьная, 9", Phone = "89110000006", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-8)) }, + new Reader { Id = 7, FullName = "Никитин Роман Евгеньевич", Address = "ул. Центральная, 15", Phone = "89110000007", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-6)) }, + new Reader { Id = 8, FullName = "Волкова Дарья Ильинична", Address = "ул. Мира, 19", Phone = "89110000008", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-5)) }, + new Reader { Id = 9, FullName = "Зайцев Павел Николаевич", Address = "ул. Новая, 8", Phone = "89110000009", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-4)) }, + new Reader { Id = 10, FullName = "Громова София Артемовна", Address = "ул. Южная, 14", Phone = "89110000010", RegistrationDate = DateOnly.FromDateTime(SeedNow.AddMonths(-2)) }, + }; + + public List BookIssues => new() + { + new BookIssue { Id = 1, BookId = 1, ReaderId = 1, IssueDate = SeedNow.AddDays(-15), Days = 30 }, + new BookIssue { Id = 2, BookId = 2, ReaderId = 1, IssueDate = SeedNow.AddDays(-200), Days = 60 }, + new BookIssue { Id = 3, BookId = 3, ReaderId = 2, IssueDate = SeedNow.AddDays(-40), Days = 14 }, + new BookIssue { Id = 4, BookId = 4, ReaderId = 2, IssueDate = SeedNow.AddDays(-7), Days = 10 }, + new BookIssue { Id = 5, BookId = 5, ReaderId = 3, IssueDate = SeedNow.AddDays(-300), Days = 21 }, + new BookIssue { Id = 6, BookId = 6, ReaderId = 4, IssueDate = SeedNow.AddDays(-50), Days = 14 }, + new BookIssue { Id = 7, BookId = 7, ReaderId = 5, IssueDate = SeedNow.AddDays(-3), Days = 7 }, + new BookIssue { Id = 8, BookId = 8, ReaderId = 6, IssueDate = SeedNow.AddDays(-120), Days = 30 }, + new BookIssue { Id = 9, BookId = 9, ReaderId = 7, IssueDate = SeedNow.AddDays(-60), Days = 20 }, + new BookIssue { Id = 10, BookId = 10, ReaderId = 8, IssueDate = SeedNow.AddDays(-25), Days = 14 }, + new BookIssue { Id = 11, BookId = 1, ReaderId = 9, IssueDate = SeedNow.AddDays(-5), Days = 10 }, + new BookIssue { Id = 12, BookId = 2, ReaderId = 10, IssueDate = SeedNow.AddDays(-90), Days = 30 } + }; +} diff --git a/Library/Library.Domain/Library.Domain.csproj b/Library/Library.Domain/Library.Domain.csproj new file mode 100644 index 000000000..bb23fb7d6 --- /dev/null +++ b/Library/Library.Domain/Library.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Library/Library.Domain/Models/Book.cs b/Library/Library.Domain/Models/Book.cs new file mode 100644 index 000000000..b599cdfaa --- /dev/null +++ b/Library/Library.Domain/Models/Book.cs @@ -0,0 +1,62 @@ +namespace Library.Domain.Models; + +/// +/// Сущность книги из каталога библиотеки +/// +public class Book +{ + /// + /// Уникальный идентификатор + /// + public required int Id { get; set; } + + /// + /// Инвентарный номер + /// + public required string InventoryNumber { get; set; } + + /// + /// Шифр в алфавитном каталоге + /// + public required string AlphabetCode { get; set; } + + /// + /// Инициалы и фамилии авторов + /// + public string? Authors { get; set; } + + /// + /// Название + /// + public required string Title { get; set; } + + /// + /// Идентификатор вида издания + /// + public required int EditionTypeId { get; set; } + + /// + /// Вид издания + /// + public EditionType? EditionType { get; set; } + + /// + /// Идентификатор издательства + /// + public required int PublisherId { get; set; } + + /// + /// Издательство + /// + public Publisher? Publisher { get; set; } + + /// + /// Год издания + /// + public required int Year { get; set; } + + /// + /// Записи о выдаче книги + /// + public ICollection Issues { get; set; } = []; +} \ No newline at end of file diff --git a/Library/Library.Domain/Models/BookIssue.cs b/Library/Library.Domain/Models/BookIssue.cs new file mode 100644 index 000000000..81b8b8104 --- /dev/null +++ b/Library/Library.Domain/Models/BookIssue.cs @@ -0,0 +1,53 @@ +namespace Library.Domain.Models; + +/// +/// Сущность выдачи книги читателю с указанием сроков и состояния возврата +/// +public class BookIssue +{ + /// + /// Уникальный идентификатор + /// + public required int Id { get; set; } + + /// + /// Идентификатор книги + /// + public required int BookId { get; set; } + + /// + /// Выданная книга + /// + public Book? Book { get; set; } + + /// + /// Идентификатор читателя + /// + public required int ReaderId { get; set; } + + /// + /// Читатель, которому была выдана книга + /// + public Reader? Reader { get; set; } + + /// + /// Дата выдачи книги + /// + public required DateTime IssueDate { get; set; } + + /// + /// Количество дней, на которое выдана книга + /// + public required int Days { get; set; } + + /// + /// Дата возврата книги (IssueDate + Days) + /// + public DateTime? ReturnDate => IssueDate.AddDays(Days); + + /// + /// Флаг просрочки срока возврата книги + /// + public bool IsOverdue(DateTime currentDate) => + ReturnDate == null && currentDate.Date > IssueDate.AddDays(Days).Date; +} \ No newline at end of file diff --git a/Library/Library.Domain/Models/EditionType.cs b/Library/Library.Domain/Models/EditionType.cs new file mode 100644 index 000000000..4eba925ba --- /dev/null +++ b/Library/Library.Domain/Models/EditionType.cs @@ -0,0 +1,17 @@ +namespace Library.Domain.Models; + +/// +/// Справочник видов издания, используемый для классификации книг +/// +public class EditionType +{ + /// + /// Уникальный идентификатор + /// + public required int Id { get; set; } + + /// + /// Наименование вида издания + /// + public required string Name { get; set; } +} \ No newline at end of file diff --git a/Library/Library.Domain/Models/Publisher.cs b/Library/Library.Domain/Models/Publisher.cs new file mode 100644 index 000000000..5c1fedbac --- /dev/null +++ b/Library/Library.Domain/Models/Publisher.cs @@ -0,0 +1,17 @@ +namespace Library.Domain.Models; + +/// +/// Справочник издательств, к которым относятся книги +/// +public class Publisher +{ + /// + /// Уникальный идентификатор + /// + public required int Id { get; set; } + + /// + /// Наименование издательства + /// + public required string Name { get; set; } +} \ No newline at end of file diff --git a/Library/Library.Domain/Models/Reader.cs b/Library/Library.Domain/Models/Reader.cs new file mode 100644 index 000000000..3e4c87e4d --- /dev/null +++ b/Library/Library.Domain/Models/Reader.cs @@ -0,0 +1,37 @@ +namespace Library.Domain.Models; + +/// +/// Сущность читателя библиотеки с персональными данными и историей выдач +/// +public class Reader +{ + /// + /// Уникальный идентификатор + /// + public required int Id { get; set; } + + /// + /// ФИО + /// + public required string FullName { get; set; } + + /// + /// Адрес + /// + public string? Address { get; set; } + + /// + /// Телефон + /// + public required string Phone { get; set; } + + /// + /// Дата регистрации + /// + public DateOnly? RegistrationDate { get; set; } + + /// + /// Выданные книги + /// + public ICollection BookIssues { get; set; } = []; +} \ No newline at end of file diff --git a/Library/Library.Tests/Library.Tests.csproj b/Library/Library.Tests/Library.Tests.csproj new file mode 100644 index 000000000..6f0ecb3b5 --- /dev/null +++ b/Library/Library.Tests/Library.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/Library/Library.Tests/LibraryTests.cs b/Library/Library.Tests/LibraryTests.cs new file mode 100644 index 000000000..c6384e67e --- /dev/null +++ b/Library/Library.Tests/LibraryTests.cs @@ -0,0 +1,151 @@ +using Library.Domain.Data; + +namespace Library.Tests; + +/// +/// Набор unit тестов для тестирования доменной области +/// +public class LibraryTests +{ + /// + /// Тестовые данные библиотеки + /// + private readonly DataSeeder _dataSeeder = new(); + + /// + /// Контрольная дата, используемая в тестах + /// + private readonly DateTime _now = new(2026, 2, 19); + + /// + /// Топ 5 издательств за последний год по количеству выдач и сравнивает по Id и количествам + /// + [Fact] + public void IssuedBooks_OrderByBookTitle_ReturnsActiveIssuesOrderedByTitle() + { + var actualBookIds = _dataSeeder.BookIssues + .Where(bi => _now.Date < bi.ReturnDate) + .Join(_dataSeeder.Books, + bi => bi.BookId, + b => b.Id, + (bi, b) => new { bi, b }) + .OrderBy(x => x.b.Title) + .Select(x => x.b.Id) + .ToList(); + + var expectedBookIds = new List { 1, 1, 4, 7 }; + + Assert.Equal(expectedBookIds, actualBookIds); + } + + /// + /// Топ 5 читателей за последний год по количеству выдач и сравнивает по Id и по количествам + /// + [Fact] + public void Top5Readers_ByIssuesCountInPeriod_ReturnsExpectedTop5() + { + var periodStart = _now.AddYears(-1); + var periodEnd = _now; + + var topReaders = _dataSeeder.BookIssues + .Where(bi => bi.IssueDate >= periodStart && bi.IssueDate <= periodEnd) + .GroupBy(bi => bi.ReaderId) + .Select(g => new { ReaderId = g.Key, Count = g.Count() }) + .Join(_dataSeeder.Readers, g => g.ReaderId, r => r.Id, (g, r) => new { r.Id, r.FullName, g.Count }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.FullName) + .Take(5) + .ToList(); + + var actualIds = topReaders.Select(x => x.Id).ToList(); + var actualCounts = topReaders.Select(x => x.Count).ToList(); + + var expectedIds = new List { 2, 1, 3, 8, 10 }; + var expectedCounts = new List { 2, 2, 1, 1, 1 }; + + Assert.Equal(expectedIds, actualIds); + Assert.Equal(expectedCounts, actualCounts); + } + + /// + /// Читатели, у которых есть выдачи с максимальным количеством дней, и сортирует их по ФИО + /// + [Fact] + public void Readers_ByMaxLoanDaysOrderedByFullName_ReturnsExpected() + { + var maxDays = _dataSeeder.BookIssues.Max(bi => bi.Days); + + var readersWithMaxDays = _dataSeeder.BookIssues + .Where(bi => bi.Days == maxDays) + .Select(bi => bi.ReaderId) + .Distinct() + .Join(_dataSeeder.Readers, id => id, r => r.Id, (id, r) => new { r.Id, r.FullName }) + .OrderBy(r => r.FullName) + .Select(r => r.Id) + .ToList(); + + var expectedId = 1; + var expectedDays = 60; + + Assert.Single(readersWithMaxDays); + Assert.Equal(expectedDays, maxDays); + Assert.Equal(expectedId, readersWithMaxDays[0]); + } + + /// + /// Топ 5 издательств за последний год по количеству выдач и сравнивает по Id и количествам + /// + [Fact] + public void Top5Publishers_ByIssuesCountLastYear_ReturnsExpectedTop5() + { + var lastYearStart = _now.AddYears(-1); + var lastYearEnd = _now; + + var topPublishers = _dataSeeder.BookIssues + .Where(bi => bi.IssueDate >= lastYearStart && bi.IssueDate <= lastYearEnd) + .Join(_dataSeeder.Books, bi => bi.BookId, b => b.Id, (bi, b) => b.PublisherId) + .GroupBy(pid => pid) + .Select(g => new { PublisherId = g.Key, Count = g.Count() }) + .Join(_dataSeeder.Publishers, g => g.PublisherId, p => p.Id, (g, p) => new { p.Id, p.Name, g.Count }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Name) + .Take(5) + .ToList(); + + var actualPublisherIds = topPublishers.Select(x => x.Id).ToList(); + var actualCounts = topPublishers.Select(x => x.Count).ToList(); + + var expectedPublisherIds = new List { 6, 4, 5, 1, 2 }; + var expectedCounts = new List { 3, 2, 2, 1, 1 }; + + Assert.Equal(expectedPublisherIds, actualPublisherIds); + Assert.Equal(expectedCounts, actualCounts); + } + + /// + /// Топ 5 наименее популярных книг за последний год сравнение по Id и количествам + /// + [Fact] + public void Bottom5Books_ByIssuesCountLastYear_ReturnsExpectedBottom5() + { + var lastYearStart = _now.AddYears(-1); + var lastYearEnd = _now; + + var bookCounts = _dataSeeder.Books + .GroupJoin( + _dataSeeder.BookIssues.Where(bi => bi.IssueDate >= lastYearStart && bi.IssueDate <= lastYearEnd), + b => b.Id, + bi => bi.BookId, + (b, issues) => new { Book = b, Count = issues.Count() } + ) + .OrderBy(x => x.Count) + .ThenBy(x => x.Book.Title, StringComparer.Ordinal) + .Take(5) + .ToList(); + + var actualBookIds = bookCounts.Select(x => x.Book.Id).ToList(); + var expectedBookIds = new List { 9, 10, 5, 6, 3 }; + + Assert.Equal(expectedBookIds, actualBookIds); + } +}