Refactor BumperPlaceholders and BumperFacts for improved logic and readability
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 10m26s

Updated the BumperPlaceholders class to streamline token extraction from texts, enhancing performance and clarity. Modified BumperFacts to initialize slot titles with an empty array instead of a dictionary for better consistency. Renamed methods in BumperResolver for clarity, and refactored GridScheduleGenerator to simplify return logic. Additionally, improved the BumperLinesEditor component by implementing a keyed list for better state management and user experience.
This commit is contained in:
Leonid Pershin
2026-07-27 23:04:15 +03:00
parent 3d2ae20368
commit 1afe01aed8
12 changed files with 159 additions and 113 deletions
@@ -95,11 +95,12 @@ public static partial class BumperPlaceholders
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary> /// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts) public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
{ {
var used = new HashSet<string>(StringComparer.Ordinal); var matches = texts
foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t))) .Where(t => !string.IsNullOrWhiteSpace(t))
foreach (Match match in TokenPattern().Matches(text)) .SelectMany(text => TokenPattern().Matches(text))
used.Add(match.Groups[1].Value); .Select(match => match.Groups[1].Value);
return used;
return new HashSet<string>(matches, StringComparer.Ordinal);
} }
/// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary> /// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary>
@@ -59,7 +59,7 @@ internal sealed class BumperFacts
var slotTitles = tokens.Contains("slot") var slotTitles = tokens.Contains("slot")
? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken)
: new Dictionary<Guid, string>(); : [];
return new BumperFacts(items, channel, shows, slotTitles); return new BumperFacts(items, channel, shows, slotTitles);
} }
@@ -100,7 +100,7 @@ public sealed class BumperResolver(
variant.Id, variant.Id,
linesJson, linesJson,
posterShowId, posterShowId,
Signature(template, variant.Id, linesJson, posterShowId) ComputeSignature(template, variant.Id, linesJson, posterShowId)
) )
) )
); );
@@ -210,7 +210,7 @@ public sealed class BumperResolver(
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
/// <c>{channel}</c> в тексте разводит их сам собой. /// <c>{channel}</c> в тексте разводит их сам собой.
/// </summary> /// </summary>
private static string Signature( private static string ComputeSignature(
BumperTemplate template, BumperTemplate template,
Guid variantId, Guid variantId,
string linesJson, string linesJson,
@@ -447,13 +447,13 @@ public sealed class GridScheduleGenerator(
); );
} }
return elements.Count == 0 if (elements.Count == 0)
? null return null;
: new PlanningJunction(
id, var cap = template.MaxTotalSeconds is { } seconds
elements, ? TimeSpan.FromSeconds(seconds)
template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null : (TimeSpan?)null;
); return new PlanningJunction(id, elements, cap);
} }
/// <summary> /// <summary>
@@ -360,9 +360,28 @@ public sealed class GridPlanner(
PlanRun Run PlanRun Run
) )
{ {
/// <summary>
/// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти
/// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается
/// к текущему, чтобы в сетке не появлялось огрызков.
/// </summary>
private (int Duration, int Units) Measure(GroupCandidate group, int available)
{
var unit = UnitMinutes(group);
// Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком.
if (Band.UnitsPerBlock <= 0 || unit == 0)
return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0);
var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available);
// Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло,
// решает контент.
return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit)));
}
public PlannedSlot Content(GroupCandidate group, int available) public PlannedSlot Content(GroupCandidate group, int available)
{ {
var (duration, units) = Measure(Band, group, available); var (duration, units) = Measure(group, available);
Run.Reserve(group, units, duration, Target.AiringsPerWeek); Run.Reserve(group, units, duration, Target.AiringsPerWeek);
return Build( return Build(
@@ -442,29 +461,6 @@ public sealed class GridPlanner(
private static int RepeatMinutes(GridBand band) => private static int RepeatMinutes(GridBand band) =>
band.BlockMinutes > 0 ? band.BlockMinutes : 120; band.BlockMinutes > 0 ? band.BlockMinutes : 120;
/// <summary>
/// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти
/// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается
/// к текущему, чтобы в сетке не появлялось огрызков.
/// </summary>
private static (int Duration, int Units) Measure(
GridBand band,
GroupCandidate group,
int available
)
{
var unit = UnitMinutes(group);
// Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком.
if (band.UnitsPerBlock <= 0 || unit == 0)
return (Fit(band.BlockMinutes > 0 ? band.BlockMinutes : available, available), 0);
var duration = Fit(Math.Max(1, band.UnitsPerBlock) * unit, available);
// Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло,
// решает контент.
return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit)));
}
/// <summary> /// <summary>
/// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота /// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота
/// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток /// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток
@@ -63,6 +63,18 @@ public sealed record JunctionInsert(
TimeSpan Duration TimeSpan Duration
); );
/// <summary>
/// Накопители прогона, нужные раскладке стыка: история врезок (интервалы и ротация единиц),
/// жребий и сама собираемая лента. Принадлежат прогону целиком, а не отдельному стыку, поэтому
/// едут одним параметром — плоским списком сигнатура <see cref="JunctionFiller.Fill"/> перестаёт
/// читаться.
/// </summary>
public sealed record JunctionRun(
JunctionHistory History,
IRandomSource Random,
List<PlannedItem> Items
);
/// <summary> /// <summary>
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. /// Раскладка врезок стыка: реклама, промо, заставка, заполнитель.
/// ///
@@ -80,15 +92,15 @@ public static class JunctionFiller
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
JunctionPlacement placement, JunctionPlacement placement,
JunctionHistory history, JunctionRun run,
IRandomSource random,
List<PlannedItem> items,
PlanTrace? trace PlanTrace? trace
) )
{ {
if (junction is null || junction.Elements.Count == 0) if (junction is null || junction.Elements.Count == 0)
return cursor; return cursor;
var (history, random, items) = run;
var eligible = junction var eligible = junction
.Elements.Where(e => Passes(e, cursor, placement, history, random)) .Elements.Where(e => Passes(e, cursor, placement, history, random))
.ToList(); .ToList();
@@ -31,6 +31,11 @@ public static class SchedulePlanner
public List<PlanningWarning> Warnings { get; } = []; public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new(); public JunctionHistory Junctions { get; } = new();
private JunctionRun? _junctionRun;
/// <summary>Накопители, которые нужны раскладке стыка: история, жребий и сама лента.</summary>
public JunctionRun JunctionRun => _junctionRun ??= new(Junctions, Random, Items);
/// <summary>Смещение времени канала — врезки со своим окном суток считают его по нему.</summary> /// <summary>Смещение времени канала — врезки со своим окном суток считают его по нему.</summary>
public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes); public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes);
@@ -260,9 +265,7 @@ public static class SchedulePlanner
ElementChanged: run.PreviousShowId != unit.ShowId, ElementChanged: run.PreviousShowId != unit.ShowId,
run.ChannelOffset run.ChannelOffset
), ),
run.Junctions, run.JunctionRun,
run.Random,
run.Items,
slotTrace slotTrace
); );
@@ -294,9 +297,7 @@ public static class SchedulePlanner
ElementChanged: true, ElementChanged: true,
run.ChannelOffset run.ChannelOffset
), ),
run.Junctions, run.JunctionRun,
run.Random,
run.Items,
slotTrace slotTrace
); );
@@ -152,7 +152,7 @@ public sealed class FileSystemStorageInspector(
return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files); return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files);
} }
private StorageAreaUsage Area( private static StorageAreaUsage Area(
StorageArea area, StorageArea area,
string directory, string directory,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -171,7 +171,7 @@ public class BumperPreviewTests
.Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None);
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
var spec = Specs(renderer).First(); var spec = Specs(renderer)[0];
Assert.Equal("/data/images/bg.png", spec.BackgroundFile); Assert.Equal("/data/images/bg.png", spec.BackgroundFile);
// Канала нет — образцы заглушечные, но кадр всё равно собирается. // Канала нет — образцы заглушечные, но кадр всё равно собирается.
Assert.Equal("Первое шоу", spec.Lines[1].Text); Assert.Equal("Первое шоу", spec.Lines[1].Text);
@@ -201,7 +201,7 @@ public class BumperPreviewTests
.Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None);
// Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре. // Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре.
var spec = Specs(renderer).First(); var spec = Specs(renderer)[0];
Assert.Single(spec.Lines); Assert.Single(spec.Lines);
} }
} }
@@ -5,6 +5,7 @@ import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/a
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
import { useKeyedList } from '@/shared/lib/keyed-list'
import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders' import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders'
const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption'] const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption']
@@ -44,45 +45,71 @@ function presets(t: (key: string) => string): { key: string; lines: BumperLineDt
* Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем, * Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем,
* пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно * пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно
* отвергнет, но узнавать об этом при сохранении неудобно. * отвергнет, но узнавать об этом при сохранении неудобно.
*
* Список свой, со стабильными ключами строк (см. useKeyedList), а наружу уезжает только значение:
* при индексных ключах удаление строки из середины уводило бы фокус в соседнюю. Пересев на другой
* подблок, редактор пересоздаётся по `key` — поэтому props читаются только на первом рендере.
*/ */
export function BumperLinesEditor({ export function BumperLinesEditor({
lines, lines: initial,
onChange, onChange,
}: Readonly<{ }: Readonly<{
lines: BumperLineDto[] lines: BumperLineDto[]
onChange: (lines: BumperLineDto[]) => void onChange: (lines: BumperLineDto[]) => void
}>) { }>) {
const { t } = useTranslation() const { t } = useTranslation()
const focused = useRef<number | null>(null) const { rows, reset, add, remove, patch } = useKeyedList(initial)
const inputs = useRef<(HTMLInputElement | null)[]>([]) const focused = useRef<string | null>(null)
const dragged = useRef<number | null>(null) const inputs = useRef(new Map<string, HTMLInputElement | null>())
const dragged = useRef<string | null>(null)
const patch = (index: number, part: Partial<BumperLineDto>) => const setLine = (key: string, part: Partial<BumperLineDto>) => {
onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line))) patch(key, (line) => ({ ...line, ...part }))
// Правка уезжает наверх сразу: сохраняет подблок родитель, у него же лежит остальная форма.
const add = () => { onChange(rows.map((row) => (row.key === key ? { ...row.value, ...part } : row.value)))
const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' }
onChange([...lines, line].slice(0, MAX_LINES))
} }
const remove = (index: number) => onChange(lines.filter((_, i) => i !== index)) const addLine = () => {
if (rows.length >= MAX_LINES) return
const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' }
add(line)
onChange([...rows.map((row) => row.value), line])
}
const move = (from: number, to: number) => { const removeLine = (key: string) => {
if (from === to) return remove(key)
const next = [...lines] onChange(rows.filter((row) => row.key !== key).map((row) => row.value))
const [line] = next.splice(from, 1) }
next.splice(to, 0, line)
onChange(next) const applyPreset = (preset: BumperLineDto[]) => {
reset(preset)
onChange(preset)
}
const move = (fromKey: string, toKey: string) => {
if (fromKey === toKey) return
const next = [...rows]
const from = next.findIndex((row) => row.key === fromKey)
const to = next.findIndex((row) => row.key === toKey)
if (from < 0 || to < 0) return
const [row] = next.splice(from, 1)
next.splice(to, 0, row)
const values = next.map((r) => r.value)
reset(values)
onChange(values)
} }
/** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */ /** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */
const insert = (token: string) => { const insert = (token: string) => {
const index = focused.current ?? lines.length - 1 const key = focused.current ?? rows.at(-1)?.key
if (index < 0) return const row = rows.find((r) => r.key === key)
const input = inputs.current[index] if (!key || !row) return
const text = lines[index].text
const at = input?.selectionStart ?? text.length const input = inputs.current.get(key)
patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) const at = input?.selectionStart ?? row.value.text.length
const text = row.value.text
setLine(key, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` })
requestAnimationFrame(() => { requestAnimationFrame(() => {
input?.focus() input?.focus()
const caret = at + token.length + 2 const caret = at + token.length + 2
@@ -101,25 +128,25 @@ export function BumperLinesEditor({
key={preset.key} key={preset.key}
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onChange(preset.lines)} onClick={() => applyPreset(preset.lines)}
> >
{t(`admin.bumpers.preset_${preset.key}`)} {t(`admin.bumpers.preset_${preset.key}`)}
</Button> </Button>
))} ))}
</div> </div>
{lines.map((line, index) => { {rows.map(({ key, value: line }) => {
const unknown = unknownTokens(line.text) const unknown = unknownTokens(line.text)
return ( return (
<div <div
key={index} key={key}
draggable draggable
onDragStart={() => { onDragStart={() => {
dragged.current = index dragged.current = key
}} }}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={() => { onDrop={() => {
if (dragged.current !== null) move(dragged.current, index) if (dragged.current !== null) move(dragged.current, key)
dragged.current = null dragged.current = null
}} }}
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2" className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2"
@@ -128,7 +155,7 @@ export function BumperLinesEditor({
<select <select
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs" className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
value={line.style} value={line.style}
onChange={(e) => patch(index, { style: e.target.value as BumperLineStyle })} onChange={(e) => setLine(key, { style: e.target.value as BumperLineStyle })}
> >
{STYLES.map((style) => ( {STYLES.map((style) => (
<option key={style} value={style}> <option key={style} value={style}>
@@ -139,7 +166,7 @@ export function BumperLinesEditor({
<select <select
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs" className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
value={line.color} value={line.color}
onChange={(e) => patch(index, { color: e.target.value as BumperLineColor })} onChange={(e) => setLine(key, { color: e.target.value as BumperLineColor })}
> >
{COLORS.map((color) => ( {COLORS.map((color) => (
<option key={color} value={color}> <option key={color} value={color}>
@@ -149,17 +176,17 @@ export function BumperLinesEditor({
</select> </select>
<Input <Input
ref={(el) => { ref={(el) => {
inputs.current[index] = el inputs.current.set(key, el)
}} }}
className={cn('h-8 min-w-40 flex-1', unknown.length > 0 && 'border-destructive')} className={cn('h-8 min-w-40 flex-1', unknown.length > 0 && 'border-destructive')}
value={line.text} value={line.text}
maxLength={120} maxLength={120}
onFocus={() => { onFocus={() => {
focused.current = index focused.current = key
}} }}
onChange={(e) => patch(index, { text: e.target.value })} onChange={(e) => setLine(key, { text: e.target.value })}
/> />
<Button size="sm" variant="ghost" onClick={() => remove(index)}> <Button size="sm" variant="ghost" onClick={() => removeLine(key)}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
<div className="w-full pl-6 text-xs"> <div className="w-full pl-6 text-xs">
@@ -181,24 +208,27 @@ export function BumperLinesEditor({
})} })}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" disabled={lines.length >= MAX_LINES} onClick={add}> <Button size="sm" variant="outline" disabled={rows.length >= MAX_LINES} onClick={addLine}>
<Plus className="h-4 w-4" /> {t('admin.bumpers.addLine')} <Plus className="h-4 w-4" /> {t('admin.bumpers.addLine')}
</Button> </Button>
</div> </div>
{/* Палитра: клик вставляет плейсхолдер в фокусированное поле, подсказка показывает образец. */} {/* Палитра: клик вставляет плейсхолдер в фокусированное поле, подсказка показывает образец. */}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{PLACEHOLDERS.map((placeholder) => ( {PLACEHOLDERS.map((placeholder) => {
<button const description = t(`admin.bumpers.tokens.${placeholder.token}`)
key={placeholder.token} return (
type="button" <button
title={`${t(`admin.bumpers.tokens.${placeholder.token}`)}${placeholder.sample}`} key={placeholder.token}
onClick={() => insert(placeholder.token)} type="button"
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground hover:border-primary hover:text-foreground" title={`${description}${placeholder.sample}`}
> onClick={() => insert(placeholder.token)}
{`{${placeholder.token}}`} className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground hover:border-primary hover:text-foreground"
</button> >
))} {`{${placeholder.token}}`}
</button>
)
})}
</div> </div>
</div> </div>
) )
@@ -300,14 +300,17 @@ export function JunctionChain({
{/* Линейка: доля каждого звена в стыке. Пустые (без источника) в неё не попадают. */} {/* Линейка: доля каждого звена в стыке. Пустые (без источника) в неё не попадают. */}
{total > 0 && ( {total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40"> <div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
{steps.map((step, index) => ( {steps.map((step, index) => {
<div const kind = t(`admin.junctions.kinds.${step.elements[0].kind}`)
key={step.key} return (
className={KIND_COLORS[step.elements[0].kind]} <div
style={{ width: `${(estimates[index].seconds / total) * 100}%` }} key={step.key}
title={`${t(`admin.junctions.kinds.${step.elements[0].kind}`)} · ${formatClock(estimates[index].seconds)}`} className={KIND_COLORS[step.elements[0].kind]}
/> style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
))} title={`${kind} · ${formatClock(estimates[index].seconds)}`}
/>
)
})}
</div> </div>
)} )}
@@ -163,14 +163,17 @@ export function StoragePanel() {
{/* Полоса состава хранилища — доли областей друг относительно друга. */} {/* Полоса состава хранилища — доли областей друг относительно друга. */}
{storage > 0 && ( {storage > 0 && (
<div className="flex h-3 overflow-hidden rounded-full bg-muted/40"> <div className="flex h-3 overflow-hidden rounded-full bg-muted/40">
{areas.map((area) => ( {areas.map((area) => {
<div const name = t(`admin.storage.areas.${area.area}`)
key={area.area} return (
className={AREA_COLORS[area.area]} <div
style={{ width: `${percentOf(area.bytes, storage)}%` }} key={area.area}
title={`${t(`admin.storage.areas.${area.area}`)} · ${formatBytes(area.bytes)}`} className={AREA_COLORS[area.area]}
/> style={{ width: `${percentOf(area.bytes, storage)}%` }}
))} title={`${name} · ${formatBytes(area.bytes)}`}
/>
)
})}
</div> </div>
)} )}