Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
This commit is contained in:
+3
-33
@@ -62,8 +62,6 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-backend, build-frontend]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
@@ -72,44 +70,16 @@ jobs:
|
||||
- name: Restore
|
||||
working-directory: backend
|
||||
run: dotnet restore TeleWave.slnx
|
||||
- name: Test + coverage
|
||||
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
|
||||
# а бейдж покрытия в README берётся из SonarCloud.
|
||||
- name: Test
|
||||
working-directory: backend
|
||||
run: >
|
||||
dotnet test TeleWave.slnx
|
||||
-c Release
|
||||
--no-restore
|
||||
--collect:"XPlat Code Coverage"
|
||||
--results-directory ./coverage
|
||||
--logger "console;verbosity=normal"
|
||||
|
||||
# Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и
|
||||
# force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL.
|
||||
- name: Coverage badge
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
working-directory: backend
|
||||
env:
|
||||
TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
dotnet tool install --global dotnet-reportgenerator-globaltool
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/badges" \
|
||||
"-reporttypes:Badges" \
|
||||
"-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \
|
||||
"-classfilters:-System.*"
|
||||
BADGE=coverage/badges/badge_linecoverage.svg
|
||||
test -f "$BADGE" || { echo "badge not generated"; exit 1; }
|
||||
WORK=$(mktemp -d)
|
||||
cp "$BADGE" "$WORK/coverage.svg"
|
||||
cd "$WORK"
|
||||
git init -q -b badges
|
||||
git add coverage.svg
|
||||
git -c user.name=gitea-actions -c user.email=actions@local \
|
||||
commit -q -m "coverage badge [skip ci]"
|
||||
git push -f \
|
||||
"https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges
|
||||
|
||||
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
|
||||
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
|
||||
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# TeleWave
|
||||
|
||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
|
||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
||||
хранилища на сервере, админ управляет каналами и пользователями.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине
|
||||
/// сегмента HLS, а названия «из/в» берутся из групп этого канала — иначе превью показывало бы
|
||||
/// случайные шоу из библиотеки.
|
||||
/// </summary>
|
||||
public class BumperPreviewTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static RenderBumperPreviewCommandHandler Handler(
|
||||
AppDbContext db,
|
||||
IBumperRenderer? renderer = null,
|
||||
IBumperTemplateStorage? storage = null,
|
||||
IImageStore? imageStore = null
|
||||
) =>
|
||||
new(
|
||||
db,
|
||||
renderer ?? Substitute.For<IBumperRenderer>(),
|
||||
storage ?? Substitute.For<IBumperTemplateStorage>(),
|
||||
imageStore ?? Substitute.For<IImageStore>(),
|
||||
Options.Create(new BumperOptions()),
|
||||
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Render_UnknownChannel_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await Handler(db)
|
||||
.Handle(
|
||||
new RenderBumperPreviewCommand(Guid.NewGuid(), Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_UnknownTemplate_ReturnsBumperTemplateNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", 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 Handler(db)
|
||||
.Handle(
|
||||
new RenderBumperPreviewCommand(channel.Id, Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_DrawsEveryVariant_WithChannelShowNamesAndAlignedDuration()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var template = channel.BumperTemplates[0];
|
||||
var second = template.AddVariant("Второй подблок");
|
||||
|
||||
// Шоу канала: слот сетки ссылается на группу, в группе — шоу.
|
||||
var show = Show.Create("Наше шоу", ShowKind.Series);
|
||||
var group = Group.Create("Кино");
|
||||
group.AddElement(GroupElementKind.Show, show.Id);
|
||||
var grid = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = grid.Background!.AddSlot("Круглосуточно", new TimeOnly(6, 0), 24 * 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
null,
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
channel.SetTemplate(grid.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
seed.Groups.Add(group);
|
||||
seed.ScheduleTemplates.Add(grid);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var renderer = Substitute.For<IBumperRenderer>();
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, renderer)
|
||||
.Handle(
|
||||
new RenderBumperPreviewCommand(channel.Id, template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Оба подблока отрисованы, каждый — в свой ассет-превью.
|
||||
await renderer
|
||||
.Received(1)
|
||||
.RenderAsync(
|
||||
BumperPreview.AssetId(template.Variants[0].Id),
|
||||
Arg.Any<BumperRenderSpec>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await renderer
|
||||
.Received(1)
|
||||
.RenderAsync(
|
||||
BumperPreview.AssetId(second.Id),
|
||||
Arg.Any<BumperRenderSpec>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
|
||||
var specs = renderer
|
||||
.ReceivedCalls()
|
||||
.Select(c => c.GetArguments()[1])
|
||||
.OfType<BumperRenderSpec>()
|
||||
.ToList();
|
||||
// Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек.
|
||||
Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds));
|
||||
Assert.All(specs, s => Assert.Equal("Наше шоу", s.NowTitle));
|
||||
// Второго шоу у канала нет — подставляется заглушка.
|
||||
Assert.All(specs, s => Assert.Equal("Второе шоу", s.NextTitle));
|
||||
Assert.All(specs, s => Assert.Null(s.BackgroundFile));
|
||||
Assert.All(specs, s => Assert.Null(s.PosterFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var template = channel.BumperTemplates[0];
|
||||
var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png");
|
||||
template.SetBackgroundImage(image.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Images.Add(image);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var renderer = Substitute.For<IBumperRenderer>();
|
||||
var imageStore = Substitute.For<IImageStore>();
|
||||
imageStore.ResolvePath(image.Id, ".png").Returns("/data/images/bg.png");
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, renderer, imageStore: imageStore)
|
||||
.Handle(
|
||||
new RenderBumperPreviewCommand(channel.Id, template.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var spec = renderer
|
||||
.ReceivedCalls()
|
||||
.Select(c => c.GetArguments()[1])
|
||||
.OfType<BumperRenderSpec>()
|
||||
.First();
|
||||
Assert.Equal("/data/images/bg.png", spec.BackgroundFile);
|
||||
// Шоу у канала нет — обе подписи заглушечные.
|
||||
Assert.Equal("Первое шоу", spec.NowTitle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.GetChannel;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка канала: собственные свойства канала плюс блоки заставки с подблоками. Сетка сюда не
|
||||
/// входит — она запрашивается отдельно.
|
||||
/// </summary>
|
||||
public class ChannelDetailsTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_MapsSettingsAndBumperTemplates()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var fillerId = Guid.NewGuid();
|
||||
var logoId = Guid.NewGuid();
|
||||
channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: true, fillerId);
|
||||
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
||||
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.Random);
|
||||
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
||||
// Второй блок должен приехать после дефолтного — порядок задаёт позиция.
|
||||
var extra = channel.AddBumperTemplate("Ночной");
|
||||
|
||||
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 GetChannelQueryHandler(db).Handle(
|
||||
new GetChannelQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var dto = result.Value;
|
||||
Assert.Equal("one", dto.Slug);
|
||||
Assert.True(dto.IsEnabled);
|
||||
Assert.Equal(3, dto.Number);
|
||||
Assert.Equal(120, dto.UtcOffsetMinutes);
|
||||
Assert.Equal(new TimeOnly(5, 0), dto.DayStartTime);
|
||||
Assert.Equal(fillerId, dto.FillerAssetId);
|
||||
Assert.True(dto.BumpersEnabled);
|
||||
Assert.Equal(BumperFont.Serif, dto.Bumper.Font);
|
||||
Assert.Equal(BumperSelection.Random, dto.Bumper.Selection);
|
||||
Assert.Equal(logoId, dto.Viewer.LogoImageId);
|
||||
Assert.Equal(LogoCorner.BottomLeft, dto.Viewer.LogoCorner);
|
||||
Assert.Equal(0.4, dto.Viewer.LogoOpacity);
|
||||
Assert.True(dto.Viewer.ShowClock);
|
||||
Assert.Equal(0.25, dto.Viewer.AnalogFilterStrength);
|
||||
|
||||
Assert.Equal(2, dto.BumperTemplates.Count);
|
||||
Assert.True(dto.BumperTemplates[0].IsDefault);
|
||||
Assert.Equal(extra.Id, dto.BumperTemplates[1].Id);
|
||||
Assert.Equal("Ночной", dto.BumperTemplates[1].Name);
|
||||
// Своего звука у свежего блока нет — джингл синтезируется.
|
||||
Assert.False(dto.BumperTemplates[1].HasAudio);
|
||||
Assert.NotEmpty(dto.BumperTemplates[0].Variants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new GetChannelQueryHandler(db).Handle(
|
||||
new GetChannelQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using TeleWave.Application.Library.ListShows;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Список библиотеки: он же считает сезоны (номера в модели не хранятся — только в именах файлов)
|
||||
/// и подставляет названия жанров, поэтому проверяем именно сводку, а не факт выборки.
|
||||
/// </summary>
|
||||
public class ListShowsTests
|
||||
{
|
||||
private sealed record Seeded(TestDb Db, Guid ActionGenreId, Guid DramaGenreId, Guid SeriesId);
|
||||
|
||||
private static async Task<Seeded> SeedAsync()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var action = Genre.Create("Боевик", "action");
|
||||
var drama = Genre.Create("Драма", "drama");
|
||||
|
||||
var series = Show.Create("Сериал", ShowKind.Series);
|
||||
series.SetGenres([action.Id, drama.Id], action.Id);
|
||||
series.SetPosterImage(Guid.NewGuid());
|
||||
var assets = new[]
|
||||
{
|
||||
MediaAsset.Register("Serial.S01E01.mkv", ".mkv", MediaSource.Upload),
|
||||
MediaAsset.Register("Serial.S01E02.mkv", ".mkv", MediaSource.Upload),
|
||||
MediaAsset.Register("Serial.S02E01.mkv", ".mkv", MediaSource.Upload),
|
||||
// Имя без метки сезона: серия считается, сезон — нет.
|
||||
MediaAsset.Register("Serial-bonus.mkv", ".mkv", MediaSource.Upload),
|
||||
};
|
||||
foreach (var asset in assets)
|
||||
series.AddEpisode(asset.Id);
|
||||
|
||||
var film = Show.Create("Автофильм", ShowKind.Single);
|
||||
var bumper = Show.Create("Ролик", ShowKind.Interstitial);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.Genres.AddRange(action, drama);
|
||||
seed.MediaAssets.AddRange(assets);
|
||||
seed.Shows.AddRange(series, film, bumper);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return new Seeded(fixture, action.Id, drama.Id, series.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsContentSortedByName_WithoutInterstitials()
|
||||
{
|
||||
var seeded = await SeedAsync();
|
||||
await using var db = seeded.Db.New();
|
||||
|
||||
var shows = await new ListShowsQueryHandler(db).Handle(
|
||||
new ListShowsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Ролики-врезки живут на своей странице и в общей библиотеке только мешали бы.
|
||||
Assert.Equal(new[] { "Автофильм", "Сериал" }, shows.Select(s => s.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_CountsEpisodesAndDistinctSeasons()
|
||||
{
|
||||
var seeded = await SeedAsync();
|
||||
await using var db = seeded.Db.New();
|
||||
|
||||
var shows = await new ListShowsQueryHandler(db).Handle(
|
||||
new ListShowsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var series = shows.Single(s => s.Id == seeded.SeriesId);
|
||||
Assert.Equal(4, series.EpisodeCount);
|
||||
Assert.Equal(2, series.SeasonCount);
|
||||
Assert.True(series.HasPoster);
|
||||
Assert.Equal("Боевик", series.PrimaryGenre);
|
||||
Assert.Equal(new[] { "Драма" }, series.OtherGenres.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ByGenre_KeepsOnlyShowsWithIt()
|
||||
{
|
||||
var seeded = await SeedAsync();
|
||||
await using var db = seeded.Db.New();
|
||||
|
||||
var byDrama = await new ListShowsQueryHandler(db).Handle(
|
||||
new ListShowsQuery(seeded.DramaGenreId),
|
||||
CancellationToken.None
|
||||
);
|
||||
var byUnknown = await new ListShowsQueryHandler(db).Handle(
|
||||
new ListShowsQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Жанр ищется среди всех, а не только основного.
|
||||
Assert.Equal(seeded.SeriesId, Assert.Single(byDrama).Id);
|
||||
Assert.Empty(byUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Interstitials_ReturnsOnlyThem()
|
||||
{
|
||||
var seeded = await SeedAsync();
|
||||
await using var db = seeded.Db.New();
|
||||
|
||||
var shows = await new ListShowsQueryHandler(db).Handle(
|
||||
new ListShowsQuery(Interstitials: true),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var only = Assert.Single(shows);
|
||||
Assert.Equal("Ролик", only.Name);
|
||||
Assert.Equal(ShowKind.Interstitial, only.Kind);
|
||||
Assert.Equal(0, only.SeasonCount);
|
||||
Assert.Null(only.PrimaryGenre);
|
||||
Assert.False(only.HasPoster);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Tests.TestSupport;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Infrastructure.Metadata;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Разбор ответов OMDb. Особенности источника, ради которых тесты и написаны: «N/A» вместо
|
||||
/// отсутствующего значения, флаг <c>Response</c> вместо кода ошибки и жанры одной строкой.
|
||||
/// </summary>
|
||||
public class OmdbMetadataProviderTests
|
||||
{
|
||||
private static OmdbMetadataProvider Provider(FakeHttpClientFactory http) =>
|
||||
new(
|
||||
http,
|
||||
Options.Create(new MetadataOptions { Omdb = new OmdbOptions { ApiKey = "key" } })
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task SearchShows_KeepsOnlyShowsWithId()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"Search":[
|
||||
{"imdbID":"tt1","Title":"Сериал","Year":"2001–2005","Type":"series","Poster":"http://p/1.jpg"},
|
||||
{"imdbID":"tt2","Title":"Серия","Type":"episode"},
|
||||
{"Title":"Без идентификатора","Type":"movie"},
|
||||
{"imdbID":"tt3","Title":"Фильм","Year":"N/A","Type":"movie","Poster":"N/A"}
|
||||
]}
|
||||
"""
|
||||
);
|
||||
|
||||
var found = await Provider(http)
|
||||
.SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Contains("type=series", http.LastUrl);
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Equal("tt1", found[0].ExternalId);
|
||||
Assert.Equal(2001, found[0].Year);
|
||||
Assert.Equal("http://p/1.jpg", found[0].PosterUrl);
|
||||
Assert.Equal(ShowKind.Series, found[0].Kind);
|
||||
// Отдельные серии просачиваются в выдачу и шоу не годятся, запись без imdbID — тоже.
|
||||
Assert.Equal("tt3", found[1].ExternalId);
|
||||
Assert.Equal(ShowKind.Single, found[1].Kind);
|
||||
Assert.Null(found[1].Year);
|
||||
Assert.Null(found[1].PosterUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchShows_Single_AsksForMovies()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("{}");
|
||||
|
||||
var found = await Provider(http)
|
||||
.SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
Assert.Contains("type=movie", http.LastUrl);
|
||||
Assert.Empty(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReadsRatingAndSplitsGenres()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"Response":"True","Title":"Сериал","Year":"2001–2005","Plot":"описание",
|
||||
"Poster":"http://p/1.jpg","Genre":"Action, Sci-Fi","Rated":"TV-14"}
|
||||
"""
|
||||
);
|
||||
|
||||
var show = await Provider(http)
|
||||
.GetShowAsync("tt1", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Contains("i=tt1", http.LastUrl);
|
||||
Assert.NotNull(show);
|
||||
Assert.Equal("tt1", show.ExternalId);
|
||||
Assert.Equal("Сериал", show.Title);
|
||||
Assert.Equal(2001, show.Year);
|
||||
Assert.Equal("описание", show.Overview);
|
||||
Assert.Equal(new[] { "Action", "Sci-Fi" }, show.Genres);
|
||||
Assert.Equal("TV-14", show.ContentRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_TreatsNotAvailableAsMissing()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"Response":"True","Title":"N/A","Plot":"N/A","Poster":"N/A","Genre":"N/A","Rated":"N/A"}"""
|
||||
);
|
||||
|
||||
var show = await Provider(http)
|
||||
.GetShowAsync("tt1", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(show);
|
||||
Assert.Equal("—", show.Title);
|
||||
Assert.Null(show.Overview);
|
||||
Assert.Null(show.PosterUrl);
|
||||
Assert.Null(show.ContentRating);
|
||||
Assert.Empty(show.Genres!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReturnsNull_WhenResponseIsFalse()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"Response":"False","Error":"Movie not found!"}"""
|
||||
);
|
||||
|
||||
var show = await Provider(http)
|
||||
.GetShowAsync("tt0", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Null(show);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReturnsNull_WhenRequestFails()
|
||||
{
|
||||
var show = await Provider(FakeHttpClientFactory.Failing())
|
||||
.GetShowAsync("tt1", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Null(show);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpisode_ReadsTitleAndReleaseDate()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"Response":"True","Title":"Серия","Plot":"о серии","Poster":"http://p/s.jpg",
|
||||
"Released":"07 Jun 2001"}
|
||||
"""
|
||||
);
|
||||
|
||||
var episode = await Provider(http).GetEpisodeAsync("tt1", 2, 3, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Season=2", http.LastUrl);
|
||||
Assert.Contains("Episode=3", http.LastUrl);
|
||||
Assert.NotNull(episode);
|
||||
Assert.Equal("Серия", episode.Title);
|
||||
Assert.Equal("http://p/s.jpg", episode.StillUrl);
|
||||
Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpisode_ReturnsNull_WhenResponseIsFalse()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("""{"Response":"False"}""");
|
||||
|
||||
var episode = await Provider(http).GetEpisodeAsync("tt1", 9, 9, CancellationToken.None);
|
||||
|
||||
Assert.Null(episode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonEpisodeCount_CountsEpisodes()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"Response":"True","Episodes":[{"Episode":"1"},{"Episode":"2"}]}"""
|
||||
);
|
||||
|
||||
var count = await Provider(http)
|
||||
.GetSeasonEpisodeCountAsync("tt1", 2, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSeasonMissing()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("""{"Response":"True"}""");
|
||||
|
||||
var count = await Provider(http)
|
||||
.GetSeasonEpisodeCountAsync("tt1", 9, CancellationToken.None);
|
||||
|
||||
Assert.Null(count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonNumbers_NumbersSeasonsContinuously()
|
||||
{
|
||||
// Перечня сезонов OMDb не отдаёт — только их количество строкой.
|
||||
var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"3"}""");
|
||||
|
||||
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, seasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonNumbers_ReturnsEmpty_WhenTotalIsNotANumber()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"N/A"}""");
|
||||
|
||||
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
|
||||
|
||||
Assert.Empty(seasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonNumbers_ReturnsEmpty_WhenResponseIsFalse()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("""{"Response":"False"}""");
|
||||
|
||||
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
|
||||
|
||||
Assert.Empty(seasons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Tests.TestSupport;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Infrastructure.Metadata;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Разбор ответов TMDb. Наружу тесты не ходят — HTTP подменён, проверяем и собранный адрес
|
||||
/// (сериалы и фильмы живут в разных пространствах API), и то, что из ответа вычитано.
|
||||
/// </summary>
|
||||
public class TmdbMetadataProviderTests
|
||||
{
|
||||
private static TmdbMetadataProvider Provider(FakeHttpClientFactory http) =>
|
||||
new(
|
||||
http,
|
||||
Options.Create(
|
||||
new MetadataOptions
|
||||
{
|
||||
Language = "ru-RU",
|
||||
Tmdb = new TmdbOptions { ApiKey = "key" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task SearchShows_Series_ReadsNameAndFirstAirDate()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"results":[
|
||||
{"id":42,"name":"Сериал","first_air_date":"2001-05-06","overview":"о чём-то","poster_path":"/p.jpg"},
|
||||
{"name":"без идентификатора"}
|
||||
]}
|
||||
"""
|
||||
);
|
||||
|
||||
var found = await Provider(http)
|
||||
.SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/search/tv", http.LastUrl);
|
||||
Assert.Contains("language=ru-RU", http.LastUrl);
|
||||
// Запись без id пропущена: обратиться по ней всё равно не получится.
|
||||
var candidate = Assert.Single(found);
|
||||
Assert.Equal("42", candidate.ExternalId);
|
||||
Assert.Equal("Сериал", candidate.Title);
|
||||
Assert.Equal(2001, candidate.Year);
|
||||
Assert.Equal("о чём-то", candidate.Overview);
|
||||
Assert.Equal("https://image.tmdb.org/t/p/w500/p.jpg", candidate.PosterUrl);
|
||||
Assert.Equal(ShowKind.Series, candidate.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchShows_Single_UsesMovieNamespace()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"results":[{"id":7,"title":"Фильм","release_date":"1999-03-31"}]}"""
|
||||
);
|
||||
|
||||
var found = await Provider(http)
|
||||
.SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/search/movie", http.LastUrl);
|
||||
var candidate = Assert.Single(found);
|
||||
Assert.Equal("Фильм", candidate.Title);
|
||||
Assert.Equal(1999, candidate.Year);
|
||||
Assert.Null(candidate.PosterUrl);
|
||||
Assert.Equal(ShowKind.Single, candidate.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchShows_ReturnsEmpty_WhenNoResultsSection()
|
||||
{
|
||||
var found = await Provider(FakeHttpClientFactory.Json("{}"))
|
||||
.SearchShowsAsync("ничего", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Empty(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_Series_ReadsGenresAndContentRating()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"name":"Сериал","first_air_date":"2001-05-06","overview":"описание","poster_path":"/p.jpg",
|
||||
"genres":[{"id":28,"name":"Боевик"},{"name":"Драма"},{"id":99}],
|
||||
"content_ratings":{"results":[{"iso_3166_1":"RU","rating":"16+"},{"iso_3166_1":"US","rating":"TV-14"}]}}
|
||||
"""
|
||||
);
|
||||
|
||||
var show = await Provider(http).GetShowAsync("42", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/tv/42", http.LastUrl);
|
||||
Assert.Contains("append_to_response=content_ratings", http.LastUrl);
|
||||
Assert.NotNull(show);
|
||||
Assert.Equal("Сериал", show.Title);
|
||||
Assert.Equal(2001, show.Year);
|
||||
// Идентификатор впереди названия, безымянный жанр — только идентификатором.
|
||||
Assert.Equal(new[] { "tmdb:28", "Боевик", "Драма", "tmdb:99" }, show.Genres);
|
||||
// US приоритетнее RU, хотя в ответе идёт вторым.
|
||||
Assert.Equal("TV-14", show.ContentRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_Movie_PrefersTheatricalCertification()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"title":"Фильм","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[
|
||||
{"type":4,"certification":"R"},
|
||||
{"type":3,"certification":"PG-13"}
|
||||
]}]}}
|
||||
"""
|
||||
);
|
||||
|
||||
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/movie/7", http.LastUrl);
|
||||
Assert.Contains("append_to_response=release_dates", http.LastUrl);
|
||||
Assert.Equal("PG-13", show?.ContentRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_Movie_FallsBackToAnyReleaseCertification()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"title":"Фильм","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[
|
||||
{"type":5,"certification":"NC-17"}
|
||||
]}]}}
|
||||
"""
|
||||
);
|
||||
|
||||
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
Assert.Equal("NC-17", show?.ContentRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_Movie_SkipsCountriesWithoutCertification()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""
|
||||
{"title":"Фильм","release_dates":{"results":[
|
||||
{"iso_3166_1":"US"},
|
||||
{"iso_3166_1":"US","release_dates":[{"type":3,"certification":" "}]},
|
||||
{"iso_3166_1":"RU","release_dates":[{"type":3,"certification":"18+"}]}
|
||||
]}}
|
||||
"""
|
||||
);
|
||||
|
||||
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
|
||||
|
||||
// У США записи есть, но сертификации в них нет — ответом становится следующая страна.
|
||||
Assert.Equal("18+", show?.ContentRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_WithoutOptionalFields_FallsBackToPlaceholders()
|
||||
{
|
||||
var show = await Provider(FakeHttpClientFactory.Json("{}"))
|
||||
.GetShowAsync("42", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(show);
|
||||
Assert.Equal("42", show.ExternalId);
|
||||
Assert.Equal("—", show.Title);
|
||||
Assert.Null(show.Year);
|
||||
Assert.Null(show.PosterUrl);
|
||||
Assert.Null(show.ContentRating);
|
||||
Assert.Empty(show.Genres!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShow_ReturnsNull_WhenRequestFails()
|
||||
{
|
||||
var show = await Provider(FakeHttpClientFactory.Failing())
|
||||
.GetShowAsync("42", ShowKind.Series, CancellationToken.None);
|
||||
|
||||
Assert.Null(show);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpisode_ReadsStillAndAirDate()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"name":"Серия","overview":"о серии","still_path":"/s.jpg","air_date":"2001-06-07"}"""
|
||||
);
|
||||
|
||||
var episode = await Provider(http).GetEpisodeAsync("42", 2, 3, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/tv/42/season/2/episode/3", http.LastUrl);
|
||||
Assert.NotNull(episode);
|
||||
Assert.Equal("Серия", episode.Title);
|
||||
Assert.Equal("о серии", episode.Overview);
|
||||
Assert.Equal("https://image.tmdb.org/t/p/w300/s.jpg", episode.StillUrl);
|
||||
Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpisode_ReturnsNull_WhenRequestFails()
|
||||
{
|
||||
var episode = await Provider(FakeHttpClientFactory.Failing())
|
||||
.GetEpisodeAsync("42", 1, 1, CancellationToken.None);
|
||||
|
||||
Assert.Null(episode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonEpisodeCount_CountsEpisodes()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json("""{"episodes":[{"id":1},{"id":2},{"id":3}]}""");
|
||||
|
||||
var count = await Provider(http)
|
||||
.GetSeasonEpisodeCountAsync("42", 2, CancellationToken.None);
|
||||
|
||||
Assert.Contains("/tv/42/season/2", http.LastUrl);
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSectionMissing()
|
||||
{
|
||||
var count = await Provider(FakeHttpClientFactory.Json("{}"))
|
||||
.GetSeasonEpisodeCountAsync("42", 9, CancellationToken.None);
|
||||
|
||||
Assert.Null(count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonNumbers_SkipsSpecialsAndSorts()
|
||||
{
|
||||
var http = FakeHttpClientFactory.Json(
|
||||
"""{"seasons":[{"season_number":2},{"season_number":0},{"season_number":1},{"name":"без номера"}]}"""
|
||||
);
|
||||
|
||||
var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, seasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeasonNumbers_ReturnsEmpty_WhenRequestFails()
|
||||
{
|
||||
var seasons = await Provider(FakeHttpClientFactory.Failing())
|
||||
.GetSeasonNumbersAsync("42", CancellationToken.None);
|
||||
|
||||
Assert.Empty(seasons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Programming.Planning.Trace;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// «Почему это здесь»: трейс пишется в момент генерации и разбирается вместе с текущим состоянием
|
||||
/// сетки. Слот мог быть с тех пор изменён или удалён — подписи тогда пустеют, но ответ остаётся.
|
||||
/// </summary>
|
||||
public class EntryTraceTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 18, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||
private static readonly JsonSerializerOptions TraceJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_UnknownEntry_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new GetEntryTraceQueryHandler(db).Handle(
|
||||
new GetEntryTraceQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_ResolvesSlotLayerGroupAndJunction()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Кино");
|
||||
var collection = Collection.Create("Франшиза");
|
||||
var show = Show.Create("Фильм", ShowKind.Single);
|
||||
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
|
||||
|
||||
var junction = JunctionTemplate.Create(channel.Id, "Прайм");
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Прайм", 10);
|
||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
JunctionAfterId: junction.Id
|
||||
)
|
||||
);
|
||||
|
||||
var trace = new PlanTrace(
|
||||
slot.Id,
|
||||
SlotKind.Content,
|
||||
GroupElementKind.Show,
|
||||
show.Id,
|
||||
SlotStrategyKind.RandomWithCooldown,
|
||||
CandidatesAfterCooldown: 4,
|
||||
DriftMinutes: 3,
|
||||
Snapped: true
|
||||
);
|
||||
var entry = ScheduleEntry.FromSlot(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
ScheduleEntryKind.Program,
|
||||
T0,
|
||||
T0.AddMinutes(90),
|
||||
new ScheduleEntryOrigin(
|
||||
show.Id,
|
||||
2,
|
||||
slot.Id,
|
||||
JsonSerializer.Serialize(trace, TraceJson),
|
||||
collection.Id
|
||||
)
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.Collections.Add(collection);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.JunctionTemplates.Add(junction);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
seed.ScheduleEntries.Add(entry);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetEntryTraceQueryHandler(db).Handle(
|
||||
new GetEntryTraceQuery(entry.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var dto = result.Value;
|
||||
Assert.Equal("Фильм", dto.ShowName);
|
||||
Assert.Equal(2, dto.EpisodeIndex);
|
||||
Assert.Equal("Франшиза", dto.CollectionName);
|
||||
Assert.Equal("Прайм", dto.LayerName);
|
||||
Assert.Equal(10, dto.LayerPriority);
|
||||
Assert.Equal("Вечернее кино", dto.SlotTitle);
|
||||
Assert.Equal(new TimeOnly(20, 0), dto.SlotTargetStart);
|
||||
Assert.Equal(120, dto.SlotDurationMinutes);
|
||||
Assert.Equal("Кино", dto.GroupName);
|
||||
Assert.Equal(SlotStrategyKind.RandomWithCooldown, dto.Strategy);
|
||||
// Остывание берётся из текущей стратегии слота, а число кандидатов — из трейса.
|
||||
Assert.Equal(14, dto.CooldownDays);
|
||||
Assert.Equal(4, dto.CandidatesAfterCooldown);
|
||||
Assert.Equal(3, dto.DriftMinutes);
|
||||
Assert.True(dto.Snapped);
|
||||
Assert.Equal("Прайм", dto.JunctionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("{не json}")]
|
||||
public async Task Trace_WithoutUsableTrace_ReturnsBareEntry(string? traceJson)
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var show = Show.Create("Фильм", ShowKind.Single);
|
||||
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
|
||||
var entry = ScheduleEntry.FromSlot(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
ScheduleEntryKind.Program,
|
||||
T0,
|
||||
T0.AddMinutes(90),
|
||||
new ScheduleEntryOrigin(show.Id, 1, null, traceJson)
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(entry);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetEntryTraceQueryHandler(db).Handle(
|
||||
new GetEntryTraceQuery(entry.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Старая запись (или битый трейс) — ответ есть, подписи пустые.
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Фильм", result.Value.ShowName);
|
||||
Assert.Null(result.Value.LayerName);
|
||||
Assert.Null(result.Value.SlotTitle);
|
||||
Assert.Null(result.Value.Strategy);
|
||||
Assert.Equal(0, result.Value.DriftMinutes);
|
||||
Assert.False(result.Value.Snapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_WithDeletedSlot_KeepsWhatTraceRemembers()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
|
||||
var trace = new PlanTrace(
|
||||
Guid.NewGuid(),
|
||||
SlotKind.Repeat,
|
||||
null,
|
||||
null,
|
||||
SlotStrategyKind.Sequential,
|
||||
null,
|
||||
DriftMinutes: 0,
|
||||
Snapped: false
|
||||
);
|
||||
var entry = ScheduleEntry.FromSlot(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
ScheduleEntryKind.Program,
|
||||
T0,
|
||||
T0.AddMinutes(30),
|
||||
new ScheduleEntryOrigin(null, null, null, JsonSerializer.Serialize(trace, TraceJson))
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(entry);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new GetEntryTraceQueryHandler(db).Handle(
|
||||
new GetEntryTraceQuery(entry.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Слота уже нет — из трейса остаётся его тип и стратегия, остальное пустеет.
|
||||
Assert.Equal(SlotKind.Repeat, result.Value.SlotKind);
|
||||
Assert.Equal(SlotStrategyKind.Sequential, result.Value.Strategy);
|
||||
Assert.Null(result.Value.SlotTitle);
|
||||
Assert.Null(result.Value.GroupName);
|
||||
Assert.Null(result.Value.JunctionName);
|
||||
Assert.Null(result.Value.ShowName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
using TeleWave.Application.Programming.Templates.Layers;
|
||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам —
|
||||
/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию».
|
||||
/// </summary>
|
||||
public class TemplateEditingTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static SlotInput Input(
|
||||
string title = "Вечернее кино",
|
||||
TimeOnly? start = null,
|
||||
int minutes = 120,
|
||||
SlotKind kind = SlotKind.Content,
|
||||
Guid? groupId = null,
|
||||
RepeatSource? repeat = null,
|
||||
int? weekday = null
|
||||
) =>
|
||||
new(
|
||||
title,
|
||||
weekday,
|
||||
start ?? new TimeOnly(20, 0),
|
||||
minutes,
|
||||
Daypart.Prime,
|
||||
kind,
|
||||
groupId,
|
||||
new SlotStrategy(SlotStrategyType.Sequential),
|
||||
repeat,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext,
|
||||
IsAnchor: true,
|
||||
MaxDriftMinutes: 10,
|
||||
SnapToMinutes: 15
|
||||
);
|
||||
|
||||
/// <summary>Канал с сеткой: один обычный слой поверх фонового плюс группа контента.</summary>
|
||||
private sealed record Fixture(
|
||||
TestDb Db,
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
Guid LayerId,
|
||||
Guid BackgroundLayerId,
|
||||
Guid GroupId
|
||||
);
|
||||
|
||||
private static async Task<Fixture> SeedAsync()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Кино");
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Прайм", 10);
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return new Fixture(
|
||||
fixture,
|
||||
channel.Id,
|
||||
template.Id,
|
||||
layer.Id,
|
||||
template.Background!.Id,
|
||||
group.Id
|
||||
);
|
||||
}
|
||||
|
||||
private static async Task<Guid> AddSlotAsync(Fixture f, SlotInput input)
|
||||
{
|
||||
await using var db = f.Db.New();
|
||||
var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(f.LayerId, input),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(created.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return created.Value;
|
||||
}
|
||||
|
||||
private static async Task<Slot> LoadSlotAsync(Fixture f, Guid slotId)
|
||||
{
|
||||
await using var db = f.Db.New();
|
||||
return await db.Slots.AsNoTracking().FirstAsync(s => s.Id == slotId);
|
||||
}
|
||||
|
||||
private static async Task<int> RevisionAsync(Fixture f)
|
||||
{
|
||||
await using var db = f.Db.New();
|
||||
return (
|
||||
await db.ScheduleTemplates.AsNoTracking().FirstAsync(t => t.Id == f.TemplateId)
|
||||
).Revision;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_UnknownLayer_ReturnsLayerNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.LayerNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_StoresTimingAndContent_AndBumpsRevision()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
|
||||
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId, weekday: 3));
|
||||
|
||||
var slot = await LoadSlotAsync(f, slotId);
|
||||
Assert.Equal("Вечернее кино", slot.Title);
|
||||
Assert.Equal(3, slot.Weekday);
|
||||
Assert.Equal(new TimeOnly(20, 0), slot.TargetStart);
|
||||
Assert.Equal(120, slot.TargetDurationMinutes);
|
||||
Assert.Equal(Daypart.Prime, slot.Daypart);
|
||||
Assert.Equal(f.GroupId, slot.GroupId);
|
||||
Assert.True(slot.IsAnchor);
|
||||
Assert.Equal(15, slot.SnapToMinutes);
|
||||
Assert.NotNull(SlotStrategy.FromJson(slot.StrategyJson));
|
||||
// Правка сетки эфир не двигает — только помечает шаблон изменённым.
|
||||
Assert.Equal(1, await RevisionAsync(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_ContentWithoutGroup_ReturnsGroupRequired()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(f.LayerId, Input(groupId: null)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.GroupRequired, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_UnknownGroup_ReturnsGroupNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_RepeatWithoutSource_ReturnsRepeatSourceRequired()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.RepeatSourceRequired, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_Repeat_KeepsSourceAndDropsGroup()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var repeat = new RepeatSource(1, new TimeOnly(20, 0), 120);
|
||||
|
||||
var slotId = await AddSlotAsync(
|
||||
f,
|
||||
Input(kind: SlotKind.Repeat, groupId: f.GroupId, repeat: repeat)
|
||||
);
|
||||
|
||||
var slot = await LoadSlotAsync(f, slotId);
|
||||
// Группа к повтору отношения не имеет: домен гасит поля чужого типа слота.
|
||||
Assert.Null(slot.GroupId);
|
||||
Assert.Equal(repeat, RepeatSource.FromJson(slot.RepeatSourceJson));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlot_OverlappingInSameLayer_ReturnsSlotsOverlap()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await AddSlotAsync(f, Input(groupId: f.GroupId));
|
||||
|
||||
await using var db = f.Db.New();
|
||||
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new CreateSlotCommand(
|
||||
f.LayerId,
|
||||
Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.SlotsOverlap, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSlot_UnknownSlot_ReturnsSlotNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.SlotNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSlot_MovesSlot_WithoutCountingItAsOverlap()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
// Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой.
|
||||
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new UpdateSlotCommand(
|
||||
slotId,
|
||||
Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId)
|
||||
),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var slot = await LoadSlotAsync(f, slotId);
|
||||
Assert.Equal("Ночное кино", slot.Title);
|
||||
Assert.Equal(new TimeOnly(21, 0), slot.TargetStart);
|
||||
Assert.Equal(2, await RevisionAsync(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSlot_RemovesSlot_OrReportsNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new DeleteSlotCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.SlotNotFound, missing.Error);
|
||||
}
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
|
||||
new DeleteSlotCommand(slotId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(deleted.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = f.Db.New();
|
||||
Assert.False(await verify.Slots.AnyAsync(s => s.Id == slotId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLayer_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new CreateLayerCommandHandler(db).Handle(
|
||||
new CreateLayerCommand(Guid.NewGuid(), "Лето", 20),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLayer_AddsLayerToTemplate()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
|
||||
Guid layerId;
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var result = await new CreateLayerCommandHandler(db).Handle(
|
||||
new CreateLayerCommand(f.TemplateId, "Лето", 20),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
layerId = result.Value;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = f.Db.New();
|
||||
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == layerId);
|
||||
Assert.Equal("Лето", layer.Name);
|
||||
Assert.Equal(20, layer.Priority);
|
||||
Assert.False(layer.IsBackground);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLayer_StoresApplicability_OrReportsNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var applicability = new LayerApplicability(Weekdays: [5, 6]);
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var missing = await new UpdateLayerCommandHandler(db).Handle(
|
||||
new UpdateLayerCommand(Guid.NewGuid(), "Нет", 5, null, true),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
|
||||
}
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var result = await new UpdateLayerCommandHandler(db).Handle(
|
||||
new UpdateLayerCommand(f.LayerId, "Выходные", 30, applicability, false),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = f.Db.New();
|
||||
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == f.LayerId);
|
||||
Assert.Equal("Выходные", layer.Name);
|
||||
Assert.Equal(30, layer.Priority);
|
||||
Assert.False(layer.IsEnabled);
|
||||
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
|
||||
Assert.Equal(new[] { 5, 6 }, stored?.Weekdays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLayer_RefusesBackground_AndRemovesOrdinary()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var background = await new DeleteLayerCommandHandler(db).Handle(
|
||||
new DeleteLayerCommand(f.BackgroundLayerId),
|
||||
CancellationToken.None
|
||||
);
|
||||
// Без фонового слоя первую же дыру в сетке нечем закрыть.
|
||||
Assert.Equal(TemplateErrors.BackgroundLayerCannotBeDeleted, background.Error);
|
||||
}
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var missing = await new DeleteLayerCommandHandler(db).Handle(
|
||||
new DeleteLayerCommand(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
|
||||
}
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var deleted = await new DeleteLayerCommandHandler(db).Handle(
|
||||
new DeleteLayerCommand(f.LayerId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(deleted.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = f.Db.New();
|
||||
Assert.False(await verify.GridLayers.AnyAsync(l => l.Id == f.LayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_UnknownTemplate_ReturnsNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new UpdateTemplateCommandHandler(db).Handle(
|
||||
new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_UnknownFallbackGroup_ReturnsGroupNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new UpdateTemplateCommandHandler(db).Handle(
|
||||
new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_StoresFallbackAndRules()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var rules = new PlanningRules(
|
||||
MaxAudienceByTime:
|
||||
[
|
||||
new AudienceWindow(
|
||||
new TimeOnly(6, 0),
|
||||
new TimeOnly(21, 0),
|
||||
Domain.Library.ShowAudience.Pg
|
||||
),
|
||||
],
|
||||
MaxRepeatsInWindow: new RepeatLimitRule(7, 2)
|
||||
);
|
||||
|
||||
await using (var db = f.Db.New())
|
||||
{
|
||||
var result = await new UpdateTemplateCommandHandler(db).Handle(
|
||||
new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = f.Db.New();
|
||||
var template = await verify
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.FirstAsync(t => t.Id == f.TemplateId);
|
||||
Assert.Equal("Новая сетка", template.Name);
|
||||
Assert.Equal(f.GroupId, template.FallbackGroupId);
|
||||
var storedRules = PlanningRules.FromJson(template.RulesJson);
|
||||
Assert.Equal(rules.MaxRepeatsInWindow, storedRules?.MaxRepeatsInWindow);
|
||||
Assert.Equal(rules.MaxAudienceByTime, storedRules?.MaxAudienceByTime);
|
||||
Assert.True(template.HasPendingChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelTemplate_UnknownChannel_ReturnsNotFound()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
await using var db = f.Db.New();
|
||||
|
||||
var result = await new GetChannelTemplateQueryHandler(db).Handle(
|
||||
new GetChannelTemplateQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelTemplate_ChannelWithoutGrid_ReturnsTemplateNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Без сетки", "nogrid", 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 GetChannelTemplateQueryHandler(db).Handle(
|
||||
new GetChannelTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelTemplate_OrdersLayersByPriority_AndResolvesGroupNames()
|
||||
{
|
||||
var f = await SeedAsync();
|
||||
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
|
||||
|
||||
await using var db = f.Db.New();
|
||||
var result = await new GetChannelTemplateQueryHandler(db).Handle(
|
||||
new GetChannelTemplateQuery(f.ChannelId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var dto = result.Value;
|
||||
Assert.Equal(f.TemplateId, dto.Id);
|
||||
Assert.True(dto.HasPendingChanges);
|
||||
// Приоритетный слой идёт первым, фоновый — последним.
|
||||
Assert.Equal(
|
||||
new[] { f.LayerId, f.BackgroundLayerId },
|
||||
dto.Layers.Select(l => l.Id).ToList()
|
||||
);
|
||||
Assert.True(dto.Layers[^1].IsBackground);
|
||||
|
||||
var slot = Assert.Single(dto.Layers[0].Slots);
|
||||
Assert.Equal(slotId, slot.Id);
|
||||
Assert.Equal("Кино", slot.GroupName);
|
||||
Assert.Equal(SlotStrategyType.Sequential, slot.Strategy?.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.Validate;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Проверки сетки до генерации: пустые группы, пересечения слотов, недостижимое остывание,
|
||||
/// конфликт с детским временем и непокрытые интервалы суток.
|
||||
/// </summary>
|
||||
public class ValidateTemplateTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Слот на сутки целиком — им закрываем сетку, когда дыры не тема теста.</summary>
|
||||
private static Slot FullDaySlot(GridLayer layer, Guid? groupId, TimeOnly dayStart)
|
||||
{
|
||||
var slot = layer.AddSlot("Круглосуточно", dayStart, 24 * 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
groupId,
|
||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private static void Fill(
|
||||
Slot slot,
|
||||
Guid? groupId,
|
||||
SlotStrategy? strategy = null,
|
||||
SlotKind kind = SlotKind.Content
|
||||
) =>
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
kind,
|
||||
groupId,
|
||||
(strategy ?? new SlotStrategy(SlotStrategyType.Sequential)).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
|
||||
private static async Task<IReadOnlyList<TemplateIssueDto>> ValidateAsync(
|
||||
TestDb fixture,
|
||||
Guid channelId
|
||||
)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
new ValidateTemplateQuery(channelId),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_UnknownChannel_ReturnsNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
new ValidateTemplateQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_ChannelWithoutGrid_ReturnsTemplateNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Без сетки", "nogrid", 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 ValidateTemplateQueryHandler(db).Handle(
|
||||
new ValidateTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_EmptyGrid_ReportsGapForEveryDay()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
// Семь суток без единого слота — семь дыр «весь день».
|
||||
var gaps = issues.Where(i => i.Kind == TemplateIssueKind.GridGap).ToList();
|
||||
Assert.Equal(7, gaps.Count);
|
||||
Assert.All(gaps, g => Assert.Contains("весь день", g.Details));
|
||||
Assert.All(gaps, g => Assert.Equal(TemplateIssueSeverity.Error, g.Severity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_SlotWithoutGroup_ReportsGroupMissing()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
FullDaySlot(template.Background!, null, channel.DayStartTime);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupMissing);
|
||||
// Сутки закрыты слотом — дыр быть не должно.
|
||||
Assert.DoesNotContain(issues, i => i.Kind == TemplateIssueKind.GridGap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_EmptyGroup_ReportsGroupEmpty()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Пустая");
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
var issue = Assert.Single(issues, i => i.Kind == TemplateIssueKind.GroupEmpty);
|
||||
Assert.Contains("Пустая", issue.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_SmallGroupWithCooldown_ReportsBothWarnings()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Маленькая");
|
||||
group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
// Ежедневный слот — 7 выходов в неделю на 3 позиции, да ещё с двухнедельным остыванием.
|
||||
Fill(
|
||||
slot,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14)
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.GroupTooSmall);
|
||||
Assert.Contains(issues, i => i.Kind == TemplateIssueKind.CooldownUnreachable);
|
||||
Assert.All(
|
||||
issues.Where(i => i.Kind != TemplateIssueKind.GridGap),
|
||||
i => Assert.Equal(TemplateIssueSeverity.Warning, i.Severity)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_OverlappingSlotsInOneLayer_AreReported()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Кино");
|
||||
group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
var layer = template.AddLayer("Прайм", 10);
|
||||
var first = layer.AddSlot("Кино", new TimeOnly(20, 0), 120);
|
||||
Fill(first, group.Id);
|
||||
var second = layer.AddSlot("Сериал", new TimeOnly(21, 0), 60);
|
||||
Fill(second, group.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
// Внутри слоя приоритетов нет: второй слот молча пропал бы при генерации.
|
||||
var overlap = Assert.Single(issues, i => i.Kind == TemplateIssueKind.SlotOverlap);
|
||||
Assert.Equal(second.Id, overlap.SlotId);
|
||||
Assert.Equal(layer.Id, overlap.LayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_AdultContentInKidsWindow_ReportsAudienceConflict()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
|
||||
var mild = Show.Create("Мультик", ShowKind.Single);
|
||||
mild.SetAudience(ShowAudience.G);
|
||||
var harsh = Show.Create("Ужастик", ShowKind.Single);
|
||||
harsh.SetAudience(ShowAudience.R);
|
||||
// Строжайший рейтинг группы приходит через коллекцию: франшиза идёт целиком.
|
||||
var collection = Collection.Create("Франшиза");
|
||||
collection.AddShow(harsh.Id);
|
||||
|
||||
var group = Group.Create("Вечернее");
|
||||
group.AddElement(GroupElementKind.Show, mild.Id);
|
||||
group.AddElement(GroupElementKind.Collection, collection.Id);
|
||||
group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
slot.UpdateTiming(
|
||||
null,
|
||||
new TimeOnly(10, 0),
|
||||
24 * 60,
|
||||
Daypart.Day,
|
||||
isAnchor: false,
|
||||
5,
|
||||
null
|
||||
);
|
||||
template.SetRules(
|
||||
new PlanningRules(
|
||||
MaxAudienceByTime:
|
||||
[
|
||||
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(21, 0), ShowAudience.Pg),
|
||||
]
|
||||
).ToJson()
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Groups.Add(group);
|
||||
seed.Collections.Add(collection);
|
||||
seed.Shows.AddRange(mild, harsh);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var issues = await ValidateAsync(fixture, channel.Id);
|
||||
|
||||
var conflict = Assert.Single(issues, i => i.Kind == TemplateIssueKind.AudienceConflict);
|
||||
Assert.Contains("Вечернее", conflict.Details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Settings.GetSiteSettings;
|
||||
using TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Infrastructure.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные настройки сайта поверх key-value таблицы: значения по умолчанию, upsert (второй раз
|
||||
/// пишем в ту же строку, а не заводим новую) и нормализация списка языков.
|
||||
/// </summary>
|
||||
public class SiteSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Get_WithoutStoredValues_ReturnsDefaults()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var dto = await new GetSiteSettingsQueryHandler(new SiteSettings(db)).Handle(
|
||||
new GetSiteSettingsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Открытая регистрация и переключение по номерам по умолчанию выключены.
|
||||
Assert.False(dto.RegistrationEnabled);
|
||||
Assert.False(dto.ChannelNumbersEnabled);
|
||||
Assert.Equal(string.Empty, dto.PreferredAudioLanguages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_StoresValues_AndNormalizesLanguages()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var result = await new UpdateSiteSettingsCommandHandler(new SiteSettings(db)).Handle(
|
||||
new UpdateSiteSettingsCommand(true, " RUS , ,eng ", true),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
// Сохранение — за UnitOfWorkBehavior команды, в тесте делаем его руками.
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var dto = await new GetSiteSettingsQueryHandler(new SiteSettings(db)).Handle(
|
||||
new GetSiteSettingsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(dto.RegistrationEnabled);
|
||||
Assert.True(dto.ChannelNumbersEnabled);
|
||||
Assert.Equal("rus,eng", dto.PreferredAudioLanguages);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_Twice_RewritesSameRows()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
|
||||
foreach (var enabled in new[] { true, false })
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
await new UpdateSiteSettingsCommandHandler(new SiteSettings(db)).Handle(
|
||||
new UpdateSiteSettingsCommand(enabled, "rus", enabled),
|
||||
CancellationToken.None
|
||||
);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.New();
|
||||
var settings = new SiteSettings(verify);
|
||||
// Ключей ровно три — повторная запись обновляет строку, а не добавляет вторую.
|
||||
Assert.Equal(3, await verify.AppSettings.CountAsync());
|
||||
Assert.False(await settings.IsRegistrationEnabledAsync(CancellationToken.None));
|
||||
Assert.Equal("rus", await settings.GetPreferredAudioLanguagesAsync(CancellationToken.None));
|
||||
Assert.False(await settings.AreChannelNumbersEnabledAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using TeleWave.Application.Streaming.ListPublicChannels;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Витрина зрителя: только включённые каналы, порядок по номеру и обложка того, что идёт прямо
|
||||
/// сейчас. Выключенный канал не должен просачиваться в публичную выдачу.
|
||||
/// </summary>
|
||||
public class ListPublicChannelsTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsEnabledChannelsOrderedByNumber()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var numbered = Channel.Create("Второй", "two", T0);
|
||||
numbered.UpdateTimeSettings(
|
||||
2,
|
||||
Channel.DefaultUtcOffsetMinutes,
|
||||
Channel.DefaultDayStartTime
|
||||
);
|
||||
var first = Channel.Create("Первый", "one", T0);
|
||||
first.UpdateTimeSettings(1, Channel.DefaultUtcOffsetMinutes, Channel.DefaultDayStartTime);
|
||||
var unnumbered = Channel.Create("Аврора", "aurora", T0);
|
||||
var disabled = Channel.Create("Выключенный", "off", T0);
|
||||
|
||||
foreach (var channel in new[] { numbered, first, unnumbered })
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, null);
|
||||
disabled.UpdateSettings("Выключенный", isEnabled: false, bumpersEnabled: false, null);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.AddRange(numbered, first, unnumbered, disabled);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var channels = await new ListPublicChannelsQueryHandler(db).Handle(
|
||||
new ListPublicChannelsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
// Ненумерованный уходит в конец: на телевизоре порядок задаёт номер.
|
||||
Assert.Equal(
|
||||
new[] { "Первый", "Второй", "Аврора" },
|
||||
channels.Select(c => c.Name).ToArray()
|
||||
);
|
||||
Assert.Null(channels[^1].Number);
|
||||
Assert.All(channels, c => Assert.Null(c.CurrentShowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_AddsCurrentProgramCover()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: false, null);
|
||||
var logoId = Guid.NewGuid();
|
||||
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
||||
|
||||
var show = Show.Create("Сейчас в эфире", ShowKind.Series);
|
||||
show.SetPosterImage(Guid.NewGuid());
|
||||
var asset = MediaAsset.Register("now.mkv", ".mkv", MediaSource.Upload);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var current = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
now.AddMinutes(-5),
|
||||
now.AddMinutes(25),
|
||||
show.Id,
|
||||
0
|
||||
);
|
||||
// Прошедшая запись того же канала не должна перебить текущую.
|
||||
var past = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
now.AddHours(-2),
|
||||
now.AddHours(-1),
|
||||
Guid.NewGuid(),
|
||||
0
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.AddRange(current, past);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var channels = await new ListPublicChannelsQueryHandler(db).Handle(
|
||||
new ListPublicChannelsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
var dto = Assert.Single(channels);
|
||||
Assert.Equal(show.Id, dto.CurrentShowId);
|
||||
Assert.Equal("Сейчас в эфире", dto.CurrentShowName);
|
||||
Assert.Equal(show.PosterImageId, dto.CurrentShowPosterImageId);
|
||||
Assert.Equal(logoId, dto.LogoImageId);
|
||||
Assert.Equal(LogoCorner.TopRight, dto.LogoCorner);
|
||||
Assert.Equal(0.5, dto.LogoOpacity);
|
||||
Assert.True(dto.ShowClock);
|
||||
Assert.Equal(0.3, dto.AnalogFilterStrength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutEnabledChannels_ReturnsEmpty()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var channels = await new ListPublicChannelsQueryHandler(db).Handle(
|
||||
new ListPublicChannelsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.Empty(channels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace TeleWave.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика HTTP-клиентов поверх подставного обработчика: наружу запрос не уходит, ответ выбирается
|
||||
/// по URL. Запрошенные адреса копятся в <see cref="Urls"/> — по ним проверяем, что провайдер собрал
|
||||
/// правильный запрос (сегмент tv/movie, язык, append_to_response).
|
||||
/// </summary>
|
||||
internal sealed class FakeHttpClientFactory(Func<string, HttpResponseMessage> respond)
|
||||
: IHttpClientFactory
|
||||
{
|
||||
public List<string> Urls { get; } = [];
|
||||
|
||||
/// <summary>Один и тот же JSON на любой запрос — самый частый случай в тестах.</summary>
|
||||
public static FakeHttpClientFactory Json(string json) => new(_ => Ok(json));
|
||||
|
||||
/// <summary>Ответ-ошибка: провайдеры обязаны деградировать в null/пустой список.</summary>
|
||||
public static FakeHttpClientFactory Failing(
|
||||
HttpStatusCode status = HttpStatusCode.ServiceUnavailable
|
||||
) => new(_ => new HttpResponseMessage(status) { Content = new StringContent("{}") });
|
||||
|
||||
public static HttpResponseMessage Ok(string json) =>
|
||||
new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
public HttpClient CreateClient(string name) =>
|
||||
new(
|
||||
new StubHandler(url =>
|
||||
{
|
||||
Urls.Add(url);
|
||||
return respond(url);
|
||||
})
|
||||
);
|
||||
|
||||
/// <summary>Последний запрошенный адрес — короче, чем каждый раз доставать из списка.</summary>
|
||||
public string LastUrl => Urls.Count > 0 ? Urls[^1] : string.Empty;
|
||||
|
||||
private sealed class StubHandler(Func<string, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(respond(request.RequestUri!.ToString()));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "1.1.14",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
|
||||
Generated
+408
@@ -17,6 +17,9 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.11
|
||||
version: 2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: 1.1.14
|
||||
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -352,6 +355,9 @@ packages:
|
||||
'@radix-ui/number@1.1.3':
|
||||
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
|
||||
|
||||
'@radix-ui/primitive@1.1.2':
|
||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
||||
|
||||
'@radix-ui/primitive@1.1.7':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
@@ -368,6 +374,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.13':
|
||||
resolution: {integrity: sha512-Q8xYqNRFObXPmi45bFSkGdM2JA5hjBeYGFqzg6lZR3wp6b+cciraVq8DdneTabtws+uOYqjmsvmKOufk/M9Cyg==}
|
||||
peerDependencies:
|
||||
@@ -381,6 +400,15 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.4':
|
||||
resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==}
|
||||
peerDependencies:
|
||||
@@ -390,6 +418,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.2.1':
|
||||
resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==}
|
||||
peerDependencies:
|
||||
@@ -421,6 +458,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10':
|
||||
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.17':
|
||||
resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==}
|
||||
peerDependencies:
|
||||
@@ -434,6 +484,15 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2':
|
||||
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.5':
|
||||
resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==}
|
||||
peerDependencies:
|
||||
@@ -456,6 +515,28 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.3':
|
||||
resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==}
|
||||
peerDependencies:
|
||||
@@ -478,6 +559,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.14':
|
||||
resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.7':
|
||||
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.3.5':
|
||||
resolution: {integrity: sha512-6hLng2Rs45IZcvZ31BNvMeaFEMMEbtsog4xPcb8LZrGfyoV9fdAXqB9UKhLpTHtL0+E2DhSr6VW54Tehd6ksAQ==}
|
||||
peerDependencies:
|
||||
@@ -504,6 +611,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.4':
|
||||
resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.9':
|
||||
resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==}
|
||||
peerDependencies:
|
||||
@@ -517,6 +650,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.8':
|
||||
resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==}
|
||||
peerDependencies:
|
||||
@@ -543,6 +689,15 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.3.1':
|
||||
resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==}
|
||||
peerDependencies:
|
||||
@@ -552,6 +707,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.3':
|
||||
resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==}
|
||||
peerDependencies:
|
||||
@@ -561,6 +725,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.5':
|
||||
resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==}
|
||||
peerDependencies:
|
||||
@@ -570,6 +743,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.4':
|
||||
resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==}
|
||||
peerDependencies:
|
||||
@@ -579,6 +761,24 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.3':
|
||||
resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==}
|
||||
peerDependencies:
|
||||
@@ -597,6 +797,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1':
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.3':
|
||||
resolution: {integrity: sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==}
|
||||
peerDependencies:
|
||||
@@ -606,6 +815,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1':
|
||||
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.3':
|
||||
resolution: {integrity: sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==}
|
||||
peerDependencies:
|
||||
@@ -628,6 +846,9 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@radix-ui/rect@1.1.3':
|
||||
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
|
||||
|
||||
@@ -1735,6 +1956,8 @@ snapshots:
|
||||
|
||||
'@radix-ui/number@1.1.3': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.7': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
@@ -1746,6 +1969,15 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1758,12 +1990,24 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -1799,6 +2043,19 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
@@ -1812,6 +2069,12 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -1829,6 +2092,24 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1845,6 +2126,47 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.8)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -1873,6 +2195,26 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1882,6 +2224,15 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1921,6 +2272,13 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
@@ -1929,12 +2287,26 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.8)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
@@ -1944,6 +2316,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1951,6 +2330,19 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -1963,6 +2355,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.3
|
||||
@@ -1970,6 +2369,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8)
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8)
|
||||
@@ -1986,6 +2392,8 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@radix-ui/rect@1.1.3': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.5':
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import {
|
||||
addCollectionShow,
|
||||
getCollection,
|
||||
@@ -165,18 +165,14 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.collections.parts')}
|
||||
</h3>
|
||||
<Select value={pendingShow} onValueChange={setPendingShow}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder={t('admin.collections.addShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{available.map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Только те, кого в коллекции ещё нет: повторно добавлять некого. */}
|
||||
<ShowPicker
|
||||
value={pendingShow}
|
||||
shows={available}
|
||||
placeholder={t('admin.collections.pickShow')}
|
||||
className="h-9 w-64"
|
||||
onChange={setPendingShow}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!pendingShow || addMutation.isPending}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { importManualInbox, listManualInbox } from './api'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
@@ -221,24 +221,15 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<Select
|
||||
<ShowPicker
|
||||
value={showId}
|
||||
onValueChange={(value) => {
|
||||
shows={shows}
|
||||
placeholder={t('admin.media.manualPickShow')}
|
||||
onChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(shows ?? []).map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||
)}
|
||||
|
||||
@@ -14,15 +14,12 @@ import {
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { matchShowByName } from './match-show'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
/** Radix Select запрещает пустое значение — под «в библиотеку» используем спец-значение. */
|
||||
const LIBRARY_VALUE = '__library__'
|
||||
|
||||
export function UploadToShowDialog({
|
||||
files,
|
||||
onClose,
|
||||
@@ -119,19 +116,16 @@ export function UploadToShowDialog({
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
|
||||
</span>
|
||||
<Select value="" onValueChange={(v) => applyToAll(v === LIBRARY_VALUE ? '' : v)}>
|
||||
<SelectTrigger className="h-8 w-52">
|
||||
<SelectValue placeholder={t('admin.media.applyToAll')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
|
||||
{shows?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
|
||||
поэтому на кнопке всегда её собственная подпись. */}
|
||||
<ShowPicker
|
||||
value=""
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
placeholder={t('admin.media.applyToAll')}
|
||||
className="h-8 w-52"
|
||||
onChange={applyToAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||
@@ -148,26 +142,13 @@ export function UploadToShowDialog({
|
||||
<span className="min-w-0 flex-1 truncate" title={p.name}>
|
||||
{p.name}
|
||||
</span>
|
||||
<Select
|
||||
value={current || LIBRARY_VALUE}
|
||||
onValueChange={(v) =>
|
||||
setOverrides((o) => ({ ...o, [p.name]: v === LIBRARY_VALUE ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-56 shrink-0 whitespace-nowrap [&>span]:truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LIBRARY_VALUE}>
|
||||
{t('admin.media.toShowLibrary')}
|
||||
</SelectItem>
|
||||
{shows?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ShowPicker
|
||||
value={current}
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
className="h-8 w-56 shrink-0"
|
||||
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, ChevronDown, Plus } from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { ShowKind, ShowSummaryDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/ui/popover'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createShow } from './api'
|
||||
|
||||
/** Сколько шоу показывать за раз: длинный список всё равно не читают — его сужают поиском. */
|
||||
const MAX_VISIBLE = 50
|
||||
|
||||
/**
|
||||
* Выбор шоу с поиском и созданием на месте. Шоу в библиотеке бывают сотни, поэтому обычный
|
||||
* `Select` не годится: здесь строка поиска, а недостающее шоу заводится прямо из выпадашки —
|
||||
* не покидая разбора файлов. Кнопка создания появляется, только когда набранное имя ни с чем не
|
||||
* совпало, так что в обычном случае (шоу есть) она на глаза не попадается.
|
||||
*/
|
||||
export function ShowPicker({
|
||||
value,
|
||||
onChange,
|
||||
shows,
|
||||
noneLabel,
|
||||
placeholder,
|
||||
className,
|
||||
}: Readonly<{
|
||||
value: string
|
||||
onChange: (showId: string) => void
|
||||
shows: ShowSummaryDto[] | undefined
|
||||
/** Подпись пункта «без шоу» (в загрузке — «в библиотеку»). Не задан — шоу обязательно. */
|
||||
noneLabel?: string
|
||||
/** Что показывать на кнопке, когда ничего не выбрано. */
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const onError = useApiError()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const trimmed = query.trim()
|
||||
const matches = useMemo(() => {
|
||||
const q = trimmed.toLowerCase()
|
||||
const all = shows ?? []
|
||||
if (!q) return all.slice(0, MAX_VISIBLE)
|
||||
return all
|
||||
.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, MAX_VISIBLE)
|
||||
}, [shows, trimmed])
|
||||
|
||||
// Точное совпадение — создавать нечего: шоу уже есть, его надо просто выбрать.
|
||||
const exists = (shows ?? []).some((s) => s.name.toLowerCase() === trimmed.toLowerCase())
|
||||
const canCreate = trimmed.length > 0 && !exists
|
||||
|
||||
const selected = shows?.find((s) => s.id === value)
|
||||
const label = selected?.name ?? placeholder ?? noneLabel ?? t('admin.shows.pickerPlaceholder')
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
}
|
||||
|
||||
const pick = (showId: string) => {
|
||||
onChange(showId)
|
||||
close()
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (kind: ShowKind) => createShow({ name: trimmed, kind }),
|
||||
onSuccess: async (created) => {
|
||||
// Список шоу перечитываем до выбора: иначе кнопка покажет плейсхолдер вместо нового имени.
|
||||
await queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
toast.success(t('admin.shows.pickerCreated'))
|
||||
pick(created.id)
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
/** Стрелки/Enter по списку — иначе после поиска пришлось бы тянуться к мыши. */
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
const options = noneLabel ? matches.length + 1 : matches.length
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (options === 0) return
|
||||
const next = event.key === 'ArrowDown' ? active + 1 : active - 1
|
||||
setActive((next + options) % options)
|
||||
requestAnimationFrame(() =>
|
||||
listRef.current
|
||||
?.querySelector('[data-active="true"]')
|
||||
?.scrollIntoView({ block: 'nearest' }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
if (options === 0) {
|
||||
if (canCreate && !create.isPending) create.mutate('Series')
|
||||
return
|
||||
}
|
||||
if (noneLabel && active === 0) {
|
||||
pick('')
|
||||
return
|
||||
}
|
||||
const show = matches[noneLabel ? active - 1 : active]
|
||||
if (show) pick(show.id)
|
||||
}
|
||||
|
||||
return (
|
||||
// modal — потому что пикер живёт внутри диалогов: те держат фокус-ловушку, и без своего
|
||||
// слоя поповер отдавал бы фокус обратно диалогу, а в строку поиска нельзя было бы печатать.
|
||||
<Popover modal open={open} onOpenChange={(next) => (next ? setOpen(true) : close())}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between gap-2 rounded-sm border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('min-w-0 truncate', !selected && 'text-muted-foreground')}>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-64 p-1"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8"
|
||||
placeholder={t('admin.shows.pickerSearch')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div ref={listRef} className="mt-1 max-h-64 overflow-y-auto">
|
||||
{noneLabel && (
|
||||
<Option
|
||||
active={active === 0}
|
||||
selected={!value}
|
||||
label={noneLabel}
|
||||
onSelect={() => pick('')}
|
||||
/>
|
||||
)}
|
||||
{matches.map((show, index) => (
|
||||
<Option
|
||||
key={show.id}
|
||||
active={active === (noneLabel ? index + 1 : index)}
|
||||
selected={show.id === value}
|
||||
label={show.name}
|
||||
hint={show.originalName ?? undefined}
|
||||
onSelect={() => pick(show.id)}
|
||||
/>
|
||||
))}
|
||||
{matches.length === 0 && !canCreate && (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
{t('admin.shows.pickerEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
// Сериал — основной случай (в шоу заливают сезоны), полнометражка рядом отдельной кнопкой:
|
||||
// так создание остаётся в один клик и при этом не навязывает тип.
|
||||
<div className="mt-1 flex items-center gap-1 border-t border-border pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={create.isPending}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-primary hover:bg-muted disabled:opacity-50"
|
||||
onClick={() => create.mutate('Series')}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{t('admin.shows.pickerCreate', { name: trimmed })}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={create.isPending}
|
||||
className="shrink-0 rounded-sm border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary disabled:opacity-50"
|
||||
onClick={() => create.mutate('Single')}
|
||||
title={t('admin.shows.pickerCreateSingleHint')}
|
||||
>
|
||||
{t('admin.shows.kinds.Single')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Option({
|
||||
active,
|
||||
selected,
|
||||
label,
|
||||
hint,
|
||||
onSelect,
|
||||
}: Readonly<{
|
||||
active: boolean
|
||||
selected: boolean
|
||||
label: string
|
||||
hint?: string
|
||||
onSelect: () => void
|
||||
}>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-active={active}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted',
|
||||
active && 'bg-muted',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Check className={cn('h-3.5 w-3.5 shrink-0', !selected && 'opacity-0')} />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{hint && hint !== label && (
|
||||
<span className="min-w-0 shrink truncate text-xs text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +129,7 @@ export const en = {
|
||||
parts: 'Parts',
|
||||
units: 'Units',
|
||||
addShow: 'Add show',
|
||||
pickShow: 'Pick a show',
|
||||
empty: 'Collection is empty',
|
||||
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
|
||||
},
|
||||
@@ -295,6 +296,12 @@ export const en = {
|
||||
originalName: 'Original name (eng)',
|
||||
kind: 'Kind',
|
||||
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
|
||||
pickerPlaceholder: 'Pick a show',
|
||||
pickerSearch: 'Search by name…',
|
||||
pickerEmpty: 'Nothing found',
|
||||
pickerCreate: 'Create series “{{name}}”',
|
||||
pickerCreateSingleHint: 'Create as a movie',
|
||||
pickerCreated: 'Show created',
|
||||
audience: 'Rating',
|
||||
audienceUnset: 'Not rated',
|
||||
audiences: {
|
||||
|
||||
@@ -129,6 +129,7 @@ export const ru = {
|
||||
parts: 'Части',
|
||||
units: 'Единиц',
|
||||
addShow: 'Добавить шоу',
|
||||
pickShow: 'Выберите шоу',
|
||||
empty: 'Коллекция пуста',
|
||||
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
|
||||
},
|
||||
@@ -296,6 +297,12 @@ export const ru = {
|
||||
originalName: 'Оригинальное название (eng)',
|
||||
kind: 'Тип',
|
||||
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
|
||||
pickerPlaceholder: 'Выберите шоу',
|
||||
pickerSearch: 'Поиск по названию…',
|
||||
pickerEmpty: 'Ничего не найдено',
|
||||
pickerCreate: 'Создать сериал «{{name}}»',
|
||||
pickerCreateSingleHint: 'Создать как полнометражку',
|
||||
pickerCreated: 'Шоу создано',
|
||||
audience: 'Рейтинг',
|
||||
audienceUnset: 'Не проставлен',
|
||||
audiences: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Popover = PopoverPrimitive.Root
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
export const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
export const PopoverContent = forwardRef<
|
||||
ElementRef<typeof PopoverPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
// z-50 — как у диалога: поповер живёт и внутри диалогов, и должен быть над ними.
|
||||
className={cn('crt-panel z-50 rounded-sm outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
Reference in New Issue
Block a user