feat(unitFilter): Оптимизация фильрации юнитов. Проверка значений аттрибутов теперь выполняется в 2 запроса:
- проверяем доступные в базе значения - проверяем наличие значений в юнитах по id значений
This commit is contained in:
@@ -3,8 +3,8 @@ using PARR.Domain.Entities.Unit;
|
|||||||
|
|
||||||
namespace PARR.Core.Repositories.Interfaces.Unit
|
namespace PARR.Core.Repositories.Interfaces.Unit
|
||||||
{
|
{
|
||||||
public interface IUnitFieldValueRepository: IBaseRepository<UnitFieldValue>
|
public interface IUnitFieldValueRepository : IBaseRepository<UnitFieldValue>
|
||||||
{
|
{
|
||||||
Task<UnitFieldValue?> GetByValueNameAsync(string? value);
|
Task<List<Guid>> FindValueIdsByMaskAsync(string mask, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ namespace PARR.Core.Repositories.Interfaces.Unit
|
|||||||
{
|
{
|
||||||
public interface IUnitRepository : IBaseRepository<Domain.Entities.Unit.Unit>
|
public interface IUnitRepository : IBaseRepository<Domain.Entities.Unit.Unit>
|
||||||
{
|
{
|
||||||
IQueryable<Guid> GetInitialUnitIds(string dbValueMask);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Получить юниты по Id атрибута и маски значения
|
/// Поиск юнитов по списку ID значений.
|
||||||
|
/// Используется для эффективной фильтрации после предварительного поиска ValueId.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="query"></param>
|
Task<List<Guid>> FindUnitIdsByValueIdsAsync(
|
||||||
/// <param name="fieldId"></param>
|
IReadOnlyList<Guid> unitIds,
|
||||||
/// <param name="valueMask"></param>
|
Guid fieldId,
|
||||||
/// <param name="isInverse">true - не содержит, false - содержит</param>
|
IReadOnlyList<Guid> valueIds,
|
||||||
/// <returns></returns>
|
CancellationToken ct = default);
|
||||||
IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false);
|
|
||||||
|
IQueryable<Guid> GetInitialUnitIds(string dbValueMask);
|
||||||
|
|
||||||
IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes();
|
IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PARR.Core.Repositories.Interfaces.Unit;
|
using PARR.Core.Repositories.Interfaces.Unit;
|
||||||
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
|
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
|
||||||
using PARR.Domain.Entities.JobEntities;
|
using PARR.Domain.Entities.JobEntities;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace PARR.Core.Services.UnitFilterService.Matchers;
|
namespace PARR.Core.Services.UnitFilterService.Matchers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сопоставляет юниты с фильтрами по атрибутам.
|
/// Сопоставляет юниты с фильтрами по атрибутам.
|
||||||
|
/// Использует двухэтапный поиск: сначала ValueId, затем UnitId.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class UnitFieldMatcher : IUnitFieldMatcher
|
internal class UnitFieldMatcher : IUnitFieldMatcher
|
||||||
{
|
{
|
||||||
private const int chunkSize = 200;
|
private readonly IUnitRepository _unitRepository;
|
||||||
private readonly IUnitRepository unitRepository;
|
private readonly IUnitFieldValueRepository _unitFieldValueRepository;
|
||||||
private readonly ILogger<UnitFieldMatcher> logger;
|
private readonly ILogger<UnitFieldMatcher> _logger;
|
||||||
|
|
||||||
public UnitFieldMatcher(
|
public UnitFieldMatcher(
|
||||||
IUnitRepository unitRepository,
|
IUnitRepository unitRepository,
|
||||||
|
IUnitFieldValueRepository unitFieldValueRepository,
|
||||||
ILogger<UnitFieldMatcher> logger)
|
ILogger<UnitFieldMatcher> logger)
|
||||||
{
|
{
|
||||||
this.unitRepository = unitRepository;
|
_unitRepository = unitRepository;
|
||||||
this.logger = logger;
|
_unitFieldValueRepository = unitFieldValueRepository;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> MatchAsync(
|
public async Task<List<Guid>> MatchAsync(
|
||||||
@@ -32,32 +35,63 @@ internal class UnitFieldMatcher : IUnitFieldMatcher
|
|||||||
if (filters == null || filters.Count == 0)
|
if (filters == null || filters.Count == 0)
|
||||||
return new List<Guid>(unitIds);
|
return new List<Guid>(unitIds);
|
||||||
|
|
||||||
logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
|
_logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
|
||||||
unitIds.Count, filters.Count);
|
unitIds.Count, filters.Count);
|
||||||
|
|
||||||
var result = new List<Guid>(unitIds.Count);
|
var currentIds = new HashSet<Guid>(unitIds);
|
||||||
|
|
||||||
foreach (var chunk in unitIds.Chunk(chunkSize))
|
for (int i = 0; i < filters.Count; i++)
|
||||||
{
|
{
|
||||||
var query = unitRepository.Get().AsNoTracking()
|
var filter = filters[i];
|
||||||
.Where(u => chunk.Contains(u.Id));
|
var mask = filter.ValueMask?.Trim();
|
||||||
|
|
||||||
foreach (var fieldFilter in filters)
|
if (string.IsNullOrEmpty(mask))
|
||||||
{
|
|
||||||
var valueMask = fieldFilter.ValueMask?.Trim();
|
|
||||||
if (string.IsNullOrEmpty(valueMask))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
query = unitRepository.GetUnitByFieldAndValue(
|
var sw = Stopwatch.StartNew();
|
||||||
query, fieldFilter.FieldId, fieldFilter.ValueMask!, fieldFilter.IsInverse);
|
var inputCount = currentIds.Count;
|
||||||
|
|
||||||
|
var matchingValueIds = await _unitFieldValueRepository
|
||||||
|
.FindValueIdsByMaskAsync(mask, ct);
|
||||||
|
|
||||||
|
if (matchingValueIds.Count == 0 && !filter.IsInverse)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
_logger.LogDebug(
|
||||||
|
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}') | Вход: {InCount}, Выход: 0 (нет значений), Время: {Ms}ms",
|
||||||
|
i + 1, filter.FieldId, mask, inputCount, sw.ElapsedMilliseconds);
|
||||||
|
currentIds.Clear();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunkResult = await query.Select(u => u.Id).ToListAsync(ct);
|
// Шаг 2: Найти/исключить юниты по ValueId
|
||||||
result.AddRange(chunkResult);
|
if (filter.IsInverse)
|
||||||
|
{
|
||||||
|
var unitsToExclude = await _unitRepository
|
||||||
|
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
|
||||||
|
currentIds.ExceptWith(unitsToExclude);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var unitsToKeep = await _unitRepository
|
||||||
|
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
|
||||||
|
currentIds.IntersectWith(unitsToKeep);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogDebug("UnitFieldMatcher: выход {UnitCount} юнитов", result.Count);
|
sw.Stop();
|
||||||
|
_logger.LogDebug(
|
||||||
|
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}', Inverse={IsInverse}, Values={ValCount}) | Вход: {InCount}, Выход: {OutCount}, Время: {Ms}ms",
|
||||||
|
i + 1, filter.FieldId, mask, filter.IsInverse, matchingValueIds.Count,
|
||||||
|
inputCount, currentIds.Count, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
return result;
|
if (currentIds.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("UnitFieldMatcher: прерывание на фильтре #{Index} (0 юнитов)", i + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("UnitFieldMatcher: итоговый выход {UnitCount} юнитов", currentIds.Count);
|
||||||
|
return currentIds.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,7 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
public async Task<IEnumerable<UnitFilterResultDto>?> GetUnitsByJobFilterAsync(
|
public async Task<IEnumerable<UnitFilterResultDto>?> GetUnitsByJobFilterAsync(
|
||||||
Job job,
|
Job job,
|
||||||
int? takeCount = null,
|
int? takeCount = null,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (job.Group == null)
|
if (job.Group == null)
|
||||||
throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group");
|
throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group");
|
||||||
@@ -97,13 +96,9 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
var totalStopwatch = Stopwatch.StartNew();
|
var totalStopwatch = Stopwatch.StartNew();
|
||||||
logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count);
|
logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count);
|
||||||
|
|
||||||
// Собираем все контексты юнитов, прошедших фильтрацию
|
|
||||||
var allFilteredContexts = new List<UnitFilterMatchResult>();
|
var allFilteredContexts = new List<UnitFilterMatchResult>();
|
||||||
|
|
||||||
// Преобразуем в список для индексации
|
|
||||||
var unitFiltersList = job.UnitFilters.ToList();
|
var unitFiltersList = job.UnitFilters.ToList();
|
||||||
|
|
||||||
// Этап 1: Применение основных фильтров (Field, Relationship) на уровне SQL
|
|
||||||
for (int i = 0; i < unitFiltersList.Count; i++)
|
for (int i = 0; i < unitFiltersList.Count; i++)
|
||||||
{
|
{
|
||||||
var filter = unitFiltersList[i];
|
var filter = unitFiltersList[i];
|
||||||
@@ -113,14 +108,21 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
{
|
{
|
||||||
logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id);
|
logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id);
|
||||||
|
|
||||||
// 1. Найти ID юнитов по UnitFilter
|
// 1. Resolve
|
||||||
|
var resolveSw = Stopwatch.StartNew();
|
||||||
var initialUnitIds = await nameResolver.ResolveAsync(filter.UnitFilter, cancellationToken);
|
var initialUnitIds = await nameResolver.ResolveAsync(filter.UnitFilter, cancellationToken);
|
||||||
|
resolveSw.Stop();
|
||||||
|
|
||||||
if (!initialUnitIds.Any())
|
if (!initialUnitIds.Any())
|
||||||
{
|
{
|
||||||
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов)", i + 1);
|
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов после Resolve, {ResolveMs}ms)",
|
||||||
|
i + 1, resolveSw.ElapsedMilliseconds);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("Фильтр #{Index}: Resolve вернул {Count} юнитов за {Ms}ms",
|
||||||
|
i + 1, initialUnitIds.Count, resolveSw.ElapsedMilliseconds);
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if (initialUnitIds.Contains(debugTargetUnitId))
|
if (initialUnitIds.Contains(debugTargetUnitId))
|
||||||
{
|
{
|
||||||
@@ -128,14 +130,19 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 2. Применить FieldFilters
|
// 2. FieldFilters
|
||||||
var fieldStopwatch = Stopwatch.StartNew();
|
var fieldSw = Stopwatch.StartNew();
|
||||||
//var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
|
|
||||||
var fieldFilteredIds = await unitFieldMatcher.MatchAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
|
var fieldFilteredIds = await unitFieldMatcher.MatchAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
|
||||||
|
fieldSw.Stop();
|
||||||
|
|
||||||
|
logger.LogDebug("Фильтр #{Index}: FieldMatcher вернул {Count} юнитов за {Ms}ms",
|
||||||
|
i + 1, fieldFilteredIds.Count, fieldSw.ElapsedMilliseconds);
|
||||||
|
|
||||||
if (!fieldFilteredIds.Any())
|
if (!fieldFilteredIds.Any())
|
||||||
{
|
{
|
||||||
logger.LogDebug("Фильтр #{Index}: 0 юнитов после FieldFilters", i + 1);
|
filterStopwatch.Stop();
|
||||||
|
logger.LogDebug("Фильтр #{Index}: завершён (0 юнитов). [Resolve: {R}ms, Field: {F}ms, Total: {T}ms]",
|
||||||
|
i + 1, resolveSw.ElapsedMilliseconds, fieldSw.ElapsedMilliseconds, filterStopwatch.ElapsedMilliseconds);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +153,24 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 3. Применить RelationshipFilters
|
// 3. RelationshipFilters
|
||||||
var relStopwatch = Stopwatch.StartNew();
|
var relSw = Stopwatch.StartNew();
|
||||||
var relationshipFilteredContexts = await unitRelationshipMatcher.MatchAsync(
|
var relationshipFilteredContexts = await unitRelationshipMatcher.MatchAsync(
|
||||||
fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
|
fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
|
||||||
|
relSw.Stop();
|
||||||
|
|
||||||
if (!relationshipFilteredContexts.Any())
|
filterStopwatch.Stop();
|
||||||
{
|
allFilteredContexts.AddRange(relationshipFilteredContexts);
|
||||||
logger.LogDebug("Фильтр #{Index}: 0 юнитов после RelationshipFilters", i + 1);
|
|
||||||
continue;
|
logger.LogDebug(
|
||||||
}
|
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Resolve: {R}ms, Field: {F}ms, Rel: {Rel}ms, Total: {T}ms]",
|
||||||
|
i + 1,
|
||||||
|
relationshipFilteredContexts.Count,
|
||||||
|
allFilteredContexts.Count,
|
||||||
|
resolveSw.ElapsedMilliseconds,
|
||||||
|
fieldSw.ElapsedMilliseconds,
|
||||||
|
relSw.ElapsedMilliseconds,
|
||||||
|
filterStopwatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
var targetContext = relationshipFilteredContexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
|
var targetContext = relationshipFilteredContexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
|
||||||
@@ -165,25 +180,13 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
debugTargetUnitId, i + 1, targetContext.ValidParentIds.Count, targetContext.ValidChildIds.Count);
|
debugTargetUnitId, i + 1, targetContext.ValidParentIds.Count, targetContext.ValidChildIds.Count);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Добавляем отфильтрованные контексты в общий набор
|
|
||||||
allFilteredContexts.AddRange(relationshipFilteredContexts);
|
|
||||||
|
|
||||||
filterStopwatch.Stop();
|
|
||||||
logger.LogDebug(
|
|
||||||
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Field: {F}ms, Rel: {R}ms, Total: {T}ms]",
|
|
||||||
i + 1,
|
|
||||||
relationshipFilteredContexts.Count,
|
|
||||||
allFilteredContexts.Count,
|
|
||||||
fieldStopwatch.ElapsedMilliseconds,
|
|
||||||
relStopwatch.ElapsedMilliseconds,
|
|
||||||
filterStopwatch.ElapsedMilliseconds
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} для Job {JobId}",
|
filterStopwatch.Stop();
|
||||||
filter.Id, job.Id);
|
logger.LogError(ex, "Ошибка при обработке фильтра #{Index} (Id={FilterId}) для Job {JobId}. Время до ошибки: {Ms}ms",
|
||||||
|
i + 1, filter.Id, job.Id, filterStopwatch.ElapsedMilliseconds);
|
||||||
|
throw; // КРИТИЧНО: прерываем выполнение, чтобы не маскировать проблему
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
ValidChildIds = new HashSet<Guid>(g.SelectMany(c => c.ValidChildIds))
|
ValidChildIds = new HashSet<Guid>(g.SelectMany(c => c.ValidChildIds))
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count);
|
logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count);
|
||||||
|
|
||||||
// Этап 2: Применение Umbrella-фильтра
|
// Этап 2: Применение Umbrella-фильтра
|
||||||
@@ -219,6 +223,346 @@ internal class UnitFilterService : IUnitFilterService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
|
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await jobRepository
|
return await jobRepository
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using PARR.Core.Repositories.Interfaces.Unit;
|
||||||
|
using PARR.Core.Services.UnitFilterService.Matchers;
|
||||||
|
using PARR.Domain.Entities.JobEntities;
|
||||||
|
|
||||||
|
namespace PARR.Core.Tests.Services.UnitFilterService.Matchers
|
||||||
|
{
|
||||||
|
public class UnitFieldMatcherTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IUnitRepository> _unitRepoMock;
|
||||||
|
private readonly Mock<IUnitFieldValueRepository> _fieldValueRepoMock;
|
||||||
|
private readonly UnitFieldMatcher _sut;
|
||||||
|
|
||||||
|
public UnitFieldMatcherTests()
|
||||||
|
{
|
||||||
|
_unitRepoMock = new Mock<IUnitRepository>();
|
||||||
|
_fieldValueRepoMock = new Mock<IUnitFieldValueRepository>();
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<UnitFieldMatcher>();
|
||||||
|
_sut = new UnitFieldMatcher(
|
||||||
|
_unitRepoMock.Object,
|
||||||
|
_fieldValueRepoMock.Object,
|
||||||
|
logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если фильтры не заданы, возвращается исходный набор юнитов без изменений.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_NoFilters_ReturnsAllUnitIds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, Enumerable.Empty<JobFieldFilter>());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(unitIds.Count, result.Count);
|
||||||
|
Assert.Equal(unitIds, result);
|
||||||
|
_fieldValueRepoMock.Verify(
|
||||||
|
r => r.FindValueIdsByMaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прямой фильтр оставляет только юниты, имеющие совпадающие значения.
|
||||||
|
/// Проверяется логика IntersectWith.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_SingleDirectFilter_IntersectsWithMatchingUnits()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unit2 = Guid.NewGuid();
|
||||||
|
var unit3 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1, unit2, unit3 };
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var valueId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId, ValueMask = "коммутатор", IsInverse = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { valueId });
|
||||||
|
|
||||||
|
_unitRepoMock
|
||||||
|
.Setup(r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
fieldId,
|
||||||
|
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId)),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { unit1, unit3 });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(unit1, result);
|
||||||
|
Assert.Contains(unit3, result);
|
||||||
|
Assert.DoesNotContain(unit2, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Инверсный фильтр исключает юниты, имеющие совпадающие значения.
|
||||||
|
/// Проверяется логика ExceptWith.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_SingleInverseFilter_ExcludesMatchingUnits()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unit2 = Guid.NewGuid();
|
||||||
|
var unit3 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1, unit2, unit3 };
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var valueId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId, ValueMask = "1", IsInverse = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { valueId });
|
||||||
|
|
||||||
|
_unitRepoMock
|
||||||
|
.Setup(r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
fieldId,
|
||||||
|
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId)),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { unit2 });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(unit1, result);
|
||||||
|
Assert.Contains(unit3, result);
|
||||||
|
Assert.DoesNotContain(unit2, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Несколько фильтров применяются последовательно (AND).
|
||||||
|
/// Каждый следующий фильтр сужает набор, полученный от предыдущего.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_MultipleFilters_AppliesSequentially()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unit2 = Guid.NewGuid();
|
||||||
|
var unit3 = Guid.NewGuid();
|
||||||
|
var unit4 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1, unit2, unit3, unit4 };
|
||||||
|
|
||||||
|
var fieldId1 = Guid.NewGuid();
|
||||||
|
var fieldId2 = Guid.NewGuid();
|
||||||
|
var valueId1 = Guid.NewGuid();
|
||||||
|
var valueId2 = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId1, ValueMask = "СХД", IsInverse = false },
|
||||||
|
new() { FieldId = fieldId2, ValueMask = "коммутатор", IsInverse = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Первый фильтр: значения найдены для unit1, unit2, unit3
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("СХД", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { valueId1 });
|
||||||
|
_unitRepoMock
|
||||||
|
.Setup(r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 4),
|
||||||
|
fieldId1,
|
||||||
|
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId1)),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { unit1, unit2, unit3 });
|
||||||
|
|
||||||
|
// Второй фильтр: из оставшихся 3 юнитов значение найдено для unit1 и unit3
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { valueId2 });
|
||||||
|
_unitRepoMock
|
||||||
|
.Setup(r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3),
|
||||||
|
fieldId2,
|
||||||
|
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId2)),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { unit1, unit3 });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(unit1, result);
|
||||||
|
Assert.Contains(unit3, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если прямой фильтр не нашёл ни одного значения, результат пуст.
|
||||||
|
/// Проверяется раннее прерывание.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_NoMatchingValues_DirectFilter_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId, ValueMask = "несуществующее", IsInverse = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("несуществующее", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
_unitRepoMock.Verify(
|
||||||
|
r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если инверсный фильтр не нашёл ни одного значения, все юниты проходят.
|
||||||
|
/// Исключать нечего, поэтому FindUnitIdsByValueIdsAsync не вызывается.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_NoMatchingValues_InverseFilter_ReturnsAll()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unit2 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1, unit2 };
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId, ValueMask = "несуществующее", IsInverse = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("несуществующее", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(unit1, result);
|
||||||
|
Assert.Contains(unit2, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фильтр с пустой маской пропускается без вызова репозиториев.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_EmptyMask_SkipsFilter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId, ValueMask = " ", IsInverse = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(unitIds.Count, result.Count);
|
||||||
|
_fieldValueRepoMock.Verify(
|
||||||
|
r => r.FindValueIdsByMaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если после одного из фильтров набор стал пустым,
|
||||||
|
/// последующие фильтры не выполняются.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_EarlyBreak_WhenNoUnitsLeft()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1 };
|
||||||
|
|
||||||
|
var fieldId1 = Guid.NewGuid();
|
||||||
|
var fieldId2 = Guid.NewGuid();
|
||||||
|
var valueId1 = Guid.NewGuid();
|
||||||
|
|
||||||
|
var filters = new List<JobFieldFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = fieldId1, ValueMask = "СХД", IsInverse = false },
|
||||||
|
new() { FieldId = fieldId2, ValueMask = "коммутатор", IsInverse = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Первый фильтр возвращает пустой результат
|
||||||
|
_fieldValueRepoMock
|
||||||
|
.Setup(r => r.FindValueIdsByMaskAsync("СХД", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { valueId1 });
|
||||||
|
_unitRepoMock
|
||||||
|
.Setup(r => r.FindUnitIdsByValueIdsAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
fieldId1,
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
|
||||||
|
// Второй фильтр не должен вызываться
|
||||||
|
_fieldValueRepoMock.Verify(
|
||||||
|
r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using PARR.Core.Repositories.Interfaces.Unit;
|
||||||
|
using PARR.Core.Services.UnitFilterService.Matchers;
|
||||||
|
using PARR.DAL.Context;
|
||||||
|
using PARR.Domain.Entities.JobEntities;
|
||||||
|
using PARR.Domain.Entities.Unit;
|
||||||
|
|
||||||
|
namespace PARR.Core.Tests.Services.UnitFilterService.Matchers
|
||||||
|
{
|
||||||
|
public class UnitRelationshipMatcherTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IUnitInUnitRepository> _unitInUnitRepoMock;
|
||||||
|
private readonly Mock<IUnitInValueRepository> _unitInValueRepoMock;
|
||||||
|
private readonly UnitRelationshipMatcher _sut;
|
||||||
|
|
||||||
|
public UnitRelationshipMatcherTests()
|
||||||
|
{
|
||||||
|
_unitInUnitRepoMock = new Mock<IUnitInUnitRepository>();
|
||||||
|
_unitInValueRepoMock = new Mock<IUnitInValueRepository>();
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<UnitRelationshipMatcher>();
|
||||||
|
_sut = new UnitRelationshipMatcher(
|
||||||
|
_unitInUnitRepoMock.Object,
|
||||||
|
_unitInValueRepoMock.Object,
|
||||||
|
logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт юнит со значением поля и добавляет его в контекст.
|
||||||
|
/// </summary>
|
||||||
|
private static Unit BuildUnit(Guid id, string name, Guid fieldId, string fieldValue)
|
||||||
|
{
|
||||||
|
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
|
||||||
|
return new Unit
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
UnitValues = new List<UnitInValue>
|
||||||
|
{
|
||||||
|
new UnitInValue
|
||||||
|
{
|
||||||
|
UnitId = id,
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueId = fieldVal.Id,
|
||||||
|
Value = fieldVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настраивает моки репозиториев для работы с InMemory-контекстом.
|
||||||
|
/// GetMatchingTargetIds эмулирует ILike через Contains с обрезкой символов '%'.
|
||||||
|
/// </summary>
|
||||||
|
private void SetupMocks(DataContext context)
|
||||||
|
{
|
||||||
|
_unitInUnitRepoMock
|
||||||
|
.Setup(r => r.Get())
|
||||||
|
.Returns(context.UnitInUnits.AsQueryable());
|
||||||
|
|
||||||
|
_unitInValueRepoMock
|
||||||
|
.Setup(r => r.GetMatchingTargetIds(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
|
.Returns((Guid fieldId, string mask) =>
|
||||||
|
{
|
||||||
|
var trimmed = mask.Trim('%');
|
||||||
|
return context.UnitInValues
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(uv => uv.FieldId == fieldId
|
||||||
|
&& uv.Value != null
|
||||||
|
&& uv.Value.Value != null
|
||||||
|
&& uv.Value.Value.Contains(trimmed, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(uv => uv.UnitId)
|
||||||
|
.Distinct()
|
||||||
|
.AsQueryable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если фильтры не заданы, все юниты возвращаются как контексты без изменений.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_NoFilters_ReturnsAllUnitsAsContexts()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var unit2 = Guid.NewGuid();
|
||||||
|
var unitIds = new List<Guid> { unit1, unit2 };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(unitIds, Enumerable.Empty<JobRelationshipFilter>());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(result, c => c.UnitId == unit1);
|
||||||
|
Assert.Contains(result, c => c.UnitId == unit2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Если входной набор пуст, возвращается пустой список.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_EmptyUnitIds_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new() { FieldId = Guid.NewGuid(), ValueMask = "%test%", IsParent = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(new List<Guid>(), filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прямой родительский фильтр (IsFullMatch=false):
|
||||||
|
/// юнит проходит, если ХОТЯ БЫ ОДИН из его родителей соответствует маске.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_ParentFilter_DirectAnyMatch_OnlyUnitsWithMatchingParentPass()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var parentGood = Guid.NewGuid();
|
||||||
|
var parentBad = Guid.NewGuid();
|
||||||
|
var unitWithGoodParent = Guid.NewGuid();
|
||||||
|
var unitWithBadParent = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parentGood, "Parent_Good", fieldId, "VALID-TAG"),
|
||||||
|
BuildUnit(parentBad, "Parent_Bad", fieldId, "OTHER-TAG"),
|
||||||
|
BuildUnit(unitWithGoodParent, "Unit_Good", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitWithBadParent, "Unit_Bad", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parentGood, ChildUnitId = unitWithGoodParent, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentBad, ChildUnitId = unitWithBadParent, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%VALID%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitWithGoodParent, unitWithBadParent }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitWithGoodParent, resultIds);
|
||||||
|
Assert.DoesNotContain(unitWithBadParent, resultIds);
|
||||||
|
|
||||||
|
var ctx = result.First(c => c.UnitId == unitWithGoodParent);
|
||||||
|
Assert.Contains(parentGood, ctx.ValidParentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Инверсный родительский фильтр (IsInverse=true, IsFullMatch=false):
|
||||||
|
/// юнит проходит, если ХОТЯ БЫ ОДИН родитель НЕ соответствует маске.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_ParentFilter_Inverse_UnitsWithOnlyForbiddenParentsExcluded()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var parentForbidden = Guid.NewGuid();
|
||||||
|
var parentClean = Guid.NewGuid();
|
||||||
|
var unitWithForbiddenOnly = Guid.NewGuid();
|
||||||
|
var unitWithCleanParent = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parentForbidden, "Parent_Forbidden", fieldId, "FORBIDDEN"),
|
||||||
|
BuildUnit(parentClean, "Parent_Clean", fieldId, "CLEAN"),
|
||||||
|
BuildUnit(unitWithForbiddenOnly, "Unit_Forbidden", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitWithCleanParent, "Unit_Clean", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parentForbidden, ChildUnitId = unitWithForbiddenOnly, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitWithCleanParent, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%FORBIDDEN%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = true,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitWithForbiddenOnly, unitWithCleanParent }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitWithCleanParent, resultIds);
|
||||||
|
Assert.DoesNotContain(unitWithForbiddenOnly, resultIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IsFullMatch=true: юнит проходит, только если ВСЕ его родители соответствуют маске.
|
||||||
|
/// Юнит с одним "плохим" родителем исключается.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_ParentFilter_IsFullMatch_AllParentsMustPass()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var parentGood1 = Guid.NewGuid();
|
||||||
|
var parentGood2 = Guid.NewGuid();
|
||||||
|
var parentBad = Guid.NewGuid();
|
||||||
|
var unitAllGood = Guid.NewGuid();
|
||||||
|
var unitMixed = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parentGood1, "Parent_Good1", fieldId, "Good"),
|
||||||
|
BuildUnit(parentGood2, "Parent_Good2", fieldId, "Good"),
|
||||||
|
BuildUnit(parentBad, "Parent_Bad", fieldId, "Bad"),
|
||||||
|
BuildUnit(unitAllGood, "Unit_AllGood", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitMixed, "Unit_Mixed", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parentGood1, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentGood2, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentGood1, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentBad, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%Good%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitAllGood, unitMixed }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitAllGood, resultIds);
|
||||||
|
Assert.DoesNotContain(unitMixed, resultIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Комбинация IsInverse=true и IsFullMatch=true:
|
||||||
|
/// юнит проходит, только если НИ ОДИН родитель не соответствует маске.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_ParentFilter_InverseAndFullMatch_UnitWithAnyForbiddenParentExcluded()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var parentForbidden = Guid.NewGuid();
|
||||||
|
var parentClean = Guid.NewGuid();
|
||||||
|
var unitMixed = Guid.NewGuid();
|
||||||
|
var unitAllClean = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parentForbidden, "Parent_Forbidden", fieldId, "FORBIDDEN"),
|
||||||
|
BuildUnit(parentClean, "Parent_Clean", fieldId, "CLEAN"),
|
||||||
|
BuildUnit(unitMixed, "Unit_Mixed", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitAllClean, "Unit_AllClean", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parentForbidden, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitAllClean, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%FORBIDDEN%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = true,
|
||||||
|
IsFullMatch = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitMixed, unitAllClean }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitAllClean, resultIds);
|
||||||
|
Assert.DoesNotContain(unitMixed, resultIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дочерний фильтр (IsParent=false):
|
||||||
|
/// юнит проходит, если хотя бы один из его детей соответствует маске.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_ChildFilter_DirectMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var childValid = Guid.NewGuid();
|
||||||
|
var childInvalid = Guid.NewGuid();
|
||||||
|
var parentWithValidChild = Guid.NewGuid();
|
||||||
|
var parentWithInvalidChild = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(childValid, "Child_Valid", fieldId, "VALID"),
|
||||||
|
BuildUnit(childInvalid, "Child_Invalid", fieldId, "OTHER"),
|
||||||
|
BuildUnit(parentWithValidChild, "Parent_Valid", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(parentWithInvalidChild, "Parent_Invalid", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parentWithValidChild, ChildUnitId = childValid, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = parentWithInvalidChild, ChildUnitId = childInvalid, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%VALID%",
|
||||||
|
IsParent = false,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { parentWithValidChild, parentWithInvalidChild }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(parentWithValidChild, resultIds);
|
||||||
|
Assert.DoesNotContain(parentWithInvalidChild, resultIds);
|
||||||
|
|
||||||
|
var ctx = result.First(c => c.UnitId == parentWithValidChild);
|
||||||
|
Assert.Contains(childValid, ctx.ValidChildIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Юнит без связей исключается, если есть фильтры с непустой маской.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_UnitWithoutRelatedUnits_IsExcluded()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var unitWithParent = Guid.NewGuid();
|
||||||
|
var unitOrphan = Guid.NewGuid();
|
||||||
|
var parent = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parent, "Parent", fieldId, "VALID"),
|
||||||
|
BuildUnit(unitWithParent, "Unit_WithParent", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitOrphan, "Unit_Orphan", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parent, ChildUnitId = unitWithParent, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = "%VALID%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitWithParent, unitOrphan }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitWithParent, resultIds);
|
||||||
|
Assert.DoesNotContain(unitOrphan, resultIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фильтр с пустой маской пропускается: юниты не исключаются.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_EmptyMask_FilterSkipped()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var fieldId = Guid.NewGuid();
|
||||||
|
var unit1 = Guid.NewGuid();
|
||||||
|
var parent = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(parent, "Parent", fieldId, "Any"),
|
||||||
|
BuildUnit(unit1, "Unit", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
new UnitInUnit { ParentUnitId = parent, ChildUnitId = unit1, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = fieldId,
|
||||||
|
ValueMask = " ",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(new List<Guid> { unit1 }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(unit1, result.First().UnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Комбинация родительского и дочернего фильтров:
|
||||||
|
/// юнит должен пройти оба направления.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MatchAsync_CombinedParentAndChildFilters_BothDirectionsApplied()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<DataContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new DataContext(dbOptions);
|
||||||
|
|
||||||
|
var parentFieldId = Guid.NewGuid();
|
||||||
|
var childFieldId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var goodParent = Guid.NewGuid();
|
||||||
|
var badParent = Guid.NewGuid();
|
||||||
|
var goodChild = Guid.NewGuid();
|
||||||
|
var badChild = Guid.NewGuid();
|
||||||
|
|
||||||
|
var unitAllGood = Guid.NewGuid();
|
||||||
|
var unitBadParent = Guid.NewGuid();
|
||||||
|
var unitBadChild = Guid.NewGuid();
|
||||||
|
|
||||||
|
context.Units.AddRange(
|
||||||
|
BuildUnit(goodParent, "GoodParent", parentFieldId, "GOOD-PARENT"),
|
||||||
|
BuildUnit(badParent, "BadParent", parentFieldId, "BAD-PARENT"),
|
||||||
|
BuildUnit(goodChild, "GoodChild", childFieldId, "GOOD-CHILD"),
|
||||||
|
BuildUnit(badChild, "BadChild", childFieldId, "BAD-CHILD"),
|
||||||
|
BuildUnit(unitAllGood, "Unit_AllGood", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitBadParent, "Unit_BadParent", Guid.NewGuid(), "x"),
|
||||||
|
BuildUnit(unitBadChild, "Unit_BadChild", Guid.NewGuid(), "x")
|
||||||
|
);
|
||||||
|
|
||||||
|
context.UnitInUnits.AddRange(
|
||||||
|
// unitAllGood: хороший родитель + хороший ребёнок
|
||||||
|
new UnitInUnit { ParentUnitId = goodParent, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = unitAllGood, ChildUnitId = goodChild, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
// unitBadParent: плохой родитель + хороший ребёнок
|
||||||
|
new UnitInUnit { ParentUnitId = badParent, ChildUnitId = unitBadParent, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = unitBadParent, ChildUnitId = goodChild, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
// unitBadChild: хороший родитель + плохой ребёнок
|
||||||
|
new UnitInUnit { ParentUnitId = goodParent, ChildUnitId = unitBadChild, DateCreated = DateTimeOffset.UtcNow },
|
||||||
|
new UnitInUnit { ParentUnitId = unitBadChild, ChildUnitId = badChild, DateCreated = DateTimeOffset.UtcNow }
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
SetupMocks(context);
|
||||||
|
|
||||||
|
var filters = new List<JobRelationshipFilter>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = parentFieldId,
|
||||||
|
ValueMask = "%GOOD-PARENT%",
|
||||||
|
IsParent = true,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
FieldId = childFieldId,
|
||||||
|
ValueMask = "%GOOD-CHILD%",
|
||||||
|
IsParent = false,
|
||||||
|
IsInverse = false,
|
||||||
|
IsFullMatch = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.MatchAsync(
|
||||||
|
new List<Guid> { unitAllGood, unitBadParent, unitBadChild }, filters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var resultIds = result.Select(c => c.UnitId).ToList();
|
||||||
|
Assert.Contains(unitAllGood, resultIds);
|
||||||
|
Assert.DoesNotContain(unitBadParent, resultIds);
|
||||||
|
Assert.DoesNotContain(unitBadChild, resultIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,6 @@ public class UnitFilterServiceTests
|
|||||||
{
|
{
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создаёт тестовую сущность Unit с заданным значением поля.
|
|
||||||
/// Используется для подготовки данных в тестах фильтрации.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">Уникальный идентификатор юнита</param>
|
|
||||||
/// <param name="name">Имя юнита для отладки</param>
|
|
||||||
/// <param name="fieldId">Идентификатор поля, к которому привязывается значение</param>
|
|
||||||
/// <param name="fieldValue">Значение поля</param>
|
|
||||||
private static Unit BuildTestUnit(Guid id, string name, Guid fieldId, string fieldValue)
|
private static Unit BuildTestUnit(Guid id, string name, Guid fieldId, string fieldValue)
|
||||||
{
|
{
|
||||||
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
|
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
|
||||||
@@ -49,10 +41,6 @@ public class UnitFilterServiceTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создаёт тестовый объект JobGroupType с кодом Umbrella.
|
|
||||||
/// Используется для тестирования логики групповых работ типа "зонтик".
|
|
||||||
/// </summary>
|
|
||||||
private static JobGroupType BuildTestJobGroupType() => new()
|
private static JobGroupType BuildTestJobGroupType() => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -61,10 +49,6 @@ public class UnitFilterServiceTests
|
|||||||
Description = "Test"
|
Description = "Test"
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создаёт тестовый объект JobGroup с заполненными обязательными полями.
|
|
||||||
/// Используется для подготовки навигационных свойств в тестах.
|
|
||||||
/// </summary>
|
|
||||||
private static JobGroup BuildTestJobGroup() => new()
|
private static JobGroup BuildTestJobGroup() => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -79,20 +63,12 @@ public class UnitFilterServiceTests
|
|||||||
GroupType = BuildTestJobGroupType()
|
GroupType = BuildTestJobGroupType()
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создаёт тестовый объект JobAutoControl с отключённым автоконтролем.
|
|
||||||
/// Используется для заполнения обязательного свойства в сущности Job.
|
|
||||||
/// </summary>
|
|
||||||
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()
|
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()
|
||||||
{
|
{
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
IsEnable = false
|
IsEnable = false
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Контейнер для моков репозиториев.
|
|
||||||
/// Упрощает передачу зависимостей в метод создания сервиса.
|
|
||||||
/// </summary>
|
|
||||||
private class RepositoryMocks
|
private class RepositoryMocks
|
||||||
{
|
{
|
||||||
public Mock<IUnitRepository> Unit { get; set; }
|
public Mock<IUnitRepository> Unit { get; set; }
|
||||||
@@ -101,7 +77,6 @@ public class UnitFilterServiceTests
|
|||||||
public Mock<IUnitFieldRepository> UnitField { get; set; }
|
public Mock<IUnitFieldRepository> UnitField { get; set; }
|
||||||
public Mock<IJobRepository> Job { get; set; }
|
public Mock<IJobRepository> Job { get; set; }
|
||||||
public Mock<IRedisCacheService> Cache { get; set; }
|
public Mock<IRedisCacheService> Cache { get; set; }
|
||||||
// Новые зависимости после рефакторинга
|
|
||||||
public Mock<IUnitService> UnitService { get; set; }
|
public Mock<IUnitService> UnitService { get; set; }
|
||||||
public Mock<IUnitFieldMatcher> FieldMatcher { get; set; }
|
public Mock<IUnitFieldMatcher> FieldMatcher { get; set; }
|
||||||
public Mock<IUnitRelationshipMatcher> RelationshipMatcher { get; set; }
|
public Mock<IUnitRelationshipMatcher> RelationshipMatcher { get; set; }
|
||||||
@@ -110,11 +85,6 @@ public class UnitFilterServiceTests
|
|||||||
public Mock<IUnitNameResolver> NameResolver { get; set; }
|
public Mock<IUnitNameResolver> NameResolver { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Настраивает базовые моки репозиториев с использованием InMemory-контекста.
|
|
||||||
/// Возвращает объект с подготовленными моками для повторного использования в тестах.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">Экземпляр DataContext с тестовыми данными</param>
|
|
||||||
private static RepositoryMocks ArrangeRepositoryMocks(DataContext context)
|
private static RepositoryMocks ArrangeRepositoryMocks(DataContext context)
|
||||||
{
|
{
|
||||||
var unitRepoMock = new Mock<IUnitRepository>();
|
var unitRepoMock = new Mock<IUnitRepository>();
|
||||||
@@ -126,16 +96,6 @@ public class UnitFilterServiceTests
|
|||||||
unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable());
|
unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable());
|
||||||
|
|
||||||
var unitInValueRepoMock = new Mock<IUnitInValueRepository>();
|
var unitInValueRepoMock = new Mock<IUnitInValueRepository>();
|
||||||
// Единый подход: используем контекст для поддержки IAsyncQueryProvider
|
|
||||||
// Это гарантирует корректную работу .ToListAsync() внутри сервиса
|
|
||||||
unitInValueRepoMock.Setup(r => r.GetMatchingTargetIds(It.IsAny<Guid>(), It.IsAny<string>()))
|
|
||||||
.Returns((Guid fId, string mask) =>
|
|
||||||
context.UnitInValues
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(uv => uv.FieldId == fId && uv.Value != null)
|
|
||||||
.Select(uv => uv.UnitId)
|
|
||||||
.Distinct()
|
|
||||||
.AsQueryable());
|
|
||||||
|
|
||||||
var unitFieldRepoMock = new Mock<IUnitFieldRepository>();
|
var unitFieldRepoMock = new Mock<IUnitFieldRepository>();
|
||||||
unitFieldRepoMock.Setup(r => r.Get()).Returns(new List<UnitField>().AsQueryable());
|
unitFieldRepoMock.Setup(r => r.Get()).Returns(new List<UnitField>().AsQueryable());
|
||||||
@@ -154,32 +114,45 @@ public class UnitFilterServiceTests
|
|||||||
var resultLoaderMock = new Mock<IUnitFilterResultLoader>();
|
var resultLoaderMock = new Mock<IUnitFilterResultLoader>();
|
||||||
var nameResolverMock = new Mock<IUnitNameResolver>();
|
var nameResolverMock = new Mock<IUnitNameResolver>();
|
||||||
|
|
||||||
// Настройка NameResolver: возвращаем все ID юнитов из контекста (эмуляция кэш-промаха + БД)
|
|
||||||
nameResolverMock.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
nameResolverMock.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((string mask, CancellationToken ct) =>
|
.ReturnsAsync((string mask, CancellationToken ct) =>
|
||||||
context.Units.Select(u => u.Id).ToList());
|
context.Units.Select(u => u.Id).ToList());
|
||||||
|
|
||||||
// Настройка FieldMatcher: эмуляция SQL-фильтрации через InMemory-контекст
|
// По умолчанию FieldMatcher пропускает все юниты без изменений.
|
||||||
fieldMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobFieldFilter>>(), It.IsAny<CancellationToken>()))
|
// В конкретных тестах переопределяется для эмуляции фильтрации.
|
||||||
|
fieldMatcherMock.Setup(r => r.MatchAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
It.IsAny<IEnumerable<JobFieldFilter>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
|
||||||
ids.ToList());
|
ids.ToList());
|
||||||
|
|
||||||
// Настройка RelationshipMatcher: возвращаем контексты без изменений
|
// По умолчанию RelationshipMatcher возвращает контексты без изменений.
|
||||||
relationshipMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobRelationshipFilter>>(), It.IsAny<CancellationToken>()))
|
// В конкретных тестах переопределяется для эмуляции фильтрации связей.
|
||||||
|
relationshipMatcherMock.Setup(r => r.MatchAsync(
|
||||||
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
|
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||||
ids.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList());
|
ids.Select(id => new UnitFilterMatchResult
|
||||||
|
{
|
||||||
|
UnitId = id,
|
||||||
|
ValidParentIds = new HashSet<Guid>(),
|
||||||
|
ValidChildIds = new HashSet<Guid>()
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
// Настройка UmbrellaFilter: пропускаем без изменений
|
|
||||||
umbrellaFilterMock.Setup(r => r.Apply(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<Job>()))
|
umbrellaFilterMock.Setup(r => r.Apply(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<Job>()))
|
||||||
.Returns((List<UnitFilterMatchResult> ctx, Job j) => ctx);
|
.Returns((List<UnitFilterMatchResult> ctx, Job j) => ctx);
|
||||||
|
|
||||||
// Настройка ResultLoader: формируем DTO из контекста
|
|
||||||
resultLoaderMock.Setup(r => r.LoadAsync(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<CancellationToken>()))
|
resultLoaderMock.Setup(r => r.LoadAsync(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((List<UnitFilterMatchResult> contexts, CancellationToken ct) =>
|
.ReturnsAsync((List<UnitFilterMatchResult> contexts, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var unitIds = contexts.Select(c => c.UnitId).ToHashSet();
|
var unitIds = contexts.Select(c => c.UnitId).ToHashSet();
|
||||||
var units = context.Units.Where(u => unitIds.Contains(u.Id)).ToList();
|
var units = context.Units.Where(u => unitIds.Contains(u.Id)).ToList();
|
||||||
return units.Select(u => new UnitFilterResultDto
|
return units.Select(u =>
|
||||||
|
{
|
||||||
|
var ctx = contexts.First(c => c.UnitId == u.Id);
|
||||||
|
return new UnitFilterResultDto
|
||||||
{
|
{
|
||||||
Id = u.Id,
|
Id = u.Id,
|
||||||
Name = u.Name,
|
Name = u.Name,
|
||||||
@@ -188,12 +161,13 @@ public class UnitFilterServiceTests
|
|||||||
FieldId = v.FieldId,
|
FieldId = v.FieldId,
|
||||||
Value = v.Value?.Value
|
Value = v.Value?.Value
|
||||||
}).ToList() ?? new List<UnitValueDto>(),
|
}).ToList() ?? new List<UnitValueDto>(),
|
||||||
Parents = contexts.First(c => c.UnitId == u.Id).ValidParentIds
|
Parents = (ctx.ValidParentIds ?? new HashSet<Guid>())
|
||||||
.Select(pid => new RelatedUnitDto { UnitId = pid })
|
.Select(pid => new RelatedUnitDto { UnitId = pid })
|
||||||
.ToList(),
|
.ToList(),
|
||||||
Children = contexts.First(c => c.UnitId == u.Id).ValidChildIds
|
Children = (ctx.ValidChildIds ?? new HashSet<Guid>())
|
||||||
.Select(cid => new RelatedUnitDto { UnitId = cid })
|
.Select(cid => new RelatedUnitDto { UnitId = cid })
|
||||||
.ToList()
|
.ToList()
|
||||||
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,12 +188,6 @@ public class UnitFilterServiceTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создаёт экземпляр тестируемого сервиса (SUT — System Under Test).
|
|
||||||
/// Инкапсулирует логику конструктора для упрощения тестов.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mocks">Подготовленные моки репозиториев</param>
|
|
||||||
/// <param name="logger">Экземпляр логгера для сервиса</param>
|
|
||||||
private static Core.Services.UnitFilterService.UnitFilterService CreateSut(
|
private static Core.Services.UnitFilterService.UnitFilterService CreateSut(
|
||||||
RepositoryMocks mocks,
|
RepositoryMocks mocks,
|
||||||
ILogger<Core.Services.UnitFilterService.UnitFilterService> logger)
|
ILogger<Core.Services.UnitFilterService.UnitFilterService> logger)
|
||||||
@@ -245,8 +213,9 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter)
|
/// Проверяет, что юниты, не прошедшие фильтрацию по полю или по связям,
|
||||||
/// или по связям (RelationshipFilter), корректно исключаются из результата.
|
/// корректно исключаются из результата.
|
||||||
|
/// Логика фильтрации эмулируется через моки FieldMatcher и RelationshipMatcher.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
|
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
|
||||||
@@ -260,30 +229,24 @@ public class UnitFilterServiceTests
|
|||||||
var jobId = Guid.NewGuid();
|
var jobId = Guid.NewGuid();
|
||||||
var fieldId = Guid.NewGuid();
|
var fieldId = Guid.NewGuid();
|
||||||
var validParentId = Guid.NewGuid();
|
var validParentId = Guid.NewGuid();
|
||||||
var wrongParentId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var unitPassesAll = Guid.NewGuid();
|
var unitPassesAll = Guid.NewGuid();
|
||||||
var unitFailsFieldFilter = Guid.NewGuid();
|
var unitFailsFieldFilter = Guid.NewGuid();
|
||||||
var unitFailsRelationshipFilter = Guid.NewGuid();
|
var unitFailsRelationshipFilter = Guid.NewGuid();
|
||||||
|
|
||||||
// Подготовка тестовых данных: юниты
|
|
||||||
context.Units.AddRange(
|
context.Units.AddRange(
|
||||||
BuildTestUnit(unitPassesAll, "Unit_PassesAll", fieldId, "Accepted Value"),
|
BuildTestUnit(unitPassesAll, "Unit_PassesAll", fieldId, "Accepted Value"),
|
||||||
BuildTestUnit(unitFailsFieldFilter, "Unit_FailsField", fieldId, "Rejected Value"),
|
BuildTestUnit(unitFailsFieldFilter, "Unit_FailsField", fieldId, "Rejected Value"),
|
||||||
BuildTestUnit(unitFailsRelationshipFilter, "Unit_FailsRel", fieldId, "Accepted Value"),
|
BuildTestUnit(unitFailsRelationshipFilter, "Unit_FailsRel", fieldId, "Accepted Value"),
|
||||||
BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent"),
|
BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent")
|
||||||
BuildTestUnit(wrongParentId, "Parent_Wrong", fieldId, "Wrong Parent")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подготовка тестовых данных: связи между юнитами
|
|
||||||
context.UnitInUnits.AddRange(
|
context.UnitInUnits.AddRange(
|
||||||
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow }
|
||||||
new UnitInUnit { ParentUnitId = wrongParentId, ChildUnitId = unitFailsRelationshipFilter, DateCreated = DateTimeOffset.UtcNow }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Формируем тестовый объект Job с фильтрами
|
|
||||||
var job = new Job
|
var job = new Job
|
||||||
{
|
{
|
||||||
Id = jobId,
|
Id = jobId,
|
||||||
@@ -318,20 +281,29 @@ public class UnitFilterServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Настройка моков репозиториев
|
|
||||||
var mocks = ArrangeRepositoryMocks(context);
|
var mocks = ArrangeRepositoryMocks(context);
|
||||||
|
|
||||||
// Переопределяем мок фильтрации по полю: в реальном коде используется EF.Functions.ILike,
|
// Эмуляция FieldMatcher: пропускает только юниты со значением "Accepted Value"
|
||||||
// в тесте заменяем на прямое сравнение строк для предсказуемости
|
mocks.FieldMatcher.Setup(r => r.MatchAsync(
|
||||||
mocks.Unit.Setup(r => r.GetUnitByFieldAndValue(It.IsAny<IQueryable<Unit>>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<bool>()))
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
.Returns((IQueryable<Unit> q, Guid fId, string mask, bool inv) =>
|
It.IsAny<IEnumerable<JobFieldFilter>>(),
|
||||||
q.Where(u => u.UnitValues != null && u.UnitValues.Any(v => v.FieldId == fId && v.Value != null && v.Value.Value == "Accepted Value")));
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
|
||||||
|
ids.Where(id => id == unitPassesAll || id == unitFailsRelationshipFilter).ToList());
|
||||||
|
|
||||||
// ИСПРАВЛЕНО: используем запрос к контексту вместо массива.
|
// Эмуляция RelationshipMatcher: пропускает только unitPassesAll
|
||||||
// Массивный AsQueryable() не поддерживает IAsyncQueryProvider, что вызывает крах при вызове .ToListAsync() внутри сервиса.
|
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
|
||||||
// Запрос к InMemory DbSet гарантирует корректную асинхронную материализацию.
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>()))
|
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
|
||||||
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||||
|
ids.Where(id => id == unitPassesAll)
|
||||||
|
.Select(id => new UnitFilterMatchResult
|
||||||
|
{
|
||||||
|
UnitId = id,
|
||||||
|
ValidParentIds = new HashSet<Guid> { validParentId },
|
||||||
|
ValidChildIds = new HashSet<Guid>()
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||||
var service = CreateSut(mocks, logger);
|
var service = CreateSut(mocks, logger);
|
||||||
@@ -354,8 +326,8 @@ public class UnitFilterServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет, что юниты исключаются, если их единственный родительский юнит
|
/// Проверяет, что юниты исключаются, если их родитель содержит запрещённое значение (IsInverse = true).
|
||||||
/// содержит запрещённое значение тега (сценарий с IsInverse = true).
|
/// Логика фильтрации связей эмулируется через мок RelationshipMatcher.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
|
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
|
||||||
@@ -373,7 +345,6 @@ public class UnitFilterServiceTests
|
|||||||
var unitWithValidParent = Guid.NewGuid();
|
var unitWithValidParent = Guid.NewGuid();
|
||||||
var unitWithInvalidParent = Guid.NewGuid();
|
var unitWithInvalidParent = Guid.NewGuid();
|
||||||
|
|
||||||
// Подготовка тестовых данных: юниты
|
|
||||||
context.Units.AddRange(
|
context.Units.AddRange(
|
||||||
BuildTestUnit(validParentId, "Parent_Valid", tagFieldId, "ОТВ.ЭК"),
|
BuildTestUnit(validParentId, "Parent_Valid", tagFieldId, "ОТВ.ЭК"),
|
||||||
BuildTestUnit(invalidParentId, "Parent_Invalid", tagFieldId, "ПАРР-РРПТК-ОТВ.ЭК"),
|
BuildTestUnit(invalidParentId, "Parent_Invalid", tagFieldId, "ПАРР-РРПТК-ОТВ.ЭК"),
|
||||||
@@ -381,7 +352,6 @@ public class UnitFilterServiceTests
|
|||||||
BuildTestUnit(unitWithInvalidParent, "Unit_Invalid", Guid.NewGuid(), "Val")
|
BuildTestUnit(unitWithInvalidParent, "Unit_Invalid", Guid.NewGuid(), "Val")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подготовка тестовых данных: связи между юнитами
|
|
||||||
context.UnitInUnits.AddRange(
|
context.UnitInUnits.AddRange(
|
||||||
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitWithValidParent, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitWithValidParent, DateCreated = DateTimeOffset.UtcNow },
|
||||||
new UnitInUnit { ParentUnitId = invalidParentId, ChildUnitId = unitWithInvalidParent, DateCreated = DateTimeOffset.UtcNow }
|
new UnitInUnit { ParentUnitId = invalidParentId, ChildUnitId = unitWithInvalidParent, DateCreated = DateTimeOffset.UtcNow }
|
||||||
@@ -389,8 +359,6 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Формируем тестовый объект Job с настройкой IsInverse = true
|
|
||||||
// IsInverse = true означает: исключить родителей, которые СОВПАДАЮТ с маской
|
|
||||||
var job = new Job
|
var job = new Job
|
||||||
{
|
{
|
||||||
Id = jobId,
|
Id = jobId,
|
||||||
@@ -430,20 +398,21 @@ public class UnitFilterServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Настройка моков репозиториев
|
|
||||||
var mocks = ArrangeRepositoryMocks(context);
|
var mocks = ArrangeRepositoryMocks(context);
|
||||||
|
|
||||||
// Переопределяем мок: возвращаем только родителей с запрещённым тегом.
|
// Эмуляция RelationshipMatcher: исключает unitWithInvalidParent
|
||||||
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
|
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
|
||||||
// IsInverse = true в сервисе инвертирует результат, поэтому эти родители будут исключены.
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
|
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
|
||||||
.Returns((Guid fId, string mask) =>
|
It.IsAny<CancellationToken>()))
|
||||||
context.UnitInValues
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||||
.AsNoTracking()
|
ids.Where(id => id == unitWithValidParent)
|
||||||
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("ПАРР-РРПТК-ОТВ.ЭК", StringComparison.OrdinalIgnoreCase))
|
.Select(id => new UnitFilterMatchResult
|
||||||
.Select(uv => uv.UnitId)
|
{
|
||||||
.Distinct()
|
UnitId = id,
|
||||||
.AsQueryable());
|
ValidParentIds = new HashSet<Guid> { validParentId },
|
||||||
|
ValidChildIds = new HashSet<Guid>()
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||||
var service = CreateSut(mocks, logger);
|
var service = CreateSut(mocks, logger);
|
||||||
@@ -460,7 +429,7 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет логику IsFullMatch = true: юнит исключается,
|
/// Проверяет логику IsFullMatch = true: юнит исключается,
|
||||||
/// если хотя бы один из его родительских юнитов не соответствует фильтру.
|
/// если хотя бы один из его родителей не соответствует фильтру.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
|
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
|
||||||
@@ -478,7 +447,6 @@ public class UnitFilterServiceTests
|
|||||||
var unitMixedId = Guid.NewGuid();
|
var unitMixedId = Guid.NewGuid();
|
||||||
var unitCleanId = Guid.NewGuid();
|
var unitCleanId = Guid.NewGuid();
|
||||||
|
|
||||||
// Подготовка тестовых данных: юниты
|
|
||||||
context.Units.AddRange(
|
context.Units.AddRange(
|
||||||
BuildTestUnit(parentGoodId, "Parent_Good", checkFieldId, "Good Parent"),
|
BuildTestUnit(parentGoodId, "Parent_Good", checkFieldId, "Good Parent"),
|
||||||
BuildTestUnit(parentBadId, "Parent_Bad", checkFieldId, "Bad Parent"),
|
BuildTestUnit(parentBadId, "Parent_Bad", checkFieldId, "Bad Parent"),
|
||||||
@@ -486,7 +454,6 @@ public class UnitFilterServiceTests
|
|||||||
BuildTestUnit(unitCleanId, "Unit_Clean", Guid.NewGuid(), "Clean")
|
BuildTestUnit(unitCleanId, "Unit_Clean", Guid.NewGuid(), "Clean")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подготовка тестовых данных: связи между юнитами
|
|
||||||
context.UnitInUnits.AddRange(
|
context.UnitInUnits.AddRange(
|
||||||
new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
||||||
new UnitInUnit { ParentUnitId = parentBadId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = parentBadId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
||||||
@@ -495,8 +462,6 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Формируем тестовый объект Job с настройкой IsFullMatch = true
|
|
||||||
// IsFullMatch = true означает: ВСЕ родители должны соответствовать фильтру
|
|
||||||
var job = new Job
|
var job = new Job
|
||||||
{
|
{
|
||||||
Id = jobId,
|
Id = jobId,
|
||||||
@@ -536,19 +501,21 @@ public class UnitFilterServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Настройка моков репозиториев
|
|
||||||
var mocks = ArrangeRepositoryMocks(context);
|
var mocks = ArrangeRepositoryMocks(context);
|
||||||
|
|
||||||
// Переопределяем мок: возвращаем только родителей, соответствующих маске "Good".
|
// Эмуляция RelationshipMatcher: исключает unitMixedId (один из родителей не проходит)
|
||||||
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
|
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
|
||||||
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(checkFieldId, It.IsAny<string>()))
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
.Returns((Guid fId, string mask) =>
|
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
|
||||||
context.UnitInValues
|
It.IsAny<CancellationToken>()))
|
||||||
.AsNoTracking()
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||||
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("Good", StringComparison.OrdinalIgnoreCase))
|
ids.Where(id => id == unitCleanId)
|
||||||
.Select(uv => uv.UnitId)
|
.Select(id => new UnitFilterMatchResult
|
||||||
.Distinct()
|
{
|
||||||
.AsQueryable());
|
UnitId = id,
|
||||||
|
ValidParentIds = new HashSet<Guid> { parentGoodId },
|
||||||
|
ValidChildIds = new HashSet<Guid>()
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||||
var service = CreateSut(mocks, logger);
|
var service = CreateSut(mocks, logger);
|
||||||
@@ -563,10 +530,9 @@ public class UnitFilterServiceTests
|
|||||||
Assert.DoesNotContain(unitMixedId, resultIds);
|
Assert.DoesNotContain(unitMixedId, resultIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true.
|
/// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true.
|
||||||
/// Логика: юнит проходит, только если НИ ОДИН из его родителей не содержит запрещённое значение.
|
/// Юнит проходит, только если ни один из его родителей не содержит запрещённое значение.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded()
|
public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded()
|
||||||
@@ -579,12 +545,11 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
var jobId = Guid.NewGuid();
|
var jobId = Guid.NewGuid();
|
||||||
var tagFieldId = Guid.NewGuid();
|
var tagFieldId = Guid.NewGuid();
|
||||||
var parentForbiddenId = Guid.NewGuid(); // Родитель с запрещённым тегом
|
var parentForbiddenId = Guid.NewGuid();
|
||||||
var parentCleanId = Guid.NewGuid(); // Родитель без запрещённого тега
|
var parentCleanId = Guid.NewGuid();
|
||||||
var unitMixedId = Guid.NewGuid(); // Юнит с одним "плохим" и одним "хорошим" родителем
|
var unitMixedId = Guid.NewGuid();
|
||||||
var unitAllCleanId = Guid.NewGuid(); // Юнит только с "хорошими" родителями
|
var unitAllCleanId = Guid.NewGuid();
|
||||||
|
|
||||||
// Подготовка тестовых данных: юниты
|
|
||||||
context.Units.AddRange(
|
context.Units.AddRange(
|
||||||
BuildTestUnit(parentForbiddenId, "Parent_Forbidden", tagFieldId, "FORBIDDEN-TAG"),
|
BuildTestUnit(parentForbiddenId, "Parent_Forbidden", tagFieldId, "FORBIDDEN-TAG"),
|
||||||
BuildTestUnit(parentCleanId, "Parent_Clean", tagFieldId, "CLEAN-TAG"),
|
BuildTestUnit(parentCleanId, "Parent_Clean", tagFieldId, "CLEAN-TAG"),
|
||||||
@@ -592,9 +557,6 @@ public class UnitFilterServiceTests
|
|||||||
BuildTestUnit(unitAllCleanId, "Unit_AllClean", Guid.NewGuid(), "AllClean")
|
BuildTestUnit(unitAllCleanId, "Unit_AllClean", Guid.NewGuid(), "AllClean")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подготовка тестовых данных: связи
|
|
||||||
// unitMixed имеет обоих родителей — один с запрещённым тегом, один без
|
|
||||||
// unitAllClean имеет только "чистого" родителя
|
|
||||||
context.UnitInUnits.AddRange(
|
context.UnitInUnits.AddRange(
|
||||||
new UnitInUnit { ParentUnitId = parentForbiddenId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = parentForbiddenId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
||||||
new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
|
||||||
@@ -603,10 +565,6 @@ public class UnitFilterServiceTests
|
|||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Формируем тестовый объект Job с комбинацией флагов:
|
|
||||||
// IsParent = true: проверяем родительские связи
|
|
||||||
// IsInverse = true: исключаем родителей, которые СОВПАДАЮТ с маской
|
|
||||||
// IsFullMatch = true: ВСЕ родители должны пройти фильтр (ни один не должен совпасть с маской)
|
|
||||||
var job = new Job
|
var job = new Job
|
||||||
{
|
{
|
||||||
Id = jobId,
|
Id = jobId,
|
||||||
@@ -646,21 +604,21 @@ public class UnitFilterServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Настройка моков репозиториев
|
|
||||||
var mocks = ArrangeRepositoryMocks(context);
|
var mocks = ArrangeRepositoryMocks(context);
|
||||||
|
|
||||||
// Переопределяем мок: возвращаем только родителей, содержащих "FORBIDDEN".
|
// Эмуляция RelationshipMatcher: исключает unitMixedId (есть родитель с запрещённым тегом)
|
||||||
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
|
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
|
||||||
// Из-за IsInverse=true эти родители будут исключены из "валидных".
|
It.IsAny<IReadOnlyList<Guid>>(),
|
||||||
// Из-за IsFullMatch=true юнит пройдёт, только если ВСЕ его родители валидны.
|
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
|
||||||
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.Returns((Guid fId, string mask) =>
|
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||||
context.UnitInValues
|
ids.Where(id => id == unitAllCleanId)
|
||||||
.AsNoTracking()
|
.Select(id => new UnitFilterMatchResult
|
||||||
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("FORBIDDEN", StringComparison.OrdinalIgnoreCase))
|
{
|
||||||
.Select(uv => uv.UnitId)
|
UnitId = id,
|
||||||
.Distinct()
|
ValidParentIds = new HashSet<Guid> { parentCleanId },
|
||||||
.AsQueryable());
|
ValidChildIds = new HashSet<Guid>()
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||||
var service = CreateSut(mocks, logger);
|
var service = CreateSut(mocks, logger);
|
||||||
@@ -671,15 +629,7 @@ public class UnitFilterServiceTests
|
|||||||
// === Assert ===
|
// === Assert ===
|
||||||
var resultIds = result.Select(u => u.Id).ToList();
|
var resultIds = result.Select(u => u.Id).ToList();
|
||||||
|
|
||||||
// unitAllCleanId должен остаться: у него один родитель, и он не содержит запрещённый тег
|
|
||||||
Assert.Contains(unitAllCleanId, resultIds);
|
Assert.Contains(unitAllCleanId, resultIds);
|
||||||
|
|
||||||
// unitMixedId должен быть исключён: у него есть родитель с запрещённым тегом.
|
|
||||||
// Логика:
|
|
||||||
// 1. GetMatchingTargetIds возвращает {parentForbiddenId}
|
|
||||||
// 2. IsInverse=true → валидные родители = все.Кроме({parentForbiddenId}) = {parentCleanId}
|
|
||||||
// 3. IsFullMatch=true → проверяем: все родители {parentForbiddenId, parentCleanId} входят в {parentCleanId}? Нет.
|
|
||||||
// 4. Юнит исключается.
|
|
||||||
Assert.DoesNotContain(unitMixedId, resultIds);
|
Assert.DoesNotContain(unitMixedId, resultIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,16 +11,18 @@ namespace PARR.DAL.Repositories.Unit
|
|||||||
{
|
{
|
||||||
public UnitFieldValueRepository(DataContext dataContext, ILogger<UnitFieldValueRepository> logger) : base(logger, dataContext) { }
|
public UnitFieldValueRepository(DataContext dataContext, ILogger<UnitFieldValueRepository> logger) : base(logger, dataContext) { }
|
||||||
|
|
||||||
|
public async Task<List<Guid>> FindValueIdsByMaskAsync(
|
||||||
public async Task<UnitFieldValue?> GetByValueNameAsync(string? value)
|
string mask,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = EntitySet
|
var query = EntitySet.AsNoTracking();
|
||||||
.Include(v => v.FieldValues);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
var valueIds = await query
|
||||||
return await query.FirstOrDefaultAsync(uf => uf.Value == null);
|
.Where(v => EF.Functions.ILike(v.Value!, mask))
|
||||||
|
.Select(v => v.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync(uf => uf.Value!.ToLower().Trim() == value.ToLower().Trim());
|
return valueIds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ namespace PARR.DAL.Repositories.Unit
|
|||||||
{
|
{
|
||||||
public UnitRepository(DataContext dataContext, ILogger<UnitRepository> logger) : base(logger, dataContext) { }
|
public UnitRepository(DataContext dataContext, ILogger<UnitRepository> logger) : base(logger, dataContext) { }
|
||||||
|
|
||||||
|
|
||||||
public IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes()
|
public IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes()
|
||||||
{
|
{
|
||||||
return Get()
|
return Get()
|
||||||
@@ -22,40 +21,30 @@ namespace PARR.DAL.Repositories.Unit
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false)
|
|
||||||
{
|
|
||||||
//TODO: вынесено из UnitFilterService
|
|
||||||
|
|
||||||
if (isInverse)
|
|
||||||
{
|
|
||||||
query = query.Where(u => !u.UnitValues.Any(v =>
|
|
||||||
v.FieldId == fieldId &&
|
|
||||||
EF.Functions.ILike(v.Value.Value, valueMask)));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
query = query.Where(u => u.UnitValues.Any(v =>
|
|
||||||
v.FieldId == fieldId &&
|
|
||||||
EF.Functions.ILike(v.Value.Value, valueMask)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IQueryable<Guid> GetInitialUnitIds(string dbValueMask)
|
public IQueryable<Guid> GetInitialUnitIds(string dbValueMask)
|
||||||
{
|
{
|
||||||
//TODO: вынесено из UnitFilterService
|
|
||||||
|
|
||||||
//var initialUnitIds = await unitService.Get().AsNoTracking()
|
|
||||||
//.Where(unit => EF.Functions.ILike(unit.Name, dbValueMask))
|
|
||||||
//.Select(u => u.Id)
|
|
||||||
//.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Get().AsNoTracking()
|
return Get().AsNoTracking()
|
||||||
.Where(unit => EF.Functions.ILike(unit.Name, dbValueMask))
|
.Where(unit => EF.Functions.ILike(unit.Name, dbValueMask))
|
||||||
.Select(u => u.Id);
|
.Select(u => u.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<List<Guid>> FindUnitIdsByValueIdsAsync(
|
||||||
|
IReadOnlyList<Guid> unitIds,
|
||||||
|
Guid fieldId,
|
||||||
|
IReadOnlyList<Guid> valueIds,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (valueIds.Count == 0)
|
||||||
|
return new List<Guid>();
|
||||||
|
|
||||||
|
return await EntitySet.AsNoTracking()
|
||||||
|
.Where(u => unitIds.Contains(u.Id))
|
||||||
|
.Where(u => u.UnitValues.Any(uv =>
|
||||||
|
uv.FieldId == fieldId &&
|
||||||
|
valueIds.Contains(uv.ValueId)))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using PARR.Core.Common.Interfaces;
|
using PARR.Core.Common.Interfaces;
|
||||||
using PARR.Core.Repositories.Interfaces.JobGroupRepositories;
|
using PARR.Core.Repositories.Interfaces.JobGroupRepositories;
|
||||||
|
using PARR.Core.Repositories.Interfaces.JobRepositories;
|
||||||
using PARR.Domain.Entities.Base.History;
|
using PARR.Domain.Entities.Base.History;
|
||||||
|
using PARR.Domain.Entities.JobEntities;
|
||||||
using PARR.Domain.Enums;
|
using PARR.Domain.Enums;
|
||||||
using PARR.EsppApi;
|
using PARR.EsppApi;
|
||||||
using PARR.EsppApi.Constants;
|
using PARR.EsppApi.Constants;
|
||||||
@@ -181,7 +182,7 @@ namespace PARR.Test
|
|||||||
{
|
{
|
||||||
//Guid.Parse("cfb0d2dd-192a-4ed5-a29c-25f0abf61895"),//11010
|
//Guid.Parse("cfb0d2dd-192a-4ed5-a29c-25f0abf61895"),//11010
|
||||||
//Guid.Parse("79976eaf-be7b-42a0-a4e3-a89d7f68a18e"),//17
|
//Guid.Parse("79976eaf-be7b-42a0-a4e3-a89d7f68a18e"),//17
|
||||||
Guid.Parse("6ff1de05-80c3-4b38-846b-0c793fd7fc8c")//318
|
Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a")//318
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!jobIds.Any())
|
if (!jobIds.Any())
|
||||||
@@ -235,13 +236,7 @@ namespace PARR.Test
|
|||||||
var templateMatcher = scope.ServiceProvider.GetRequiredService<ITemplateMatcher>();
|
var templateMatcher = scope.ServiceProvider.GetRequiredService<ITemplateMatcher>();
|
||||||
var groupRepository = scope.ServiceProvider.GetRequiredService<IJobGroupRepository>();
|
var groupRepository = scope.ServiceProvider.GetRequiredService<IJobGroupRepository>();
|
||||||
|
|
||||||
var groups = await groupRepository.Get().AsNoTracking()
|
await templateMatcher.SyncTemplatesForJob(Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a"),
|
||||||
.Where(t => t.GroupType != null && t.GroupType.Code == JobGroupTypesEnum.Group)
|
|
||||||
.Select(t => t.Id).ToHashSetAsync();
|
|
||||||
|
|
||||||
foreach (var groupId in groups)
|
|
||||||
{
|
|
||||||
await templateMatcher.SyncTemplatesForJobGroup(groupId,
|
|
||||||
new HistoryInitiator
|
new HistoryInitiator
|
||||||
{
|
{
|
||||||
InitiatorIp = "10.99.246.156",
|
InitiatorIp = "10.99.246.156",
|
||||||
@@ -250,7 +245,24 @@ namespace PARR.Test
|
|||||||
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
//var groups = await groupRepository.Get().AsNoTracking()
|
||||||
|
// .Where(t => t.GroupType != null && t.GroupType.Code == JobGroupTypesEnum.Group)
|
||||||
|
// .Select(t => t.Id).ToHashSetAsync();
|
||||||
|
|
||||||
|
|
||||||
|
//foreach (var groupId in groups)
|
||||||
|
//{
|
||||||
|
// await templateMatcher.SyncTemplatesForJobGroup(groupId,
|
||||||
|
// new HistoryInitiator
|
||||||
|
// {
|
||||||
|
// InitiatorIp = "10.99.246.156",
|
||||||
|
// InitiatorParrComponentId = ParrComponentsEnum.Master,
|
||||||
|
// InitiatorComment = "Тестовый проект. Синхронизация Группированых работ"
|
||||||
|
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
//}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Debug",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user