Files
TeleWave/backend/src/TeleWave.Domain/Programming/Group.cs
T
Leonid Pershin 375e0a810b
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
Refactor group mode switching logic to ensure proper list management
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.
2026-07-31 04:22:36 +03:00

207 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace TeleWave.Domain.Programming;
/// <summary>
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
///
/// Откуда берётся состав, решает <see cref="Mode"/>:
///
/// • <see cref="GroupMode.Static"/> — состав это явный список (<see cref="Items"/>), а
/// <see cref="FilterJson"/> лишь помогает его наполнить: «применить фильтр» добавляет найденное
/// в список, дальше он правится руками.
///
/// • <see cref="GroupMode.Dynamic"/> — состав вычисляет правило в момент обращения, а список хранит
/// только ручные поправки к нему: закреплённое (<see cref="GroupItemRole.Pinned"/>) и исключённое
/// (<see cref="GroupItemRole.Excluded"/>). Само правило домен по-прежнему не интерпретирует —
/// вычисление живёт в Application.
///
/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится
/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку
/// перетасовывало бы будущий эфир всех каналов.
/// </summary>
public class Group
{
private readonly List<GroupItem> _items = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
/// <summary>Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.</summary>
public string? FilterJson { get; private set; }
/// <summary>Откуда берётся состав: явный список либо вычисление правилом.</summary>
public GroupMode Mode { get; private set; }
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
/// <summary>Число позиций в группе.</summary>
public int ItemCount { get; private set; }
/// <summary>Число единиц воспроизведения: у сериала и коллекции их больше одной.</summary>
public int UnitCount { get; private set; }
/// <summary>Суммарная длительность готовых единиц.</summary>
public TimeSpan TotalDuration { get; private set; }
public DateTimeOffset? StatsComputedAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Позиции группы (backing-field для EF); порядок — по <see cref="GroupItem.Position"/>.</summary>
public IReadOnlyList<GroupItem> Items => _items;
private Group() { }
public static Group Create(string name, string? description = null) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name, string? description)
{
Name = name.Trim();
Description = description;
}
/// <summary>Сохранить правило набора (null — снять). Состав при этом не меняется.</summary>
public void SetFilter(string? filterJson) =>
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
/// <summary>
/// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав
/// переносится, а не теряется: при переходе в динамический список позиций становится
/// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое
/// становится обычными позициями, а исключения снимаются — исключать больше нечего.
///
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
/// </summary>
/// <summary>
/// Меняет режим набора. Список позиций при этом **сбрасывается**: в двух режимах он означает
/// разное, и переносить его как есть — значит соврать про то, чем группа стала.
///
/// Прежний состав, оставленный закреплённым, отменял бы саму суть перехода: правило считало бы
/// состав заново, но старые позиции оставались бы в нём навсегда, и «по правилу» на деле
/// означало бы «по правилу плюс всё, что было». Сузить набор правилом стало бы невозможно.
///
/// Обратный переход наполняет список вычисленным составом — это делает прикладной слой: правило
/// живёт там, домен его не интерпретирует.
/// </summary>
public void SetMode(GroupMode mode)
{
if (Mode == mode)
return;
Mode = mode;
_items.Clear();
}
/// <summary>Записать пересчитанную статистику.</summary>
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
{
ItemCount = itemCount;
UnitCount = unitCount;
TotalDuration = totalDuration;
StatsComputedAt = at;
}
public bool Contains(GroupElementKind kind, Guid elementId) =>
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
/// <summary>
/// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по
/// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить»,
/// иначе правило вымело бы позицию на первом же обращении.
/// </summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null)
{
if (Contains(kind, elementId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole);
_items.Add(item);
return item;
}
private GroupItemRole DefaultRole =>
Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member;
/// <summary>
/// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать
/// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа
/// статическая — там состав правится списком, а исключать из списка нечего.
/// </summary>
public bool Exclude(GroupElementKind kind, Guid elementId)
{
if (Mode != GroupMode.Dynamic)
return false;
var existing = _items.FirstOrDefault(i =>
i.ElementKind == kind && i.ElementId == elementId
);
if (existing is not null)
{
existing.SetRole(GroupItemRole.Excluded);
return true;
}
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
_items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded));
return true;
}
/// <summary>Снять исключение — элемент вернётся в состав, если его находит правило.</summary>
public bool RemoveExclusion(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i =>
i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId
) > 0;
public bool RemoveItem(Guid itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
_items.Remove(item);
return true;
}
/// <summary>Убирает все позиции, ссылающиеся на элемент. Вызывается при удалении шоу/коллекции
/// из библиотеки — внешнего ключа на полиморфную ссылку нет. Возвращает число снятых позиций.</summary>
public int RemoveElement(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i => i.ElementKind == kind && i.ElementId == elementId);
public bool SetWeight(Guid itemId, int weight)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
item.SetWeight(weight);
return true;
}
/// <summary>
/// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них,
/// сохраняя относительный порядок, а неизвестные игнорируются — так перетаскивание одного
/// элемента не теряет остальные.
/// </summary>
public void Reorder(IEnumerable<Guid> itemIdsInOrder)
{
var known = _items.Select(i => i.Id).ToHashSet();
var requested = itemIdsInOrder.Distinct().Where(known.Contains).ToList();
var rest = _items
.Where(i => !requested.Contains(i.Id))
.OrderBy(i => i.Position)
.Select(i => i.Id);
var position = 0;
foreach (var itemId in requested.Concat(rest))
_items.First(i => i.Id == itemId).SetPosition(position++);
}
}