fix(templateMatcher): Исправление ошибок в именах шаблонов и рефакторинг синхронизации

- Устранена ошибка, когда при переиспользовании шаблона для шорткодов передавался ЭК "КОМПЛЕКСЫ-[ЗО]" вместо целевого ЭК шаблона.
- Проведен небольшой рефакторинг общих процессов синхронизации различных типов групп работ.
This commit is contained in:
Mikhail Kuznetsov
2026-05-20 17:59:06 +10:00
parent c0148088c1
commit 513e5f869a
26 changed files with 995 additions and 638 deletions

View File

@@ -1,27 +0,0 @@
using Microsoft.Extensions.Logging;
using Moq;
using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.UnitFilterService;
using PARR.Domain.Settings;
namespace PARR.DAL.Tests.DomainServices.Implementations
{
public class ShortcodesServiceTests
{
private readonly Mock<ILogger<ShortcodesService>> loggerMock = new();
private readonly Mock<SettingsFromDb> settingsMock = new();
private readonly Mock<IJobRepository> jobServiceMock = new();
private readonly Mock<IUnitRepository> unitServiceMock = new();
private readonly Mock<IUnitInValueRepository> unitInValueServiceMock = new();
private readonly Mock<IUnitFieldRepository> unitFieldServiceMock = new();
private readonly Mock<IUnitFilterService> unitFilterServiceMock = new();
[Fact]
public async Task ApplyShortcodesAsync_WithEc_ShouldReplaceEc()
{
}
}
}

View File

