diff --git a/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs b/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs new file mode 100644 index 000000000..cf012423f --- /dev/null +++ b/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs @@ -0,0 +1,24 @@ +namespace JoinRpg.Dal.Impl.Repositories; + +internal static class KogdaIgraMissingGamesPredicate +{ + /// + /// Возвращает Expression-предикат для фильтрации проектов, нуждающихся в привязке к КогдаИгра. + /// Используется с LinqKit (.AsExpandable() + .Invoke()) для трансляции запроса в SQL. + /// См. docs/linq-queries.md + /// + /// Проект попадает в выборку если: + /// 1. Проект активен и не отключена привязка к КогдаИгра + /// 2. Нет активных КИ-привязок вообще, ИЛИ + /// все привязанные КИ-игры уже завершились (End < now) И проект недавно обновлялся (<60 дней) + /// + public static Expression> GetPredicate(DateTime now) + { + var lastUpdateMax = now.AddDays(-60); + return (project, lastUpdated) => + project.Active && + !project.Details.DisableKogdaIgraMapping && + !project.KogdaIgraGames.Any(g => g.Active && g.End >= now) && + (!project.KogdaIgraGames.Any(g => g.Active) || lastUpdated > lastUpdateMax); + } +} diff --git a/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs b/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs index 85656fb6a..89c8fe3d8 100644 --- a/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs +++ b/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs @@ -232,29 +232,17 @@ async Task IProjectRepository.GetProjectsBySpecification(Pro private async Task GetKogdaIgraMissingGames() { - var kogdaIgraStaleExpression60 = ProjectPredicates.KogdaIgraIsStaleFor(TimeSpan.FromDays(60)); - Expression> hasNonStaleKogdaIgra = project => project.KogdaIgraGames.Count(e => kogdaIgraStaleExpression60.Invoke(e)) > 0; - var filterPredicate = PredicateBuilder.New() - .And(project => project.Active) - .And(project => !hasNonStaleKogdaIgra.Invoke(project)) - .And(project => !project.Details.DisableKogdaIgraMapping); var projection = GetProjectListProjection(); - - var lastUpdateMax = DateTime.Now.AddDays(-60); + var predicate = KogdaIgraMissingGamesPredicate.GetPredicate(DateTime.Now); var query = from project in AllProjects join update in GetProjectWithLastUpdateQuery() on project.ProjectId equals update.ProjectId - where filterPredicate.Invoke(project) - let item = projection.Invoke(project, update) - where item.KogdaIgraGames.Count() == 0 || item.LastUpdated > lastUpdateMax - select item; - - + where predicate.Invoke(project, update.LastUpdated) + select projection.Invoke(project, update); var result = await query.ToListAsync(); return [.. result.Select(BuildProjectShortInfo)]; - } Task IProjectRepository.GetProjectsByIds(UserIdentification? userId, ProjectIdentification[] ids) diff --git a/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs b/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs new file mode 100644 index 000000000..25c0622f3 --- /dev/null +++ b/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs @@ -0,0 +1,162 @@ +using JoinRpg.Dal.Impl.Repositories; +using JoinRpg.DataModel.Projects; +using Shouldly; +using Xunit; + +namespace JoinRpg.DataModel.Mocks; + +public class KogdaIgraMissingGamesPredicateTest +{ + private static readonly DateTime Now = new DateTime(2025, 6, 1, 12, 0, 0); + private static readonly DateTime RecentlyUpdated = Now.AddDays(-10); + private static readonly DateTime StaleUpdated = Now.AddDays(-90); + + private static KogdaIgraGame GameEndingIn(int days) => new() + { + KogdaIgraGameId = 1, + Active = true, + End = Now.AddDays(days), + Name = "Test Game", + }; + + private static KogdaIgraGame GameWithNullEnd() => new() + { + KogdaIgraGameId = 2, + Active = true, + End = null, + Name = "Test Game No End", + }; + + private static KogdaIgraGame InactiveGame(int endDaysOffset) => new() + { + KogdaIgraGameId = 3, + Active = false, + End = Now.AddDays(endDaysOffset), + Name = "Inactive Game", + }; + + private static Project CreateProject( + IEnumerable? games = null, + bool active = true, + bool disableKogdaIgraMapping = false) => new() + { + Active = active, + Details = new ProjectDetails { DisableKogdaIgraMapping = disableKogdaIgraMapping }, + KogdaIgraGames = [.. (games ?? [])], + }; + + private static bool TestPredicate(Project project, DateTime lastUpdated) + => KogdaIgraMissingGamesPredicate.GetPredicate(Now).Compile()(project, lastUpdated); + + // --- Нет привязок --- + + [Fact] + public void NoGames_RecentlyUpdated_ShouldNeedBinding() + => TestPredicate(CreateProject(), RecentlyUpdated).ShouldBeTrue(); + + [Fact] + public void NoGames_StaleProject_ShouldNeedBinding() + // Даже устаревший проект без привязки — кандидат (нужна привязка) + => TestPredicate(CreateProject(), StaleUpdated).ShouldBeTrue(); + + // --- Будущие игры (ещё не прошли) --- + + [Fact] + public void FutureGame_RecentlyUpdated_ShouldNotNeedBinding() + => TestPredicate(CreateProject([GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse(); + + [Fact] + public void FutureGame_StaleProject_ShouldNotNeedBinding() + // Привязан к будущей игре — не нужна новая привязка + => TestPredicate(CreateProject([GameEndingIn(30)]), StaleUpdated).ShouldBeFalse(); + + [Fact] + public void GameEndingToday_ShouldNotNeedBinding() + // End == Now: граница, игра «ещё не закончилась» + => TestPredicate(CreateProject([GameEndingIn(0)]), RecentlyUpdated).ShouldBeFalse(); + + // --- Прошедшие игры --- + + [Fact] + public void PastGame_RecentlyUpdated_ShouldNeedBinding() + // Сериал: игра прошла, проект активен → надо привязать следующую + => TestPredicate(CreateProject([GameEndingIn(-1)]), RecentlyUpdated).ShouldBeTrue(); + + [Fact] + public void PastGame_StaleProject_ShouldNotNeedBinding() + // Игра прошла, проект неактивен — не показывать в выборке + => TestPredicate(CreateProject([GameEndingIn(-1)]), StaleUpdated).ShouldBeFalse(); + + [Fact] + public void PastGame_UpdatedExactlyAtBoundary_ShouldNotNeedBinding() + { + // Граница: обновлено ровно 60 дней назад — не попадает в выборку + var updatedAt60DaysAgo = Now.AddDays(-60); + TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt60DaysAgo).ShouldBeFalse(); + } + + [Fact] + public void PastGame_UpdatedJustUnderBoundary_ShouldNeedBinding() + { + // Граница: обновлено 59 дней назад — попадает в выборку + var updatedAt59DaysAgo = Now.AddDays(-59); + TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt59DaysAgo).ShouldBeTrue(); + } + + // --- Неактивные привязки --- + + [Fact] + public void OnlyInactiveGame_RecentlyUpdated_ShouldNeedBinding() + // Привязка помечена как неактивная — игнорируется, как нет привязки + => TestPredicate(CreateProject([InactiveGame(30)]), RecentlyUpdated).ShouldBeTrue(); + + [Fact] + public void OnlyInactiveGame_StaleProject_ShouldNeedBinding() + => TestPredicate(CreateProject([InactiveGame(30)]), StaleUpdated).ShouldBeTrue(); + + // --- Несколько игр (сериал) --- + + [Fact] + public void MixedGames_OnePastOneFuture_ShouldNotNeedBinding() + // Есть и прошедшая и будущая игра — привязан, не нуждается + => TestPredicate(CreateProject([GameEndingIn(-30), GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse(); + + [Fact] + public void MultiplePastGames_RecentlyUpdated_ShouldNeedBinding() + // Все игры прошли, проект активен → нужна новая привязка + => TestPredicate( + CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]), + RecentlyUpdated).ShouldBeTrue(); + + [Fact] + public void MultiplePastGames_StaleProject_ShouldNotNeedBinding() + => TestPredicate( + CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]), + StaleUpdated).ShouldBeFalse(); + + // --- Граничный случай: End = null --- + + [Fact] + public void GameWithNullEnd_RecentlyUpdated_ShouldNeedBinding() + // Игра без даты окончания: End=null → null >= now = false → hasActiveOrFutureGame=false + // Но hasAnyGame=true, поэтому проверяется активность проекта + => TestPredicate(CreateProject([GameWithNullEnd()]), RecentlyUpdated).ShouldBeTrue(); + + [Fact] + public void GameWithNullEnd_StaleProject_ShouldNotNeedBinding() + => TestPredicate(CreateProject([GameWithNullEnd()]), StaleUpdated).ShouldBeFalse(); + + // --- Проект с DisableKogdaIgraMapping --- + + [Fact] + public void DisableKogdaIgraMapping_ShouldNotNeedBinding() + // Проект с отключённой привязкой к КогдаИгра — не попадает в выборку + => TestPredicate(CreateProject(disableKogdaIgraMapping: true), RecentlyUpdated).ShouldBeFalse(); + + // --- Неактивный проект --- + + [Fact] + public void InactiveProject_ShouldNotNeedBinding() + // Неактивный проект — не попадает в выборку + => TestPredicate(CreateProject(active: false), RecentlyUpdated).ShouldBeFalse(); +}