Refactor group mode switching logic to ensure proper list management
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 6m21s

Updated the Group and UpdateGroupCommandHandler classes to clarify the behavior of group mode switching. The transition to dynamic mode now clears the existing item list to prevent old items from interfering with the new rule-based composition. Enhanced documentation and comments to explain the implications of mode changes. Updated tests to reflect the new behavior, ensuring that the group correctly handles item lists during mode transitions. Localization strings were also updated to inform users about the changes in item management during mode switching.
This commit is contained in:
Leonid Pershin
2026-07-31 04:22:36 +03:00
parent 7163a0b937
commit 375e0a810b
9 changed files with 79 additions and 37 deletions
@@ -34,9 +34,15 @@ public sealed class UpdateGroupCommandHandler(
} }
/// <summary> /// <summary>
/// Переход в статический режим материализует вычисленный состав: правило домен не понимает, /// Смена режима набора. Домен сбрасывает список позиций — в двух режимах он значит разное,
/// а группа не должна опустеть от смены режима — на неё уже ссылаются слоты. Обратный переход /// — а прикладной слой достраивает то, чего домену знать не положено.
/// домен делает сам (список становится закреплённым). ///
/// В статическом режиме список и есть состав, поэтому переход туда материализует вычисленный
/// правилом: группа не должна опустеть от смены режима — на неё уже ссылаются слоты.
///
/// В динамическом состав считает правило, а список хранит только ручные поправки — и начинается
/// он пустым. Прежние позиции, перенесённые закреплёнными, отменяли бы сам переход: сузить набор
/// правилом было бы нечем.
/// </summary> /// </summary>
private async Task SwitchModeAsync( private async Task SwitchModeAsync(
Domain.Programming.Group group, Domain.Programming.Group group,
@@ -44,14 +50,14 @@ public sealed class UpdateGroupCommandHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (mode == GroupMode.Dynamic) // Состав считаем до смены режима: после неё правило уже применяется к пустому списку.
{ var composition =
group.SetMode(mode); mode == GroupMode.Static
return; ? await dynamicResolver.ResolveAsync(group, cancellationToken)
} : [];
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
group.SetMode(mode); group.SetMode(mode);
foreach (var element in composition) foreach (var element in composition)
group.AddElement(element.Kind, element.Id); group.AddElement(element.Kind, element.Id);
} }
@@ -81,22 +81,24 @@ public class Group
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен /// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима. /// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
/// </summary> /// </summary>
/// <summary>
/// Меняет режим набора. Список позиций при этом **сбрасывается**: в двух режимах он означает
/// разное, и переносить его как есть — значит соврать про то, чем группа стала.
///
/// Прежний состав, оставленный закреплённым, отменял бы саму суть перехода: правило считало бы
/// состав заново, но старые позиции оставались бы в нём навсегда, и «по правилу» на деле
/// означало бы «по правилу плюс всё, что было». Сузить набор правилом стало бы невозможно.
///
/// Обратный переход наполняет список вычисленным составом — это делает прикладной слой: правило
/// живёт там, домен его не интерпретирует.
/// </summary>
public void SetMode(GroupMode mode) public void SetMode(GroupMode mode)
{ {
if (Mode == mode) if (Mode == mode)
return; return;
Mode = mode; Mode = mode;
if (mode == GroupMode.Dynamic) _items.Clear();
{
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);
} }
/// <summary>Записать пересчитанную статистику.</summary> /// <summary>Записать пересчитанную статистику.</summary>
@@ -29,8 +29,9 @@ public class DynamicGroupsTests
var group = Group.Create("Мультфильмы"); var group = Group.Create("Мультфильмы");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.AddElement(GroupElementKind.Show, pinned.Id);
group.SetMode(GroupMode.Dynamic); group.SetMode(GroupMode.Dynamic);
// Закрепляют уже в динамическом режиме: там список — это поправки к правилу.
group.AddElement(GroupElementKind.Show, pinned.Id);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
@@ -97,17 +98,24 @@ public class DynamicGroupsTests
} }
[Fact] [Fact]
public async Task SwitchToDynamic_KeepsExistingItemsAsPinned() public async Task SwitchToDynamic_DropsTheOldListAndLetsTheRuleDecide()
{ {
// Смена режима не должна вымывать из эфира то, что уже стояло в слотах. // Переключили «по правилу» — значит состав считает правило. Прежний список, перенесённый
// закреплённым, оставался бы в группе навсегда: сузить набор правилом было бы нечем,
// и «динамическая» отличалась бы от статической только названием.
var fixture = new TestDb(); 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("Группа"); 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()) await using (var seed = fixture.New())
{ {
seed.Shows.Add(show); seed.Genres.Add(genre);
seed.Shows.AddRange(byRule, old);
seed.Groups.Add(group); seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
@@ -117,7 +125,11 @@ public class DynamicGroupsTests
await using var check = fixture.New(); await using var check = fixture.New();
var stored = check.Groups.Include(g => g.Items).Single(); var stored = check.Groups.Include(g => g.Items).Single();
Assert.Equal(GroupMode.Dynamic, stored.Mode); 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] [Fact]
@@ -50,8 +50,9 @@ public class GetGroupTests
var group = Group.Create("Динамическая"); var group = Group.Create("Динамическая");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson()); group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.AddElement(GroupElementKind.Show, pinned.Id);
group.SetMode(GroupMode.Dynamic); group.SetMode(GroupMode.Dynamic);
// Закрепляют уже в динамическом режиме: там список — это поправки к правилу.
group.AddElement(GroupElementKind.Show, pinned.Id);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
@@ -103,22 +103,26 @@ public class GroupTests
} }
[Fact] [Fact]
public void SetMode_ToDynamic_KeepsItemsAsPinned() public void SetMode_ToDynamic_DropsTheOldList()
{ {
// Смена режима не должна вымывать из эфира то, что уже стояло в слотах. // Прежний состав, перенесённый закреплённым, отменял бы сам переход: правило считало бы
// набор заново, а старые позиции оставались бы в нём навсегда — сузить группу правилом
// стало бы нечем.
var group = Group.Create("Боевики"); var group = Group.Create("Боевики");
var show = Guid.NewGuid(); group.AddElement(GroupElementKind.Show, Guid.NewGuid());
group.AddElement(GroupElementKind.Show, show); group.AddElement(GroupElementKind.Show, Guid.NewGuid());
group.SetMode(GroupMode.Dynamic); group.SetMode(GroupMode.Dynamic);
Assert.Equal(GroupMode.Dynamic, group.Mode); Assert.Equal(GroupMode.Dynamic, group.Mode);
Assert.Equal(GroupItemRole.Pinned, group.Items.Single().Role); Assert.Empty(group.Items);
} }
[Fact] [Fact]
public void SetMode_BackToStatic_DropsExclusionsAndUnpins() public void SetMode_BackToStatic_DropsCorrectionsToo()
{ {
// В обратную сторону симметрично: закрепления и исключения — поправки к правилу, и без
// правила они бессмысленны. Состав статической группе выдаёт прикладной слой.
var group = Group.Create("Боевики"); var group = Group.Create("Боевики");
group.SetMode(GroupMode.Dynamic); group.SetMode(GroupMode.Dynamic);
group.AddElement(GroupElementKind.Show, Guid.NewGuid()); group.AddElement(GroupElementKind.Show, Guid.NewGuid());
@@ -126,9 +130,7 @@ public class GroupTests
group.SetMode(GroupMode.Static); group.SetMode(GroupMode.Static);
// Исключать в статической группе нечего: состав и есть список. Assert.Empty(group.Items);
var item = Assert.Single(group.Items);
Assert.Equal(GroupItemRole.Member, item.Role);
} }
[Fact] [Fact]
+10
View File
@@ -210,6 +210,16 @@ GroupItem
Новые поступления не пропадают: фоновая проверка сравнивает библиотеку с фильтром и показывает в UI Новые поступления не пропадают: фоновая проверка сравнивает библиотеку с фильтром и показывает в UI
«доступно 12 новых позиций», добавление — по кнопке. «доступно 12 новых позиций», добавление — по кнопке.
**Смена режима собирает список заново.** В статической группе список — это состав, в динамической —
только поправки к правилу (закреплено / исключено), и одно и то же значить сразу двумя вещами
не может. Поэтому переход в «по правилу» **сбрасывает** прежние позиции: перенесённые закреплёнными,
они остались бы в наборе навсегда, правило считало бы состав «плюс всё, что было», и сузить группу
им стало бы нечем — динамическая отличалась бы от статической только названием. Обратный переход
симметричен: список чистится и наполняется тем, что вычислило правило (это делает прикладной слой —
домен правила не интерпретирует), чтобы группа не опустела от смены режима: на неё уже ссылаются
слоты. В интерфейсе смена режима подтверждается — состав пересобирается, и знать об этом надо
до нажатия, а не после.
```json ```json
{ {
"elementKinds": ["show", "collection"], "elementKinds": ["show", "collection"],
@@ -209,7 +209,12 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
<span className="font-medium">{t('admin.groups.mode.label')}:</span> <span className="font-medium">{t('admin.groups.mode.label')}:</span>
<Select <Select
value={group.mode} value={group.mode}
onValueChange={(value) => saveMutation.mutate(value as GroupMode)} onValueChange={(value) => {
// Смена режима сбрасывает список: в статической он и есть состав, в динамической —
// только поправки к правилу. Переносить его как есть нельзя, но и терять молча тоже.
if (!window.confirm(t('admin.groups.mode.switchConfirm'))) return
saveMutation.mutate(value as GroupMode)
}}
> >
<SelectTrigger className="h-8 w-56"> <SelectTrigger className="h-8 w-56">
<SelectValue /> <SelectValue />
+2
View File
@@ -111,6 +111,8 @@ export const en = {
added: 'Items added: {{count}}', added: 'Items added: {{count}}',
alreadyIn: 'already in group', alreadyIn: 'already in group',
mode: { mode: {
switchConfirm:
'Switch the mode? The item list will be rebuilt: in a static group it is the composition itself, in a dynamic one — only corrections to the rule.',
label: 'Composition', label: 'Composition',
Static: 'Explicit list', Static: 'Explicit list',
Dynamic: 'By rule', Dynamic: 'By rule',
+2
View File
@@ -111,6 +111,8 @@ export const ru = {
added: 'Добавлено позиций: {{count}}', added: 'Добавлено позиций: {{count}}',
alreadyIn: 'уже в группе', alreadyIn: 'уже в группе',
mode: { mode: {
switchConfirm:
'Сменить режим? Список позиций будет собран заново: в статической группе он и есть состав, в динамической — только поправки к правилу.',
label: 'Состав', label: 'Состав',
Static: 'Явный список', Static: 'Явный список',
Dynamic: 'По правилу', Dynamic: 'По правилу',