@@ -1,79 +0,0 @@
namespace PARR.DAL.Tests
{
public class UnitFilterServiceTests //: IDisposable
{
// private readonly DbContextOptions<DataContext> _options;
// private readonly DataContext _context;
// private readonly UnitFilterService _service;
// public UnitFilterServiceTests()
// {
// _options = new DbContextOptionsBuilder<DataContext>()
// .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
// .EnableSensitiveDataLogging()
// .Options;
// _context = new DataContext(_options);
// _context.Database.EnsureCreated();
// TestDataGenerator.Seed(_context);
// // Логгеры
// var logger = Mock.Of<ILogger<UnitFilterService>>();
// var jobServiceLogger = Mock.Of<ILogger<JobService>>();
// var unitServiceLogger = Mock.Of<ILogger<UnitService>>();
// var unitInUnitServiceLogger = Mock.Of<ILogger<UnitInUnitService>>();
// var unitInValueServiceLogger = Mock.Of<ILogger<UnitInValueService>>();
// // Реальные сервисы с правильными конструкторами
// var jobService = new JobService(_context, jobServiceLogger);
// var unitService = new UnitService(_context, unitServiceLogger);
// var unitInUnitService = new UnitInUnitService(_context, unitInUnitServiceLogger);
// var unitInValueService = new UnitInValueService(unitInValueServiceLogger, _context); // ← logger first!
// _service = new UnitFilterService(
// logger,
// jobService,
// unitService,
// unitInUnitService,
// unitInValueService
// );
// }
// public void Dispose() => _context.Dispose();
// [Fact]
// public async Task GetUnitsIdByJobFilterAsync_WithExistingJobId_ShouldReturnExpectedUnits()
// {
// // Arrange
// var jobId = _context.Jobs.First(j => j.Name == "TestJob_1").Id;
// var expectedUnitIds = new[] { TestDataGenerator.UnitAId, TestDataGenerator.UnitBId };
// // Act
// var result = await _service.GetUnitsIdByJobFilterAsync(jobId, takeCount: 10);
// // Assert
// result.Should().NotBeNull()
// .And.HaveCount(2)
// .And.Contain(expectedUnitIds);
// }
// [Fact]
// public async Task GetRelatedUnitNamesAsync_WithChildRelationshipFilter_ShouldReturnExpectedNames()
// {
// // Arrange
// var jobId = _context.Jobs.First(j => j.Name == "TestJob_2").Id;
// var unitId = TestDataGenerator.UnitWId; // Родитель
// // rf.IsParent = false
// // Ищем юниты, у которых ParentUnitId = UnitWId (т.е. это дети UnitWId)
// // и у этих юнитов (детей) есть FieldId1 = "коммутатор"
// // Это UnitXId и UnitYId -> имена: "СХД-КМТ-CISCO-MDS9148-1-ДВС", "СХД-КМТ-CISCO-MDS9148-3-ДВС"
// // Act
// var result = await _service.GetRelatedUnitNamesAsync(jobId, unitId);
// // Assert
// result.Should().Contain(new[] { "СХД-КМТ-CISCO-MDS9148-1-ДВС", "СХД-КМТ-CISCO-MDS9148-3-ДВС" });
// }
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.16" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="MockQueryable.Moq" Version="9.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PARR.Core\PARR.Core.csproj" />
<ProjectReference Include="..\PARR.DAL\PARR.DAL.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,616 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.DAL.Context;
using PARR.Domain.Cache.Models;
using PARR.Domain.Entities.Job;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
namespace PARR.Core.Tests.Services.UnitFilterService;
public class UnitFilterServiceTests
{
#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)
{
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>
/// Создаёт тестовый объект JobGroupType с кодом Umbrella.
/// Используется для тестирования логики групповых работ типа "зонтик".
/// </summary>
private static JobGroupType BuildTestJobGroupType() => new()
{
Id = Guid.NewGuid(),
Code = JobGroupTypesEnum.Group,
Name = "Grouped Group",
Description = "Test"
};
/// <summary>
/// Создаёт тестовый объект JobGroup с заполненными обязательными полями.
/// Используется для подготовки навигационных свойств в тестах.
/// </summary>
private static JobGroup BuildTestJobGroup() => new()
{
Id = Guid.NewGuid(),
GroupName = "Test Group",
ShortDescription = "Short",
FullDescription = "Full",
Solution = "Sol",
TemplateDuration = "0:00:00",
ReferenceDate = DateTimeOffset.UtcNow,
GroupTypeId = Guid.NewGuid(),
ScheduleExcludeTypeId = Guid.NewGuid(),
GroupType = BuildTestJobGroupType()
};
/// <summary>
/// Создаёт тестовый объект JobAutoControl с отключённым автоконтролем.
/// Используется для заполнения обязательного свойства в сущности Job.
/// </summary>
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()
{
JobId = jobId,
IsEnable = false
};
/// <summary>
/// Контейнер для моков репозиториев.
/// Упрощает передачу зависимостей в метод создания сервиса.
/// </summary>
private class RepositoryMocks
{
public Mock<IUnitRepository> Unit { get; set; }
public Mock<IUnitInUnitRepository> UnitInUnit { get; set; }
public Mock<IUnitInValueRepository> UnitInValue { get; set; }
public Mock<IUnitFieldRepository> UnitField { get; set; }
public Mock<IJobRepository> Job { get; set; }
public Mock<IRedisCacheService> Cache { get; set; }
}
/// <summary>
/// Настраивает базовые моки репозиториев с использованием InMemory-контекста.
/// Возвращает объект с подготовленными моками для повторного использования в тестах.
/// </summary>
/// <param name="context">Экземпляр DataContext с тестовыми данными</param>
private static RepositoryMocks ArrangeRepositoryMocks(DataContext context)
{
var unitRepoMock = new Mock<IUnitRepository>();
unitRepoMock.Setup(r => r.Get()).Returns(context.Units.AsQueryable());
unitRepoMock.Setup(r => r.GetInitialUnitIds(It.IsAny<string>()))
.Returns(context.Units.Select(u => u.Id).AsQueryable());
var unitInUnitRepoMock = new Mock<IUnitInUnitRepository>();
unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable());
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>();
unitFieldRepoMock.Setup(r => r.Get()).Returns(new List<UnitField>().AsQueryable());
var jobRepoMock = new Mock<IJobRepository>();
jobRepoMock.Setup(r => r.Get()).Returns(context.Jobs.AsQueryable());
var cacheMock = new Mock<IRedisCacheService>();
cacheMock.Setup(r => r.GetCachedDataAsync<UnitFilterIds>(It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync((UnitFilterIds)null!);
return new RepositoryMocks
{
Unit = unitRepoMock,
UnitInUnit = unitInUnitRepoMock,
UnitInValue = unitInValueRepoMock,
UnitField = unitFieldRepoMock,
Job = jobRepoMock,
Cache = cacheMock
};
}
/// <summary>
/// Создаёт экземпляр тестируемого сервиса (SUT — System Under Test).
/// Инкапсулирует логику конструктора для упрощения тестов.
/// </summary>
/// <param name="mocks">Подготовленные моки репозиториев</param>
/// <param name="logger">Экземпляр логгера для сервиса</param>
private static PARR.Core.Services.UnitFilterService.UnitFilterService CreateSut(
RepositoryMocks mocks,
ILogger<PARR.Core.Services.UnitFilterService.UnitFilterService> logger)
{
var serviceOptions = Options.Create(new PARR.Core.Services.UnitFilterService.Models.UnitFilterServiceOptions { LoadBatchSize = 100 });
return new PARR.Core.Services.UnitFilterService.UnitFilterService(
logger,
mocks.Job.Object,
mocks.Unit.Object,
mocks.UnitInUnit.Object,
mocks.UnitInValue.Object,
mocks.Cache.Object,
serviceOptions,
mocks.UnitField.Object
);
}
#endregion
/// <summary>
/// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter)
/// или по связям (RelationshipFilter), корректно исключаются из результата.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var jobId = Guid.NewGuid();
var fieldId = Guid.NewGuid();
var validParentId = Guid.NewGuid();
var wrongParentId = Guid.NewGuid();
var unitPassesAll = Guid.NewGuid();
var unitFailsFieldFilter = Guid.NewGuid();
var unitFailsRelationshipFilter = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(unitPassesAll, "Unit_PassesAll", fieldId, "Accepted Value"),
BuildTestUnit(unitFailsFieldFilter, "Unit_FailsField", fieldId, "Rejected Value"),
BuildTestUnit(unitFailsRelationshipFilter, "Unit_FailsRel", fieldId, "Accepted Value"),
BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent"),
BuildTestUnit(wrongParentId, "Parent_Wrong", fieldId, "Wrong Parent")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = wrongParentId, ChildUnitId = unitFailsRelationshipFilter, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
// Формируем тестовый объект Job с фильтрами
var job = new Job
{
Id = jobId,
Name = "Test Job",
WorkName = "Test Work",
TemplateNameMask = "TestMask",
WorkGroupMask = "TestGroup",
ResponseAreaMask = "TestArea",
GroupId = Guid.NewGuid(),
TnkId = Guid.NewGuid(),
MinValueRelationships = 1,
MaxValueRelationships = 10,
IsParentRelationships = true,
AutoControl = BuildTestAutoControl(jobId),
Group = BuildTestJobGroup(),
UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>
{
new JobFieldFilter { Id = Guid.NewGuid(), FieldId = fieldId, ValueMask = "Accepted%", IsInverse = false }
},
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter { UnitFilterId = Guid.NewGuid(), FieldId = fieldId, ValueMask = "%", IsParent = true, IsInverse = false, IsFullMatch = false }
}
}
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок фильтрации по полю: в реальном коде используется EF.Functions.ILike,
// в тесте заменяем на прямое сравнение строк для предсказуемости
mocks.Unit.Setup(r => r.GetUnitByFieldAndValue(It.IsAny<IQueryable<Unit>>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<bool>()))
.Returns((IQueryable<Unit> q, Guid fId, string mask, bool inv) =>
q.Where(u => u.UnitValues != null && u.UnitValues.Any(v => v.FieldId == fId && v.Value != null && v.Value.Value == "Accepted Value")));
// ИСПРАВЛЕНО: используем запрос к контексту вместо массива.
// Массивный AsQueryable() не поддерживает IAsyncQueryProvider, что вызывает крах при вызове .ToListAsync() внутри сервиса.
// Запрос к InMemory DbSet гарантирует корректную асинхронную материализацию.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>()))
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
// === Act ===
var result = await service.GetUnitsByJobFilterAsync(job);
// === Assert ===
var resultIds = result.Select(u => u.Id).ToList();
Assert.Contains(unitPassesAll, resultIds);
Assert.DoesNotContain(unitFailsFieldFilter, resultIds);
Assert.DoesNotContain(unitFailsRelationshipFilter, resultIds);
Assert.Single(result);
var dto = result.First();
Assert.Equal("Accepted Value", dto.Values.FirstOrDefault()?.Value);
Assert.Single(dto.Parents);
Assert.Equal(validParentId, dto.Parents.First().UnitId);
}
/// <summary>
/// Проверяет, что юниты исключаются, если их единственный родительский юнит
/// содержит запрещённое значение тега (сценарий с IsInverse = true).
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var jobId = Guid.NewGuid();
var tagFieldId = Guid.NewGuid();
var validParentId = Guid.NewGuid();
var invalidParentId = Guid.NewGuid();
var unitWithValidParent = Guid.NewGuid();
var unitWithInvalidParent = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(validParentId, "Parent_Valid", tagFieldId, "ОТВ.ЭК"),
BuildTestUnit(invalidParentId, "Parent_Invalid", tagFieldId, "ПАРР-РРПТК-ОТВ.ЭК"),
BuildTestUnit(unitWithValidParent, "Unit_Valid", Guid.NewGuid(), "Val"),
BuildTestUnit(unitWithInvalidParent, "Unit_Invalid", Guid.NewGuid(), "Val")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitWithValidParent, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = invalidParentId, ChildUnitId = unitWithInvalidParent, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
// Формируем тестовый объект Job с настройкой IsInverse = true
// IsInverse = true означает: исключить родителей, которые СОВПАДАЮТ с маской
var job = new Job
{
Id = jobId,
Name = "Test Job Inverse",
WorkName = "Test Work",
TemplateNameMask = "TestMask",
WorkGroupMask = "TestGroup",
ResponseAreaMask = "TestArea",
GroupId = Guid.NewGuid(),
TnkId = Guid.NewGuid(),
MinValueRelationships = 1,
MaxValueRelationships = 10,
IsParentRelationships = true,
AutoControl = BuildTestAutoControl(jobId),
Group = BuildTestJobGroup(),
UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
UnitFilterId = Guid.NewGuid(),
FieldId = tagFieldId,
ValueMask = "%ПАРР-РРПТК-ОТВ.ЭК%",
IsParent = true,
IsInverse = true,
IsFullMatch = false
}
}
}
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей с запрещённым тегом.
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
// IsInverse = true в сервисе инвертирует результат, поэтому эти родители будут исключены.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("ПАРР-РРПТК-ОТВ.ЭК", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
// === Act ===
var result = await service.GetUnitsByJobFilterAsync(job);
// === Assert ===
var resultIds = result.Select(u => u.Id).ToList();
Assert.Contains(unitWithValidParent, resultIds);
Assert.DoesNotContain(unitWithInvalidParent, resultIds);
}
/// <summary>
/// Проверяет логику IsFullMatch = true: юнит исключается,
/// если хотя бы один из его родительских юнитов не соответствует фильтру.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var jobId = Guid.NewGuid();
var checkFieldId = Guid.NewGuid();
var parentGoodId = Guid.NewGuid();
var parentBadId = Guid.NewGuid();
var unitMixedId = Guid.NewGuid();
var unitCleanId = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(parentGoodId, "Parent_Good", checkFieldId, "Good Parent"),
BuildTestUnit(parentBadId, "Parent_Bad", checkFieldId, "Bad Parent"),
BuildTestUnit(unitMixedId, "Unit_Mixed", Guid.NewGuid(), "Mixed"),
BuildTestUnit(unitCleanId, "Unit_Clean", Guid.NewGuid(), "Clean")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentBadId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitCleanId, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
// Формируем тестовый объект Job с настройкой IsFullMatch = true
// IsFullMatch = true означает: ВСЕ родители должны соответствовать фильтру
var job = new Job
{
Id = jobId,
Name = "Test Job FullMatch",
WorkName = "Test Work",
TemplateNameMask = "TestMask",
WorkGroupMask = "TestGroup",
ResponseAreaMask = "TestArea",
GroupId = Guid.NewGuid(),
TnkId = Guid.NewGuid(),
MinValueRelationships = 1,
MaxValueRelationships = 10,
IsParentRelationships = true,
AutoControl = BuildTestAutoControl(jobId),
Group = BuildTestJobGroup(),
UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
UnitFilterId = Guid.NewGuid(),
FieldId = checkFieldId,
ValueMask = "%Good%",
IsParent = true,
IsInverse = false,
IsFullMatch = true
}
}
}
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей, соответствующих маске "Good".
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(checkFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("Good", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
// === Act ===
var result = await service.GetUnitsByJobFilterAsync(job);
// === Assert ===
var resultIds = result.Select(u => u.Id).ToList();
Assert.Contains(unitCleanId, resultIds);
Assert.DoesNotContain(unitMixedId, resultIds);
}
/// <summary>
/// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true.
/// Логика: юнит проходит, только если НИ ОДИН из его родителей не содержит запрещённое значение.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var jobId = Guid.NewGuid();
var tagFieldId = Guid.NewGuid();
var parentForbiddenId = Guid.NewGuid(); // Родитель с запрещённым тегом
var parentCleanId = Guid.NewGuid(); // Родитель без запрещённого тега
var unitMixedId = Guid.NewGuid(); // Юнит с одним "плохим" и одним "хорошим" родителем
var unitAllCleanId = Guid.NewGuid(); // Юнит только с "хорошими" родителями
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(parentForbiddenId, "Parent_Forbidden", tagFieldId, "FORBIDDEN-TAG"),
BuildTestUnit(parentCleanId, "Parent_Clean", tagFieldId, "CLEAN-TAG"),
BuildTestUnit(unitMixedId, "Unit_Mixed", Guid.NewGuid(), "Mixed"),
BuildTestUnit(unitAllCleanId, "Unit_AllClean", Guid.NewGuid(), "AllClean")
);
// Подготовка тестовых данных: связи
// unitMixed имеет обоих родителей — один с запрещённым тегом, один без
// unitAllClean имеет только "чистого" родителя
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentForbiddenId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitAllCleanId, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
// Формируем тестовый объект Job с комбинацией флагов:
// IsParent = true: проверяем родительские связи
// IsInverse = true: исключаем родителей, которые СОВПАДАЮТ с маской
// IsFullMatch = true: ВСЕ родители должны пройти фильтр (ни один не должен совпасть с маской)
var job = new Job
{
Id = jobId,
Name = "Test Job Combined Flags",
WorkName = "Test Work",
TemplateNameMask = "TestMask",
WorkGroupMask = "TestGroup",
ResponseAreaMask = "TestArea",
GroupId = Guid.NewGuid(),
TnkId = Guid.NewGuid(),
MinValueRelationships = 1,
MaxValueRelationships = 10,
IsParentRelationships = true,
AutoControl = BuildTestAutoControl(jobId),
Group = BuildTestJobGroup(),
UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
UnitFilterId = Guid.NewGuid(),
FieldId = tagFieldId,
ValueMask = "%FORBIDDEN%",
IsParent = true,
IsInverse = true,
IsFullMatch = true
}
}
}
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей, содержащих "FORBIDDEN".
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
// Из-за IsInverse=true эти родители будут исключены из "валидных".
// Из-за IsFullMatch=true юнит пройдёт, только если ВСЕ его родители валидны.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("FORBIDDEN", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
// === Act ===
var result = await service.GetUnitsByJobFilterAsync(job);
// === Assert ===
var resultIds = result.Select(u => u.Id).ToList();
// unitAllCleanId должен остаться: у него один родитель, и он не содержит запрещённый тег
Assert.Contains(unitAllCleanId, resultIds);
// unitMixedId должен быть исключён: у него есть родитель с запрещённым тегом.
// Логика:
// 1. GetMatchingTargetIds возвращает {parentForbiddenId}
// 2. IsInverse=true → валидные родители = все.Кроме({parentForbiddenId}) = {parentCleanId}
// 3. IsFullMatch=true → проверяем: все родители {parentForbiddenId, parentCleanId} входят в {parentCleanId}? Нет.
// 4. Юнит исключается.
Assert.DoesNotContain(unitMixedId, resultIds);
}
}

