Enhance grid import/export functionality to support dynamic groups and collections
Updated the grid import/export process to allow for the inclusion of groups and collections directly from the configuration file. This enhancement ensures that missing groups and collections are created during import, improving the flexibility of the grid management system. Adjusted related classes and methods to accommodate these changes, ensuring a cohesive integration. Enhanced documentation and user prompts to clarify the new functionality and its usage.
This commit is contained in:
@@ -38,7 +38,12 @@ public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.GroupRequired);
|
||||
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
// Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит
|
||||
// слоты в том же прогоне, а запрос увидел бы их только после сохранения.
|
||||
var known =
|
||||
dbContext.Groups.Local.Any(g => g.Id == groupId)
|
||||
|| await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -19,7 +19,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
|
||||
/// обрезается, и об обрезке прямо написано в запросе.
|
||||
/// </summary>
|
||||
private const int ShowLimit = 150;
|
||||
private const int ShowLimit = 300;
|
||||
|
||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
||||
|
||||
@@ -116,8 +116,9 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
}
|
||||
|
||||
text.AppendLine(
|
||||
"Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько "
|
||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы."
|
||||
"Эти группы уже собраны — их имена можно ставить в слоты как есть. «Единиц» — сколько "
|
||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы. "
|
||||
+ "Если нужного разреза библиотеки здесь нет, собери свою группу в разделе groups."
|
||||
);
|
||||
text.AppendLine();
|
||||
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
|
||||
@@ -149,8 +150,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
}
|
||||
|
||||
text.AppendLine(
|
||||
$"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; "
|
||||
+ "слоты всё равно ссылаются на группы, а не на шоу."
|
||||
$"Всего готового к эфиру: {shows.Count}. Названия — ключ: собирая свои группы, пиши их "
|
||||
+ "дословно отсюда, иначе шоу не найдётся при загрузке."
|
||||
);
|
||||
text.AppendLine();
|
||||
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
|
||||
|
||||
+105
-3
@@ -3,12 +3,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
|
||||
public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
||||
public sealed class ExportGridQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
DynamicGroupResolver dynamicResolver
|
||||
) : IQueryHandler<ExportGridQuery, Result<GridConfig>>
|
||||
{
|
||||
public async Task<Result<GridConfig>> Handle(
|
||||
ExportGridQuery query,
|
||||
@@ -54,6 +57,21 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Состав групп едет вместе с сеткой: на другой установке тех же групп нет, а слот без
|
||||
// группы поставить некуда. Собираем только использованные — выгружать всю библиотеку
|
||||
// ради одного канала незачем.
|
||||
var used = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Select(s => s.Group)
|
||||
.Append(Name(template.FallbackGroupId, groups))
|
||||
.OfType<string>()
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var (exportedGroups, exportedCollections) = await ExportGroupsAsync(
|
||||
used,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(
|
||||
new GridConfig(
|
||||
layers,
|
||||
@@ -64,11 +82,95 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
|
||||
),
|
||||
Rules: PlanningRules.FromJson(template.RulesJson),
|
||||
FallbackGroup: Name(template.FallbackGroupId, groups),
|
||||
DefaultJunction: Name(template.DefaultJunctionId, junctions)
|
||||
DefaultJunction: Name(template.DefaultJunctionId, junctions),
|
||||
Collections: exportedCollections,
|
||||
Groups: exportedGroups
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Состав использованных групп и коллекций именами. Динамическая группа выгружается снимком
|
||||
/// вычисленного состава, а не правилом: правило ссылается на жанры и рейтинги этой установки,
|
||||
/// и на чужой переехало бы бессмыслицей.
|
||||
/// </summary>
|
||||
private async Task<(
|
||||
List<GridConfigGroup> Groups,
|
||||
List<GridConfigCollection> Collections
|
||||
)> ExportGroupsAsync(IReadOnlySet<string> used, CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showNames = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
var collectionNames = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
|
||||
|
||||
var exported = new List<GridConfigGroup>();
|
||||
var collectionIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var group in groups.Where(g => used.Contains(g.Name.Trim())))
|
||||
{
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
|
||||
var shows = composition
|
||||
.Where(e => e.Kind == GroupElementKind.Show)
|
||||
.Select(e => showNames.GetValueOrDefault(e.Id))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
var collections = new List<string>();
|
||||
foreach (var element in composition.Where(e => e.Kind == GroupElementKind.Collection))
|
||||
{
|
||||
if (collectionNames.GetValueOrDefault(element.Id) is not { } name)
|
||||
continue;
|
||||
collections.Add(name);
|
||||
collectionIds.Add(element.Id);
|
||||
}
|
||||
|
||||
exported.Add(
|
||||
new GridConfigGroup(
|
||||
group.Name,
|
||||
shows.Count > 0 ? shows : null,
|
||||
collections.Count > 0 ? collections : null,
|
||||
group.Description
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var items = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var exportedCollections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => collectionIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (
|
||||
exported,
|
||||
[
|
||||
.. exportedCollections.Select(collection => new GridConfigCollection(
|
||||
collection.Name,
|
||||
[
|
||||
.. items
|
||||
.Where(i => i.CollectionId == collection.Id)
|
||||
.Select(i => showNames.GetValueOrDefault(i.ShowId))
|
||||
.OfType<string>(),
|
||||
],
|
||||
collection.Description
|
||||
)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private static GridConfigSlot ToConfig(
|
||||
Slot slot,
|
||||
IReadOnlyDictionary<Guid, string> groups,
|
||||
|
||||
@@ -44,6 +44,28 @@ public sealed record GridConfigLayer(
|
||||
LayerApplicability? Applicability = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Коллекция в формате обмена: франшиза или цикл, собранный из шоу по названиям. Нужна там, где
|
||||
/// в слот ставится не одно шоу, а серия фильмов целиком.
|
||||
/// </summary>
|
||||
public sealed record GridConfigCollection(
|
||||
string Name,
|
||||
IReadOnlyList<string> Shows,
|
||||
string? Description = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Группа в формате обмена: список шоу и коллекций по названиям. Слот ссылается на группу, поэтому
|
||||
/// собрать сетку под библиотеку, где групп ещё нет, можно только вместе с ними — импорт заводит
|
||||
/// недостающие сам.
|
||||
/// </summary>
|
||||
public sealed record GridConfigGroup(
|
||||
string Name,
|
||||
IReadOnlyList<string>? Shows = null,
|
||||
IReadOnlyList<string>? Collections = null,
|
||||
string? Description = null
|
||||
);
|
||||
|
||||
/// <summary>Параметры канала — переносятся справочно: импорт их не применяет.</summary>
|
||||
public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes);
|
||||
|
||||
@@ -61,7 +83,11 @@ public sealed record GridConfig(
|
||||
/// <summary>Имя аварийной группы — ею закрываются паузы между слотами.</summary>
|
||||
string? FallbackGroup = null,
|
||||
/// <summary>Имя стыка по умолчанию.</summary>
|
||||
string? DefaultJunction = null
|
||||
string? DefaultJunction = null,
|
||||
/// <summary>Коллекции, которых может не быть в библиотеке: импорт заводит недостающие.</summary>
|
||||
IReadOnlyList<GridConfigCollection>? Collections = null,
|
||||
/// <summary>Группы, на которые ссылаются слоты. Существующие по имени не трогаются.</summary>
|
||||
IReadOnlyList<GridConfigGroup>? Groups = null
|
||||
)
|
||||
{
|
||||
public const string CurrentFormat = "telewave.grid";
|
||||
|
||||
@@ -18,8 +18,17 @@ public static class GridPromptText
|
||||
- Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются
|
||||
им же следующего дня. Ночь после полуночи принадлежит предыдущему дню.
|
||||
- Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик
|
||||
из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка
|
||||
доступных ниже. Придумывать новые группы, шоу и стыки нельзя.
|
||||
из указанной группы. Поэтому группа задаётся именем.
|
||||
- Группы можно собирать самому: перечисли их в разделе groups, набрав из шоу библиотеки —
|
||||
«Вечерние сериалы», «Детское утро», «Кино 90-х». Тех, что уже есть в списке доступных
|
||||
групп, заводить заново не надо: имя оттуда просто ставится в слот. Группы с новым именем
|
||||
будут созданы при загрузке файла.
|
||||
- Коллекция — это цикл или франшиза («Терминатор», «Пираты Карибского моря»); собирается
|
||||
в разделе collections и потом целиком кладётся в группу. Нужна там, где в слот идёт серия
|
||||
фильмов, а не отдельное кино.
|
||||
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||
@@ -51,6 +60,17 @@ public static class GridPromptText
|
||||
},
|
||||
"fallbackGroup": "имя группы", // чем закрывать паузы
|
||||
"defaultJunction": "имя стыка", // стык между блоками по умолчанию
|
||||
"collections": [ // необязательно: циклы и франшизы из шоу
|
||||
{ "name": "Терминатор", "shows": ["Терминатор", "Терминатор 2"] }
|
||||
],
|
||||
"groups": [ // необязательно: группы, которых ещё нет
|
||||
{
|
||||
"name": "Вечерние сериалы",
|
||||
"shows": ["Симпсоны", "Футурама"], // названия дословно из библиотеки
|
||||
"collections": ["Терминатор"], // целиком, если нужен весь цикл
|
||||
"description": "чем канал занимает прайм"
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
{
|
||||
"name": "Основная сетка",
|
||||
|
||||
@@ -21,6 +21,9 @@ public sealed record GridImportResultDto(
|
||||
int Slots,
|
||||
int Skipped,
|
||||
int Removed,
|
||||
/// <summary>Сколько групп и коллекций файл принёс с собой — существующие не пересоздаются.</summary>
|
||||
int Groups,
|
||||
int Collections,
|
||||
IReadOnlyList<string> Warnings
|
||||
);
|
||||
|
||||
@@ -35,6 +38,12 @@ public sealed class ImportGridCommandValidator : AbstractValidator<ImportGridCom
|
||||
RuleFor(x => x.Config.Layers).NotEmpty();
|
||||
// Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч.
|
||||
RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64);
|
||||
RuleFor(x => x.Config.Groups!.Count)
|
||||
.LessThanOrEqualTo(256)
|
||||
.When(x => x.Config.Groups is not null);
|
||||
RuleFor(x => x.Config.Collections!.Count)
|
||||
.LessThanOrEqualTo(256)
|
||||
.When(x => x.Config.Collections is not null);
|
||||
RuleForEach(x => x.Config.Layers)
|
||||
.ChildRules(layer =>
|
||||
{
|
||||
|
||||
+133
-7
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
@@ -11,10 +13,17 @@ namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
|
||||
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
|
||||
///
|
||||
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
|
||||
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
|
||||
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
|
||||
///
|
||||
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
|
||||
/// </summary>
|
||||
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
|
||||
: ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||
public sealed class ImportGridCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
SlotWriter writer,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||
{
|
||||
public async Task<Result<GridImportResultDto>> Handle(
|
||||
ImportGridCommand command,
|
||||
@@ -29,16 +38,22 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
if (template is null)
|
||||
return Result.Failure<GridImportResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var groups = await NamesAsync(
|
||||
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
var junctions = await NamesAsync(
|
||||
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла
|
||||
// обязаны существовать к моменту, когда до него дойдёт очередь.
|
||||
var collections = await EnsureCollectionsAsync(command.Config, warnings, cancellationToken);
|
||||
var (groups, createdGroups) = await EnsureGroupsAsync(
|
||||
command.Config,
|
||||
collections.Names,
|
||||
warnings,
|
||||
cancellationToken
|
||||
);
|
||||
var removed = command.Replace ? Clear(template) : 0;
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
@@ -79,11 +94,122 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
created,
|
||||
skipped,
|
||||
removed,
|
||||
createdGroups,
|
||||
collections.Created,
|
||||
warnings
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
|
||||
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
|
||||
/// </summary>
|
||||
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureCollectionsAsync(
|
||||
GridConfig config,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var names = await NamesAsync(
|
||||
dbContext.Collections.AsNoTracking().Select(c => new NamedRef(c.Id, c.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
if (config.Collections is not { Count: > 0 } wanted)
|
||||
return (names, 0);
|
||||
|
||||
var shows = await ShowNamesAsync(cancellationToken);
|
||||
var created = 0;
|
||||
|
||||
foreach (var configCollection in wanted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configCollection.Name))
|
||||
continue;
|
||||
if (names.ContainsKey(configCollection.Name.Trim()))
|
||||
continue;
|
||||
|
||||
var collection = Collection.Create(configCollection.Name, configCollection.Description);
|
||||
foreach (var showName in configCollection.Shows ?? [])
|
||||
{
|
||||
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
|
||||
collection.AddShow(showId);
|
||||
}
|
||||
|
||||
dbContext.Collections.Add(collection);
|
||||
names[collection.Name] = collection.Id;
|
||||
created++;
|
||||
}
|
||||
|
||||
return (names, created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов.
|
||||
/// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает,
|
||||
/// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит
|
||||
/// в слотах.
|
||||
/// </summary>
|
||||
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureGroupsAsync(
|
||||
GridConfig config,
|
||||
IReadOnlyDictionary<string, Guid> collections,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var names = await NamesAsync(
|
||||
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
if (config.Groups is not { Count: > 0 } wanted)
|
||||
return (names, 0);
|
||||
|
||||
var shows = await ShowNamesAsync(cancellationToken);
|
||||
var created = 0;
|
||||
|
||||
foreach (var configGroup in wanted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configGroup.Name))
|
||||
continue;
|
||||
if (names.ContainsKey(configGroup.Name.Trim()))
|
||||
continue;
|
||||
|
||||
var group = Group.Create(configGroup.Name, configGroup.Description);
|
||||
|
||||
foreach (var showName in configGroup.Shows ?? [])
|
||||
{
|
||||
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
|
||||
group.AddElement(GroupElementKind.Show, showId);
|
||||
}
|
||||
|
||||
foreach (var collectionName in configGroup.Collections ?? [])
|
||||
{
|
||||
if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id)
|
||||
group.AddElement(GroupElementKind.Collection, id);
|
||||
}
|
||||
|
||||
if (group.Items.Count == 0)
|
||||
warnings.Add(
|
||||
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
|
||||
);
|
||||
|
||||
dbContext.Groups.Add(group);
|
||||
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
|
||||
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
names[group.Name] = group.Id;
|
||||
created++;
|
||||
}
|
||||
|
||||
return (names, created);
|
||||
}
|
||||
|
||||
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
|
||||
private Task<Dictionary<string, Guid>> ShowNamesAsync(CancellationToken cancellationToken) =>
|
||||
NamesAsync(
|
||||
dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
|
||||
private static int Clear(ScheduleTemplate template)
|
||||
{
|
||||
@@ -214,7 +340,7 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter
|
||||
if (known.TryGetValue(name.Trim(), out var id))
|
||||
return id;
|
||||
|
||||
var warning = $"{subject} «{name}» не найдена в библиотеке.";
|
||||
var warning = $"{subject} «{name}»: в библиотеке такого нет.";
|
||||
if (!warnings.Contains(warning, StringComparer.Ordinal))
|
||||
warnings.Add(warning);
|
||||
return null;
|
||||
|
||||
@@ -95,7 +95,7 @@ public class GridTransferTests
|
||||
private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new ExportGridQueryHandler(db).Handle(
|
||||
var result = await new ExportGridQueryHandler(db, GroupServices.Dynamic(db)).Handle(
|
||||
new ExportGridQuery(channelId),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -111,7 +111,7 @@ public class GridTransferTests
|
||||
)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle(
|
||||
var result = await new ImportGridCommandHandler(db, new SlotWriter(db), Stats(db)).Handle(
|
||||
new ImportGridCommand(channelId, config, replace),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -291,6 +291,179 @@ public class GridTransferTests
|
||||
Assert.True(stored.HasPendingChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_CreatesGroupsFromShows()
|
||||
{
|
||||
// Сетка, собранная ИИ под ещё не разобранную библиотеку: групп нет, и положить слоты
|
||||
// некуда — значит файл приносит группы с собой.
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||
|
||||
var config = new GridConfig(
|
||||
[
|
||||
new GridConfigLayer(
|
||||
"Основная сетка",
|
||||
10,
|
||||
[
|
||||
new GridConfigSlot(
|
||||
"Вечерний блок",
|
||||
new TimeOnly(20, 0),
|
||||
120,
|
||||
Daypart.Prime,
|
||||
Group: "Вечерние сериалы"
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
Groups: [new GridConfigGroup("Вечерние сериалы", Shows: ["Сериал"])]
|
||||
);
|
||||
|
||||
var imported = await ImportAsync(fixture, channelId, config);
|
||||
|
||||
Assert.Equal(1, imported.Groups);
|
||||
Assert.Equal(1, imported.Slots);
|
||||
Assert.Empty(imported.Warnings);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var group = await db
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstAsync(g => g.Name == "Вечерние сериалы", CancellationToken.None);
|
||||
Assert.Single(group.Items);
|
||||
// Статистика — кэш карточки группы, и пустой после создания она быть не должна.
|
||||
Assert.Equal(1, group.UnitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_CreatesCollections_AndPutsThemIntoGroups()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||
|
||||
var config = new GridConfig(
|
||||
[
|
||||
new GridConfigLayer(
|
||||
"Основная сетка",
|
||||
10,
|
||||
[
|
||||
new GridConfigSlot(
|
||||
"Кино",
|
||||
new TimeOnly(20, 0),
|
||||
120,
|
||||
Daypart.Prime,
|
||||
Group: "Циклы"
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
Collections: [new GridConfigCollection("Франшиза", ["Сериал"])],
|
||||
Groups: [new GridConfigGroup("Циклы", Collections: ["Франшиза"])]
|
||||
);
|
||||
|
||||
var imported = await ImportAsync(fixture, channelId, config);
|
||||
|
||||
Assert.Equal(1, imported.Collections);
|
||||
Assert.Equal(1, imported.Groups);
|
||||
Assert.Empty(imported.Warnings);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var collection = await db
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstAsync(c => c.Name == "Франшиза", CancellationToken.None);
|
||||
Assert.Single(collection.Items);
|
||||
|
||||
var group = await db
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstAsync(g => g.Name == "Циклы", CancellationToken.None);
|
||||
var item = Assert.Single(group.Items);
|
||||
Assert.Equal(GroupElementKind.Collection, item.ElementKind);
|
||||
Assert.Equal(collection.Id, item.ElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_DoesNotTouchExistingGroup()
|
||||
{
|
||||
// Группа с этим именем уже стоит в слотах соседнего канала: переписать её состав чужим
|
||||
// файлом значит молча перекроить чужой эфир.
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||
|
||||
var config = new GridConfig(
|
||||
[new GridConfigLayer("Основная сетка", 10, [])],
|
||||
Groups: [new GridConfigGroup("Сериалы", Shows: ["Сериал", "Сериал"])]
|
||||
);
|
||||
|
||||
var imported = await ImportAsync(fixture, channelId, config);
|
||||
|
||||
Assert.Equal(0, imported.Groups);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var group = await db
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstAsync(g => g.Name == "Сериалы", CancellationToken.None);
|
||||
Assert.Single(group.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_WarnsAboutUnknownShow_AndEmptyGroup()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture, withSlot: false);
|
||||
|
||||
var config = new GridConfig(
|
||||
[new GridConfigLayer("Основная сетка", 10, [])],
|
||||
Groups: [new GridConfigGroup("Придуманное", Shows: ["Шоу, которого нет"])]
|
||||
);
|
||||
|
||||
var imported = await ImportAsync(fixture, channelId, config);
|
||||
|
||||
Assert.Equal(1, imported.Groups);
|
||||
Assert.Contains(
|
||||
imported.Warnings,
|
||||
w => w.Contains("Шоу, которого нет", StringComparison.Ordinal)
|
||||
);
|
||||
// Пустая группа — это дыра в эфире, и сказать об этом надо на импорте, а не после эфира.
|
||||
Assert.Contains(
|
||||
imported.Warnings,
|
||||
w => w.Contains("создана пустой", StringComparison.Ordinal)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_CarriesCompositionOfUsedGroups()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture);
|
||||
|
||||
var config = await ExportAsync(fixture, channelId);
|
||||
|
||||
// Файл должен быть самодостаточным: на другой установке этих групп нет, а слот без группы
|
||||
// положить некуда.
|
||||
var group = Assert.Single(config.Groups ?? []);
|
||||
Assert.Equal("Сериалы", group.Name);
|
||||
Assert.Equal(["Сериал"], group.Shows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_ExplainsHowToBuildGroups()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channelId = await SeedAsync(fixture);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
|
||||
new BuildGridPromptQuery(channelId, [], null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var prompt = result.Value.Prompt;
|
||||
|
||||
// Модель должна знать и что группы можно собрать самой, и что названия шоу берутся дословно.
|
||||
Assert.Contains("\"groups\"", prompt, StringComparison.Ordinal);
|
||||
Assert.Contains("\"collections\"", prompt, StringComparison.Ordinal);
|
||||
Assert.Contains("дословно", prompt, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_ListsGroupsAndSchema()
|
||||
{
|
||||
@@ -375,4 +548,7 @@ public class GridTransferTests
|
||||
|
||||
private static GroupCatalog Catalog(IAppDbContext db) =>
|
||||
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
|
||||
|
||||
private static GroupStatsService Stats(IAppDbContext db) =>
|
||||
new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user