diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs
new file mode 100644
index 0000000..c67db2f
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs
@@ -0,0 +1,182 @@
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Library.Genres;
+using TeleWave.Application.Media.ManualInbox;
+using TeleWave.Application.Media.MovieImport;
+using TeleWave.Application.Metadata;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Media;
+
+///
+/// Пути разбора фильмов, где всё идёт не по-хорошему: источник не настроен, ассет пропал, файл уже
+/// в чужом шоу. Каждый из них должен быть тихим отказом, а не исключением: разбор пакетный.
+///
+public class MovieImportHandlerTests
+{
+ private const string Provider = "tmdb";
+
+ private static IMetadataProviderResolver EmptyResolver()
+ {
+ var resolver = Substitute.For();
+ resolver.AvailableKeys.Returns([]);
+ // Явный null: без него NSubstitute подсунул бы автозаглушку провайдера, и «источник
+ // не настроен» перестало бы быть тем, что проверяет тест.
+ resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null);
+ return resolver;
+ }
+
+ private static ShowMetadataApplier Applier(
+ IAppDbContext db,
+ IMetadataProviderResolver resolver
+ ) =>
+ new(
+ db,
+ resolver,
+ Substitute.For(),
+ Substitute.For(),
+ new GenreMatcher(db)
+ );
+
+ [Fact]
+ public async Task Match_ReturnsNothing_WhenProviderIsNotConfigured()
+ {
+ // Ключа источника нет — искать нечем, и это не ошибка: таблица разбора просто пуста.
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+ var matcher = new MovieMatcher(db, EmptyResolver());
+
+ var rows = await new MatchMoviesQueryHandler(matcher).Handle(
+ new MatchMoviesQuery(["Gladiator.2000.mkv"], Provider),
+ CancellationToken.None
+ );
+
+ Assert.Empty(rows);
+ }
+
+ [Fact]
+ public async Task MatchOne_ReturnsNotFound_WhenProviderIsNotConfigured()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+ var matcher = new MovieMatcher(db, EmptyResolver());
+
+ var row = await new MatchMovieQueryHandler(matcher).Handle(
+ new MatchMovieQuery("file.mkv", "Гладиатор", 2000, Provider),
+ CancellationToken.None
+ );
+
+ Assert.Equal(MovieMatchStatus.NotFound, row.Status);
+ Assert.Equal("file.mkv", row.Name);
+ }
+
+ [Fact]
+ public async Task Import_ReportsMissingAsset()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+ var storage = Substitute.For();
+ storage.ListManualInbox(Arg.Any()).Returns([]);
+
+ var result = await new ImportMoviesCommandHandler(
+ db,
+ new ManualInboxTaker(db, storage),
+ Substitute.For(),
+ Applier(db, EmptyResolver())
+ ).Handle(
+ new ImportMoviesCommand(
+ [new MovieImportItem(null, Guid.NewGuid(), "Пропавший", null, null)],
+ Provider
+ ),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(0, result.Value.Created);
+ Assert.Single(result.Value.Failed);
+ }
+
+ [Fact]
+ public async Task AutoAttach_DoesNothing_WhenProviderIsNotConfigured()
+ {
+ var fixture = new TestDb();
+ var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Inbox);
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(asset);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var resolver = EmptyResolver();
+ var result = await new AutoAttachMovieCommandHandler(
+ db,
+ resolver,
+ new MovieMatcher(db, resolver),
+ Applier(db, resolver)
+ ).Handle(new AutoAttachMovieCommand(asset.Id, "a.mkv"), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.False(result.Value);
+ }
+
+ [Fact]
+ public async Task AutoAttach_FailsOnMissingAsset()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var provider = Substitute.For();
+ var resolver = Substitute.For();
+ resolver.AvailableKeys.Returns([Provider]);
+ resolver.Resolve(Provider).Returns(provider);
+
+ var result = await new AutoAttachMovieCommandHandler(
+ db,
+ resolver,
+ new MovieMatcher(db, resolver),
+ Applier(db, resolver)
+ ).Handle(new AutoAttachMovieCommand(Guid.NewGuid(), "a.mkv"), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task AutoAttach_SkipsAssetAlreadyInShow()
+ {
+ // Ассет мог уехать в шоу вручную, пока файл ждал очереди, — второе шоу по нему не заводим.
+ var fixture = new TestDb();
+ var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Inbox);
+ var show = Show.Create("Уже есть", ShowKind.Single);
+ show.AddEpisode(asset.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(asset);
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var provider = Substitute.For();
+ var resolver = Substitute.For();
+ resolver.AvailableKeys.Returns([Provider]);
+ resolver.Resolve(Provider).Returns(provider);
+
+ var result = await new AutoAttachMovieCommandHandler(
+ db,
+ resolver,
+ new MovieMatcher(db, resolver),
+ Applier(db, resolver)
+ ).Handle(new AutoAttachMovieCommand(asset.Id, "a.mkv"), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.False(result.Value);
+ await provider
+ .DidNotReceive()
+ .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs
new file mode 100644
index 0000000..59bc820
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs
@@ -0,0 +1,110 @@
+using TeleWave.Application.Media.MovieImport;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Media;
+
+///
+/// Валидаторы разбора фильмов. Проверяют вход до хендлера, поэтому важны обе стороны: пропустить
+/// нормальную строку и не пропустить ту, что уронила бы разбор или ушла бы во внешний источник.
+///
+public class MovieImportValidatorTests
+{
+ private static MovieImportItem FromManual(string? title = "Гладиатор") =>
+ new("Gladiator.2000.mkv", null, title!, 2000, "98");
+
+ private static MovieImportItem Uploaded() => new(null, Guid.NewGuid(), "Гладиатор", 2000, null);
+
+ [Fact]
+ public void Match_AcceptsNormalBatch()
+ {
+ var command = new MatchMoviesQuery(["a.mkv", "b.mkv"], "tmdb");
+
+ Assert.True(new MatchMoviesQueryValidator().Validate(command).IsValid);
+ }
+
+ [Theory]
+ [InlineData(0)] // пустой список разбирать нечего
+ [InlineData(201)] // каждая строка — запрос во внешний источник, и пачка ограничена
+ public void Match_RejectsBadBatchSize(int count)
+ {
+ var names = Enumerable.Range(0, count).Select(i => $"{i}.mkv").ToList();
+
+ Assert.False(
+ new MatchMoviesQueryValidator().Validate(new MatchMoviesQuery(names, "tmdb")).IsValid
+ );
+ }
+
+ [Fact]
+ public void Match_RejectsMissingProvider()
+ {
+ Assert.False(
+ new MatchMoviesQueryValidator().Validate(new MatchMoviesQuery(["a.mkv"], "")).IsValid
+ );
+ }
+
+ [Theory]
+ [InlineData("Шоу Трумана", 1998, true)]
+ [InlineData("Шоу Трумана", null, true)] // год необязателен: в имени его часто нет
+ [InlineData("", 1998, false)] // искать нечего
+ [InlineData("Кино", 1800, false)] // раньше кинематографа
+ [InlineData("Кино", 2300, false)]
+ public void MatchOne_ChecksTitleAndYear(string title, int? year, bool valid)
+ {
+ var query = new MatchMovieQuery("file.mkv", title, year, "tmdb");
+
+ Assert.Equal(valid, new MatchMovieQueryValidator().Validate(query).IsValid);
+ }
+
+ [Fact]
+ public void Import_AcceptsBothSources()
+ {
+ var validator = new ImportMoviesCommandValidator();
+
+ Assert.True(validator.Validate(new ImportMoviesCommand([FromManual()], "tmdb")).IsValid);
+ Assert.True(validator.Validate(new ImportMoviesCommand([Uploaded()], "tmdb")).IsValid);
+ }
+
+ [Fact]
+ public void Import_RejectsItemWithoutFile()
+ {
+ // Без файла шоу пустое — заводить его незачем.
+ var item = new MovieImportItem(null, null, "Гладиатор", 2000, null);
+
+ Assert.False(
+ new ImportMoviesCommandValidator()
+ .Validate(new ImportMoviesCommand([item], "tmdb"))
+ .IsValid
+ );
+ }
+
+ [Fact]
+ public void Import_RejectsItemWithTwoSources()
+ {
+ // Путь в manual/ и уже загруженный ассет разом: неясно, какой файл брать.
+ var item = new MovieImportItem("Gladiator.mkv", Guid.NewGuid(), "Гладиатор", 2000, null);
+
+ Assert.False(
+ new ImportMoviesCommandValidator()
+ .Validate(new ImportMoviesCommand([item], "tmdb"))
+ .IsValid
+ );
+ }
+
+ [Fact]
+ public void Import_RejectsEmptyTitle()
+ {
+ Assert.False(
+ new ImportMoviesCommandValidator()
+ .Validate(new ImportMoviesCommand([FromManual(title: " ")], "tmdb"))
+ .IsValid
+ );
+ }
+
+ [Fact]
+ public void Import_RejectsEmptyBatch()
+ {
+ Assert.False(
+ new ImportMoviesCommandValidator().Validate(new ImportMoviesCommand([], "tmdb")).IsValid
+ );
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs
new file mode 100644
index 0000000..9193dfb
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs
@@ -0,0 +1,153 @@
+using TeleWave.Application.Broadcast.Bumpers;
+using TeleWave.Application.Programming.Templates;
+using TeleWave.Application.Programming.Templates.Junctions;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Валидаторы стыков и заставок. Проверяют границы, за которыми настройка перестаёт что-либо
+/// значить в эфире: врезка длиннее суток, допуск от часа шире самого часа, семь строк в кадре.
+///
+public class JunctionBumperValidatorTests
+{
+ private static JunctionElementInput Element(
+ int amount = 1,
+ int choiceWeight = 1,
+ string? choiceKey = null,
+ JunctionConditions? conditions = null
+ ) =>
+ new(
+ JunctionElementKind.Ad,
+ Guid.NewGuid(),
+ null,
+ null,
+ JunctionAmountMode.Count,
+ amount,
+ IsRequired: false,
+ choiceKey,
+ choiceWeight,
+ conditions
+ );
+
+ private static bool Valid(JunctionElementInput input) =>
+ new UpdateJunctionElementCommandValidator()
+ .Validate(new UpdateJunctionElementCommand(Guid.NewGuid(), Guid.NewGuid(), input))
+ .IsValid;
+
+ [Fact]
+ public void Element_AcceptsUsualSettings()
+ {
+ var conditions = new JunctionConditions(
+ MinMinutesBetween: 30,
+ Chance: 40,
+ NearHourMinutes: 2
+ );
+
+ Assert.True(
+ Valid(Element(amount: 3, choiceWeight: 5, choiceKey: "fork", conditions: conditions))
+ );
+ // Условий может не быть вовсе — тогда проверять нечего.
+ Assert.True(Valid(Element()));
+ }
+
+ [Theory]
+ [InlineData(0)] // ноль врезок — это отсутствие врезки, а не настройка
+ [InlineData(24 * 60 + 1)] // длиннее вещательных суток
+ public void Element_RejectsAmountOutOfRange(int amount)
+ {
+ Assert.False(Valid(Element(amount)));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(1001)]
+ public void Element_RejectsWeightOutOfRange(int weight)
+ {
+ Assert.False(Valid(Element(choiceWeight: weight)));
+ }
+
+ [Theory]
+ [InlineData(101)] // вероятность выше ста
+ [InlineData(-1)]
+ public void Element_RejectsChanceOutOfRange(int chance)
+ {
+ Assert.False(Valid(Element(conditions: new JunctionConditions(Chance: chance))));
+ }
+
+ [Fact]
+ public void Element_RejectsNearHourWiderThanHalfHour()
+ {
+ // Допуск в 31 минуту покрывает час целиком — это уже не привязка к :00.
+ Assert.False(Valid(Element(conditions: new JunctionConditions(NearHourMinutes: 31))));
+ Assert.False(Valid(Element(conditions: new JunctionConditions(NearHourMinutes: -1))));
+ }
+
+ [Fact]
+ public void Junction_ChecksNameAndCap()
+ {
+ var validator = new UpdateJunctionCommandValidator();
+ var id = Guid.NewGuid();
+
+ Assert.True(
+ validator.Validate(new UpdateJunctionCommand(id, "Рекламный шов", 120)).IsValid
+ );
+ // Потолок можно не задавать — тогда стык ограничен только якорем.
+ Assert.True(validator.Validate(new UpdateJunctionCommand(id, "Шов", null)).IsValid);
+ Assert.False(validator.Validate(new UpdateJunctionCommand(id, " ", 120)).IsValid);
+ Assert.False(validator.Validate(new UpdateJunctionCommand(id, "Шов", 0)).IsValid);
+ Assert.False(
+ validator.Validate(new UpdateJunctionCommand(id, "Шов", 24 * 60 * 60 + 1)).IsValid
+ );
+ Assert.False(
+ new CreateJunctionCommandValidator().Validate(new CreateJunctionCommand("")).IsValid
+ );
+ }
+
+ private static bool ValidVariant(int weight, params string[] lines) =>
+ new UpdateBumperVariantCommandValidator()
+ .Validate(
+ new UpdateBumperVariantCommand(
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ new BumperVariantInput(
+ "Подблок",
+ BumperTrigger.Both,
+ BumperBackground.Template,
+ weight,
+ [
+ .. lines.Select(text => new BumperLineDto(
+ BumperLineStyle.Title,
+ BumperLineColor.Text,
+ text
+ )),
+ ]
+ )
+ )
+ )
+ .IsValid;
+
+ [Fact]
+ public void Variant_AcceptsUpToSixLines()
+ {
+ Assert.True(ValidVariant(1, "СЕЙЧАС", "{now.title}"));
+ Assert.True(ValidVariant(0, "1", "2", "3", "4", "5", "6"));
+ }
+
+ [Fact]
+ public void Variant_RejectsSeventhLine()
+ {
+ // Больше шести строк в кадр не помещается ни при каком размере шрифта.
+ Assert.False(ValidVariant(1, "1", "2", "3", "4", "5", "6", "7"));
+ }
+
+ [Fact]
+ public void Variant_RejectsEmptyLineAndBadWeight()
+ {
+ Assert.False(ValidVariant(1, " "));
+ Assert.False(ValidVariant(1001, "СЕЙЧАС"));
+ Assert.False(ValidVariant(-1, "СЕЙЧАС"));
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs
new file mode 100644
index 0000000..181d78b
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs
@@ -0,0 +1,118 @@
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Templates;
+using TeleWave.Application.Programming.Templates.Generate;
+using TeleWave.Application.Programming.Templates.Transfer;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Обмен конфигурацией там, где обмениваться нечем: нет канала, нет шаблона, нет источника
+/// метаданных. Всё это штатные исходы — админ открыл экран на канале, который ещё не настроен.
+///
+public class TransferGuardsTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static ExportGridQueryHandler Export(IAppDbContext db) =>
+ new(db, GroupServices.Dynamic(db));
+
+ [Fact]
+ public async Task Export_FailsWithoutChannel()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await Export(db)
+ .Handle(new ExportGridQuery(Guid.NewGuid()), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Export_FailsWithoutTemplate()
+ {
+ // Канал без сетки выгружать нечем — и это отдельная ошибка, а не пустой файл.
+ var fixture = new TestDb();
+ var channel = Channel.Create("Первый", "first", T0);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Channels.Add(channel);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var result = await Export(db)
+ .Handle(new ExportGridQuery(channel.Id), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Import_FailsWithoutTemplate()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var config = new GridConfig([new GridConfigLayer("Основная сетка", 10, [])]);
+ var result = await new ImportGridCommandHandler(
+ db,
+ new SlotWriter(db),
+ GroupServices.Stats(db)
+ ).Handle(new ImportGridCommand(Guid.NewGuid(), config, false), CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Prompt_FailsWithoutChannel()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
+ new BuildGridPromptQuery(Guid.NewGuid(), [], null),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Prompt_SaysWhatIsMissing_OnEmptyLibrary()
+ {
+ // Пустая библиотека — не ошибка: запрос собирается, но честно говорит, что групп нет,
+ // иначе модель придумает их сама и вернёт сетку под несуществующий контент.
+ var fixture = new TestDb();
+ var channel = Channel.Create("Первый", "first", T0);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Channels.Add(channel);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
+ new BuildGridPromptQuery(channel.Id, [], null),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(0, result.Value.Groups);
+ Assert.Equal(0, result.Value.Shows);
+ Assert.Contains("Готовых групп нет", result.Value.Prompt, StringComparison.Ordinal);
+ Assert.Contains("Библиотека пуста", result.Value.Prompt, StringComparison.Ordinal);
+ // Стыков и заставок тоже нет — модель должна знать, что ссылаться не на что.
+ Assert.Contains("стыков ещё нет", result.Value.Prompt, StringComparison.Ordinal);
+ }
+
+ private static GroupCatalog Catalog(IAppDbContext db) =>
+ new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs
new file mode 100644
index 0000000..4f3d98f
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs
@@ -0,0 +1,123 @@
+using TeleWave.Application.Programming.Templates.Transfer;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Валидаторы обмена конфигурацией. Файл приходит извне — от другой установки или от языковой
+/// модели, — поэтому проверка формата и объёма здесь единственная преграда между чужим JSON
+/// и сеткой канала.
+///
+public class TransferValidatorTests
+{
+ private static GridConfigSlot Slot() =>
+ new("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime, Group: "Сериалы");
+
+ private static GridConfig Config(params GridConfigLayer[] layers) => new(layers);
+
+ private static GridConfig Valid() =>
+ Config(new GridConfigLayer("Основная сетка", 10, [Slot()]));
+
+ [Fact]
+ public void Import_AcceptsNormalFile()
+ {
+ var command = new ImportGridCommand(Guid.NewGuid(), Valid(), Replace: true);
+
+ Assert.True(new ImportGridCommandValidator().Validate(command).IsValid);
+ }
+
+ [Fact]
+ public void Import_RejectsForeignFormat()
+ {
+ // Чужой формат с теми же полями разобрался бы во что-то бессмысленное — лучше отказать.
+ var config = Valid() with
+ {
+ Format = "some.other.format",
+ };
+
+ Assert.False(Validate(config));
+ }
+
+ [Fact]
+ public void Import_RejectsFutureVersion()
+ {
+ var config = Valid() with { Version = GridConfig.CurrentVersion + 1 };
+
+ Assert.False(Validate(config));
+ }
+
+ [Fact]
+ public void Import_RejectsEmptyAndNamelessLayers()
+ {
+ Assert.False(Validate(Config()));
+ Assert.False(Validate(Config(new GridConfigLayer(" ", 10, [Slot()]))));
+ }
+
+ [Fact]
+ public void Import_RejectsOversizedFile()
+ {
+ // Потолки — про объём: недельная сетка это десятки слотов, а не десятки тысяч.
+ var layers = Enumerable
+ .Range(0, 65)
+ .Select(i => new GridConfigLayer($"Слой {i}", 10, [Slot()]))
+ .ToArray();
+
+ Assert.False(Validate(Config(layers)));
+ }
+
+ [Fact]
+ public void Import_RejectsTooManyGroupsAndCollections()
+ {
+ var groups = Enumerable
+ .Range(0, 257)
+ .Select(i => new GridConfigGroup($"Группа {i}"))
+ .ToList();
+ var collections = Enumerable
+ .Range(0, 257)
+ .Select(i => new GridConfigCollection($"Цикл {i}", ["Шоу"]))
+ .ToList();
+
+ Assert.False(Validate(Valid() with { Groups = groups }));
+ Assert.False(Validate(Valid() with { Collections = collections }));
+ }
+
+ [Fact]
+ public void Prompt_AcceptsReferencesAndNotes()
+ {
+ var query = new BuildGridPromptQuery(Guid.NewGuid(), ["2×2"], "утром детское");
+
+ Assert.True(new BuildGridPromptQueryValidator().Validate(query).IsValid);
+ }
+
+ [Fact]
+ public void Prompt_RejectsTooManyReferencesAndEmptyOnes()
+ {
+ var validator = new BuildGridPromptQueryValidator();
+ var many = Enumerable.Range(0, 17).Select(i => $"Канал {i}").ToList();
+
+ Assert.False(
+ validator.Validate(new BuildGridPromptQuery(Guid.NewGuid(), many, null)).IsValid
+ );
+ Assert.False(
+ validator.Validate(new BuildGridPromptQuery(Guid.NewGuid(), [" "], null)).IsValid
+ );
+ }
+
+ [Fact]
+ public void Prompt_RejectsHugeNotes()
+ {
+ var notes = new string('x', 4001);
+
+ Assert.False(
+ new BuildGridPromptQueryValidator()
+ .Validate(new BuildGridPromptQuery(Guid.NewGuid(), [], notes))
+ .IsValid
+ );
+ }
+
+ private static bool Validate(GridConfig config) =>
+ new ImportGridCommandValidator()
+ .Validate(new ImportGridCommand(Guid.NewGuid(), config, Replace: false))
+ .IsValid;
+}