View File

@@ -1,253 +0,0 @@
using PARR.DAL.Context;
using PARR.Domain.Entities.Job;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
namespace PARR.DAL.Tests
{
public static class TestDataGenerator
{
// --- Unit IDs ---
public static readonly Guid UnitAId = Guid.Parse("11111111-1111-1111-1111-111111111111");
public static readonly Guid UnitBId = Guid.Parse("00000000-0000-1111-1111-111111111111");
public static readonly Guid UnitCId = Guid.Parse("22222222-2222-2222-2222-222222222222");
public static readonly Guid UnitDId = Guid.Parse("33333333-3333-3333-3333-333333333333");
public static readonly Guid UnitWId = Guid.Parse("44444444-4444-4444-4444-444444444444");
public static readonly Guid UnitXId = Guid.Parse("55555555-5555-5555-5555-555555555555");
public static readonly Guid UnitYId = Guid.Parse("66666666-6666-6666-6666-666666666666");
public static readonly Guid UnitZId = Guid.Parse("77777777-7777-7777-7777-777777777777");
// --- Field IDs (для обхода составного ключа UnitFilterId+FieldId) ---
public static readonly Guid FieldId1 = Guid.Parse("08b42860-09a7-4a1f-aa4f-0290e86df883");//ТИП_ЭК
public static readonly Guid FieldId2 = Guid.Parse("5b2d3f5b-a9f3-4a6b-9142-70ef4357ac37"); //неуникальный
public static readonly Guid FieldId3 = Guid.Parse("5f3c7b8b-583d-411b-b9bd-1e045ddc5a8e");//ПОДКАТЕГОРИЯ_ЭК
public static readonly Guid FieldId4 = Guid.Parse("3e9ac5aa-531b-4ffc-8f93-694d00aa92a0");//активный
// --- Field Value IDs ---
public static readonly Guid FieldValueId1 = Guid.Parse("fe18edcd-ab6a-4f3e-b7bc-e54aad9ecc0d"); // система хранения данных (схд) field3
public static readonly Guid FieldValueId2 = Guid.Parse("f385b22c-42c3-4db9-bd41-b829603cc2d9"); // 1 field2+field4
public static readonly Guid FieldValueId3 = Guid.Parse("2badc139-69cb-4d52-830a-1b3c6a9faf9d"); // 0 field2+field4
public static readonly Guid FieldValueId4 = Guid.Parse("fde68ef7-a07e-4c10-872c-cd98c3d1c30b"); // коммутатор
public static readonly Guid FieldValueId5 = Guid.Parse("9f169862-3f78-49f2-b967-13b5b2f56231"); // серверное оборудование
public static readonly Guid FieldValueId6 = Guid.Parse("075b6fad-d24f-40f4-aa0e-83a4cfc46bbf"); // стойка цвк
public static readonly Guid FieldValueId7 = Guid.Parse("9679afd8-8004-4ce2-bd6f-9005b037ef96"); // схд
internal static void Seed(DataContext context)
{
if (context.Units.Any()) return;
var now = DateTimeOffset.UtcNow;
// 1. Значения полей (остаются те же)
var fieldValues = new[]
{
new UnitFieldValue { Id = FieldValueId1, Value = "система хранения данных (схд)", DateCreated = now },
new UnitFieldValue { Id = FieldValueId2, Value = "1", DateCreated = now },
new UnitFieldValue { Id = FieldValueId3, Value = "0", DateCreated = now },
new UnitFieldValue { Id = FieldValueId4, Value = "коммутатор", DateCreated = now },
};
context.UnitFieldValues.AddRange(fieldValues);
// 2. Unit'ы
var units = new[]
{
new Unit { Id = UnitAId, Name = "СХД-КМТ-DVGD-MDS9148-1-ДВС", DateCreated = now },
new Unit { Id = UnitBId, Name = "СХД-КМТ-DVGD-MDS9148-2-ДВС", DateCreated = now },
new Unit { Id = UnitCId, Name = "СРВ-СТОЙКА-СТОЙКА-ХИВЦ-4.0-ДВС", DateCreated = now },
new Unit { Id = UnitDId, Name = "СХД-VSP-E590-01-ДВС", DateCreated = now },
new Unit { Id = UnitWId, Name = "СХД-КМТ-SAN1_FAB_1-ДВС", DateCreated = now },
new Unit { Id = UnitXId, Name = "СХД-КМТ-CISCO-MDS9148-1-ДВС", DateCreated = now },
new Unit { Id = UnitYId, Name = "СХД-КМТ-CISCO-MDS9148-3-ДВС", DateCreated = now },
};
context.Units.AddRange(units);
// 3. UnitInUnit (связи)
var unitInUnits = new[]
{
new UnitInUnit { ParentUnitId = UnitAId, ChildUnitId = UnitCId, DateCreated = now },
new UnitInUnit { ParentUnitId = UnitBId, ChildUnitId = UnitCId, DateCreated = now },
new UnitInUnit { ParentUnitId = UnitDId, ChildUnitId = UnitAId, DateCreated = now },
new UnitInUnit { ParentUnitId = UnitDId, ChildUnitId = UnitBId, DateCreated = now },
new UnitInUnit { ParentUnitId = UnitWId, ChildUnitId = UnitXId, DateCreated = now },
new UnitInUnit { ParentUnitId = UnitWId, ChildUnitId = UnitYId, DateCreated = now }
};
context.UnitInUnits.AddRange(unitInUnits);
// 4. UnitInValue
var unitInValues = new List<UnitInValue>
{
// Основные (FieldId)
new UnitInValue { UnitId = UnitAId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now },
new UnitInValue { UnitId = UnitAId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitAId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitAId, FieldId = FieldId2, ValueId = FieldValueId3, DateCreated = now },
new UnitInValue { UnitId = UnitBId, FieldId = FieldId2, ValueId = FieldValueId3, DateCreated = now },
new UnitInValue { UnitId = UnitBId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now },
new UnitInValue { UnitId = UnitBId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitBId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitCId, FieldId = FieldId3, ValueId = FieldValueId5, DateCreated = now },
new UnitInValue { UnitId = UnitCId, FieldId = FieldId1, ValueId = FieldValueId6, DateCreated = now },
new UnitInValue { UnitId = UnitCId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitDId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitDId, FieldId = FieldId1, ValueId = FieldValueId7, DateCreated = now },
new UnitInValue { UnitId = UnitDId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitWId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitWId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now },
new UnitInValue { UnitId = UnitWId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitWId, FieldId = FieldId2, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitXId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitXId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now },
new UnitInValue { UnitId = UnitXId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
new UnitInValue { UnitId = UnitYId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now },
new UnitInValue { UnitId = UnitYId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now },
new UnitInValue { UnitId = UnitYId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now },
};
context.UnitInValues.AddRange(unitInValues);
// 5. JobGroupType и Group
var groupType = context.JobGroupTypes
.FirstOrDefault(t => t.Code == JobGroupTypesEnum.Simple)
?? throw new InvalidOperationException("JobGroupType 'Simple' не найден в контексте.");
var group = new JobGroup
{
Id = Guid.NewGuid(),
GroupTypeId = groupType.Id,
GroupType = groupType,
GroupName = "Группа",
ShortDescription = "%ТНК-КРАТКО% %ЭК%",
FullDescription = "Подробно:\n%СВЯЗИ%",
Solution = "Решение",
TemplateDuration = "540 00:00:00",
ReferenceDate = now,
IsAutoDistributionEnabled = false,
IsAgent = false,
DateCreated = now
};
context.JobGroups.Add(group);
// 6. Jobs
var jobs = new List<Job>();
// --- TestJob ---
var job1 = new Job
{
Id = Guid.NewGuid(),
Name = "TestJob_1",
WorkName = "Работа",
TemplateNameMask = "%ЭК%",
WorkGroupMask = "%ГРУППА_РАБОТ%",
GroupId = group.Id,
TnkId = Guid.NewGuid(),
Group = group,
DateCreated = now,
ResponseAreaMask = "%ЗО_РГ%"
};
var unitFilter1Id = Guid.NewGuid();
job1.UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = unitFilter1Id,
UnitFilter = "СХД-%",
JobId = job1.Id,
Job = job1,
DateCreated = now,
FieldFilters = new List<JobFieldFilter>
{
new JobFieldFilter
{
UnitFilterId = unitFilter1Id,
FieldId = FieldId1,
ValueMask="коммутатор"
},
new JobFieldFilter
{
UnitFilterId = unitFilter1Id,
FieldId = FieldId4,
ValueMask="1"
},
new JobFieldFilter
{
UnitFilterId = unitFilter1Id,
FieldId = FieldId2,
ValueMask="0"
},
},
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
IsParent = true,
IsInverse = true,
IsFullMatch = true,
FieldId = FieldId1,
ValueMask = "коммутатор",
UnitFilterId = unitFilter1Id
}
}
}
};
jobs.Add(job1);
// --- TestJob_2 (с IsParent = false для дочерних связей) ---
var job2 = new Job
{
Id = Guid.NewGuid(),
Name = "TestJob_2",
WorkName = "Работа2",
TemplateNameMask = "%ЭК%",
WorkGroupMask = "%ГРУППА_РАБОТ%",
GroupId = group.Id,
TnkId = Guid.NewGuid(),
Group = group,
DateCreated = now,
ResponseAreaMask = "%ЗО_РГ%"
};
var unitFilter2Id = Guid.NewGuid();
job2.UnitFilters = new List<JobUnitFilter>
{
new JobUnitFilter
{
Id = unitFilter2Id,
UnitFilter = "СХД-%",
JobId = job2.Id,
Job = job2,
DateCreated = now,
FieldFilters = new List<JobFieldFilter>
{
new JobFieldFilter
{
UnitFilterId = unitFilter2Id,
FieldId = FieldId1,
ValueMask="коммутатор"
},
new JobFieldFilter
{
UnitFilterId = unitFilter2Id,
FieldId = FieldId4,
ValueMask="1"
},
},
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
IsParent = false, // <-- Новый фильтр: ищем дочерние юниты
IsInverse = false, // <-- Простой фильтр
IsFullMatch = false, // <-- Простой фильтр
FieldId = FieldId1,
ValueMask = "коммутатор",
UnitFilterId = unitFilter2Id
}
}
}
};
jobs.Add(job2);
context.Jobs.AddRange(jobs);
context.SaveChanges();
}
}
}