diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 2f43630..dce1665 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -34,9 +34,15 @@ public sealed class UpdateGroupCommandHandler( } /// - /// Переход в статический режим материализует вычисленный состав: правило домен не понимает, - /// а группа не должна опустеть от смены режима — на неё уже ссылаются слоты. Обратный переход - /// домен делает сам (список становится закреплённым). + /// Смена режима набора. Домен сбрасывает список позиций — в двух режимах он значит разное, + /// — а прикладной слой достраивает то, чего домену знать не положено. + /// + /// В статическом режиме список и есть состав, поэтому переход туда материализует вычисленный + /// правилом: группа не должна опустеть от смены режима — на неё уже ссылаются слоты. + /// + /// В динамическом состав считает правило, а список хранит только ручные поправки — и начинается + /// он пустым. Прежние позиции, перенесённые закреплёнными, отменяли бы сам переход: сузить набор + /// правилом было бы нечем. /// private async Task SwitchModeAsync( Domain.Programming.Group group, @@ -44,14 +50,14 @@ public sealed class UpdateGroupCommandHandler( CancellationToken cancellationToken ) { - if (mode == GroupMode.Dynamic) - { - group.SetMode(mode); - return; - } + // Состав считаем до смены режима: после неё правило уже применяется к пустому списку. + var composition = + mode == GroupMode.Static + ? await dynamicResolver.ResolveAsync(group, cancellationToken) + : []; - var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); group.SetMode(mode); + foreach (var element in composition) group.AddElement(element.Kind, element.Id); } diff --git a/backend/src/TeleWave.Domain/Programming/Group.cs b/backend/src/TeleWave.Domain/Programming/Group.cs index ee3bd44..71697a5 100644 --- a/backend/src/TeleWave.Domain/Programming/Group.cs +++ b/backend/src/TeleWave.Domain/Programming/Group.cs @@ -81,22 +81,24 @@ public class Group /// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен /// правило не интерпретирует. Материализует его вызывающая сторона до смены режима. /// + /// + /// Меняет режим набора. Список позиций при этом **сбрасывается**: в двух режимах он означает + /// разное, и переносить его как есть — значит соврать про то, чем группа стала. + /// + /// Прежний состав, оставленный закреплённым, отменял бы саму суть перехода: правило считало бы + /// состав заново, но старые позиции оставались бы в нём навсегда, и «по правилу» на деле + /// означало бы «по правилу плюс всё, что было». Сузить набор правилом стало бы невозможно. + /// + /// Обратный переход наполняет список вычисленным составом — это делает прикладной слой: правило + /// живёт там, домен его не интерпретирует. + /// public void SetMode(GroupMode mode) { if (Mode == mode) return; Mode = mode; - if (mode == GroupMode.Dynamic) - { - foreach (var item in _items) - item.SetRole(GroupItemRole.Pinned); - return; - } - - _items.RemoveAll(i => i.Role == GroupItemRole.Excluded); - foreach (var item in _items) - item.SetRole(GroupItemRole.Member); + _items.Clear(); } /// Записать пересчитанную статистику. diff --git a/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs index 4421b7f..f09c019 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/DynamicGroupsTests.cs @@ -29,8 +29,9 @@ public class DynamicGroupsTests var group = Group.Create("Мультфильмы"); group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); - group.AddElement(GroupElementKind.Show, pinned.Id); group.SetMode(GroupMode.Dynamic); + // Закрепляют уже в динамическом режиме: там список — это поправки к правилу. + group.AddElement(GroupElementKind.Show, pinned.Id); await using (var seed = fixture.New()) { @@ -97,17 +98,24 @@ public class DynamicGroupsTests } [Fact] - public async Task SwitchToDynamic_KeepsExistingItemsAsPinned() + public async Task SwitchToDynamic_DropsTheOldListAndLetsTheRuleDecide() { - // Смена режима не должна вымывать из эфира то, что уже стояло в слотах. + // Переключили «по правилу» — значит состав считает правило. Прежний список, перенесённый + // закреплённым, оставался бы в группе навсегда: сузить набор правилом было бы нечем, + // и «динамическая» отличалась бы от статической только названием. var fixture = new TestDb(); - var show = Show.Create("A", ShowKind.Series); + var genre = Genre.Create("Мультфильм", "animation"); + var byRule = WithGenre(Show.Create("По правилу", ShowKind.Series), genre); + var old = Show.Create("Из старого списка", ShowKind.Series); + var group = Group.Create("Группа"); - group.AddElement(GroupElementKind.Show, show.Id); + group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); + group.AddElement(GroupElementKind.Show, old.Id); await using (var seed = fixture.New()) { - seed.Shows.Add(show); + seed.Genres.Add(genre); + seed.Shows.AddRange(byRule, old); seed.Groups.Add(group); await seed.SaveChangesAsync(CancellationToken.None); } @@ -117,7 +125,11 @@ public class DynamicGroupsTests await using var check = fixture.New(); var stored = check.Groups.Include(g => g.Items).Single(); Assert.Equal(GroupMode.Dynamic, stored.Mode); - Assert.Equal(GroupItemRole.Pinned, stored.Items.Single().Role); + Assert.Empty(stored.Items); + + // В составе — только то, что нашло правило: шоу из прежнего списка под него не подходит. + var composition = await ResolveAsync(fixture, group.Id); + Assert.Equal([byRule.Id], composition.Select(e => e.Id)); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs index 2b4c298..df95b0e 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GetGroupTests.cs @@ -50,8 +50,9 @@ public class GetGroupTests var group = Group.Create("Динамическая"); group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); - group.AddElement(GroupElementKind.Show, pinned.Id); group.SetMode(GroupMode.Dynamic); + // Закрепляют уже в динамическом режиме: там список — это поправки к правилу. + group.AddElement(GroupElementKind.Show, pinned.Id); await using (var seed = fixture.New()) { diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs index f79cbcc..d55bca6 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs @@ -103,22 +103,26 @@ public class GroupTests } [Fact] - public void SetMode_ToDynamic_KeepsItemsAsPinned() + public void SetMode_ToDynamic_DropsTheOldList() { - // Смена режима не должна вымывать из эфира то, что уже стояло в слотах. + // Прежний состав, перенесённый закреплённым, отменял бы сам переход: правило считало бы + // набор заново, а старые позиции оставались бы в нём навсегда — сузить группу правилом + // стало бы нечем. var group = Group.Create("Боевики"); - var show = Guid.NewGuid(); - group.AddElement(GroupElementKind.Show, show); + group.AddElement(GroupElementKind.Show, Guid.NewGuid()); + group.AddElement(GroupElementKind.Show, Guid.NewGuid()); group.SetMode(GroupMode.Dynamic); Assert.Equal(GroupMode.Dynamic, group.Mode); - Assert.Equal(GroupItemRole.Pinned, group.Items.Single().Role); + Assert.Empty(group.Items); } [Fact] - public void SetMode_BackToStatic_DropsExclusionsAndUnpins() + public void SetMode_BackToStatic_DropsCorrectionsToo() { + // В обратную сторону симметрично: закрепления и исключения — поправки к правилу, и без + // правила они бессмысленны. Состав статической группе выдаёт прикладной слой. var group = Group.Create("Боевики"); group.SetMode(GroupMode.Dynamic); group.AddElement(GroupElementKind.Show, Guid.NewGuid()); @@ -126,9 +130,7 @@ public class GroupTests group.SetMode(GroupMode.Static); - // Исключать в статической группе нечего: состав и есть список. - var item = Assert.Single(group.Items); - Assert.Equal(GroupItemRole.Member, item.Role); + Assert.Empty(group.Items); } [Fact] diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 3a76ef5..325e90d 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -210,6 +210,16 @@ GroupItem Новые поступления не пропадают: фоновая проверка сравнивает библиотеку с фильтром и показывает в UI «доступно 12 новых позиций», добавление — по кнопке. +**Смена режима собирает список заново.** В статической группе список — это состав, в динамической — +только поправки к правилу (закреплено / исключено), и одно и то же значить сразу двумя вещами +не может. Поэтому переход в «по правилу» **сбрасывает** прежние позиции: перенесённые закреплёнными, +они остались бы в наборе навсегда, правило считало бы состав «плюс всё, что было», и сузить группу +им стало бы нечем — динамическая отличалась бы от статической только названием. Обратный переход +симметричен: список чистится и наполняется тем, что вычислило правило (это делает прикладной слой — +домен правила не интерпретирует), чтобы группа не опустела от смены режима: на неё уже ссылаются +слоты. В интерфейсе смена режима подтверждается — состав пересобирается, и знать об этом надо +до нажатия, а не после. + ```json { "elementKinds": ["show", "collection"], diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index e10b779..4a0c629 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -209,7 +209,12 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { {t('admin.groups.mode.label')}: