diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
index c3ce861..d5da346 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
@@ -101,10 +101,12 @@ public static class JunctionFiller
var (history, random, items) = run;
- var eligible = junction
- .Elements.Where(e => Passes(e, cursor, placement, history, random))
- .ToList();
- var chosen = ResolveChoices(eligible, random);
+ // Жребий бросается после схлопывания развилки, а не вместе с остальными условиями: из пяти
+ // заставок с шансом 60 % каждая бросала бы свой кубик, и «заставка в шести случаях из
+ // десяти» превращалось бы в «какая-нибудь заставка почти всегда». Развилка — это один
+ // выбор, значит и жребий у неё один.
+ var eligible = junction.Elements.Where(e => Passes(e, cursor, placement, history)).ToList();
+ var chosen = ResolveChoices(eligible, random).Where(e => Rolls(e, random)).ToList();
var available = limit - cursor;
if (junction.MaxTotal is { } cap && cap < available)
@@ -134,13 +136,15 @@ public static class JunctionFiller
return cursor;
}
- /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий.
+ ///
+ /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, круглый час. Жребий
+ /// сюда не входит — он бросается после развилки (см. ).
+ ///
private static bool Passes(
PlanningJunctionElement element,
DateTimeOffset cursor,
JunctionPlacement placement,
- JunctionHistory history,
- IRandomSource random
+ JunctionHistory history
)
{
if (element.OnlyOnElementChange && !placement.ElementChanged)
@@ -155,14 +159,19 @@ public static class JunctionFiller
return false;
}
- if (!NearHour(element, cursor, placement.ChannelOffset))
- return false;
-
- // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
- // поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
- return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
+ return NearHour(element, cursor, placement.ChannelOffset);
}
+ ///
+ /// Выпал ли жребий этой врезке. У развилки жребий один на всю метку: бросает уже выбранный
+ /// вариант, и его шанс — это шанс всей развилки.
+ ///
+ /// Источник — тот же, что и у всего прогона: он зависит от координат генерации, поэтому
+ /// пересборка хвоста не перетасовывает врезки на каждое применение.
+ ///
+ private static bool Rolls(PlanningJunctionElement element, IRandomSource random) =>
+ element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
+
///
/// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что
/// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь
diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs
index ac010e4..d7f6612 100644
--- a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs
+++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs
@@ -21,6 +21,22 @@ public class JunctionFillerTests
public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
}
+ ///
+ /// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу
+ /// его и отличаем от выбора внутри развилки, который спрашивает сумму весов.
+ ///
+ private sealed class CountingRandom : IRandomSource
+ {
+ public int Rolls { get; private set; }
+
+ public int Next(int maxExclusive)
+ {
+ if (maxExclusive == 100)
+ Rolls++;
+ return 0;
+ }
+ }
+
private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) =>
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index);
@@ -318,6 +334,43 @@ public class JunctionFillerTests
Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad);
}
+ [Fact]
+ public void Choice_RollsChanceOnce_ForTheWholeFork()
+ {
+ // Три заставки в одной развилке — это один выбор, а не три независимых. Бросок жребия
+ // на каждую превратил бы «показать в 60 % случаев» в «показать хоть какую-то почти всегда».
+ var element = Series(2, 20, out _);
+ var fork = new PlanningJunction(
+ Guid.NewGuid(),
+ [
+ Ads(1, chance: 60, choiceKey: "fork"),
+ Ads(1, chance: 60, choiceKey: "fork"),
+ Ads(1, chance: 60, choiceKey: "fork"),
+ ]
+ );
+
+ var random = new CountingRandom();
+ Run(Slot(element, 2, between: fork), random);
+
+ Assert.Equal(1, random.Rolls);
+ }
+
+ [Fact]
+ public void Choice_TakesChanceOfThePickedVariant()
+ {
+ // Жребий бросает уже выбранный вариант: у него шанс ноль — значит развилка молчит целиком,
+ // а не подставляет вместо него соседа с сотней.
+ var element = Series(2, 20, out _);
+ var fork = new PlanningJunction(
+ Guid.NewGuid(),
+ [Ads(1, chance: 0, choiceKey: "fork"), Ads(1, chance: 100, choiceKey: "fork")]
+ );
+
+ var result = Run(Slot(element, 2, between: fork), new FirstAlways());
+
+ Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
+ }
+
[Fact]
public void MaxTotal_CapsJunctionLength()
{
diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md
index 7874819..7df2d61 100644
--- a/docs/tv-scheduler-architecture.md
+++ b/docs/tv-scheduler-architecture.md
@@ -532,6 +532,14 @@ JunctionElement
непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия
относятся к развилке целиком.
+**Жребий бросается после выбора варианта, а не до.** Порядок здесь и есть смысл настройки: развилка
+из пяти заставок с `chance = 60` — это «заставка в шести случаях из десяти, каждый раз разная».
+Бросай кубик за каждую врезку отдельно, и получилось бы «какая-нибудь заставка почти всегда»
+(1 − 0,4⁵ ≈ 99 %). Поэтому сначала отсекают условия (смена шоу, интервал, окно суток, круглый час),
+потом развилка схлопывается в один вариант по весам, и уже он бросает единственный жребий своим
+`chance`. Разные шансы у вариантов допустимы и означают ровно то, что написано: сыграет шанс
+выбранного.
+
Оба жребия — `chance` и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого
`Random`. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы
изменения там, где ничего не менялось.
diff --git a/frontend/src/features/admin/channels/components/AirSchedule.tsx b/frontend/src/features/admin/channels/components/AirSchedule.tsx
index 4b0740b..6441a99 100644
--- a/frontend/src/features/admin/channels/components/AirSchedule.tsx
+++ b/frontend/src/features/admin/channels/components/AirSchedule.tsx
@@ -54,6 +54,9 @@ export function AirSchedule({
const { t } = useTranslation()
const [day, setDay] = useState(0)
const [page, setPage] = useState(1)
+ // По умолчанию прошедшее скрыто: эфир смотрят, чтобы понять, что будет, а вчерашние страницы
+ // приходилось пролистывать каждый раз заново.
+ const [hidePast, setHidePast] = useState(true)
const range = useMemo(() => {
const from = dayStartUtc(day, utcOffsetMinutes, dayStartTime)
@@ -65,9 +68,16 @@ export function AirSchedule({
queryFn: () => getSchedule(channelId, range.from, range.to),
})
- const entries = data ?? []
- const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE))
- const pageItems = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
+ const entries = useMemo(() => data ?? [], [data])
+ const visible = useMemo(() => {
+ if (!hidePast) return entries
+ // Идущая запись остаётся: то, что сейчас в эфире, — ещё не история.
+ const now = Date.now()
+ return entries.filter((entry) => new Date(entry.endsAtUtc).getTime() > now)
+ }, [entries, hidePast])
+
+ const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE))
+ const pageItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (