feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order #462
@@ -99,6 +99,7 @@ in-file entries.
|
||||
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
|
||||
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
|
||||
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
|
||||
- [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404)
|
||||
|
||||
---
|
||||
|
||||
@@ -1942,3 +1943,42 @@ internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non
|
||||
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
|
||||
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
|
||||
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
|
||||
|
||||
## 2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)
|
||||
|
||||
The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
|
||||
`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
|
||||
is purely SPA (+ docs).
|
||||
|
||||
- **Two surfaces, split by where each datum lives.** The per-source *weights* are a property of the
|
||||
`MultiCollection`, so they are edited in the multi-collection editor (`/app/multi-collections`), not on the
|
||||
schedule item. The *order* (`WeightedShuffle`) is a property of the schedule item, so it is added to the
|
||||
classic schedule item's Playback Order options. Putting weights on the schedule item was rejected: the same
|
||||
multi-collection can be referenced by many schedule items, and weights are shared, not per-reference.
|
||||
- **`WeightedShuffle` is offered ONLY for `collectionType === 'MultiCollection'`** (`itemRules.ts`
|
||||
`MULTI_COLLECTION_ORDERS`) — a *meaningfulness* scope, not a rejection mirror. Weights only exist on
|
||||
multi-collection members, so the order only does something with 2+ weighted sources; on a single collection
|
||||
the classic engine still **accepts** it (verified by live-E2E: `PUT /schedules/{id}/items` with a plain
|
||||
Collection + `WeightedShuffle` → 200) but it degrades to fair-share (≈ `Shuffle`), a confusing no-op — so
|
||||
the SPA doesn't offer it there. The *rejection* the #70 entry describes is on the separate **playlist and
|
||||
block** write paths (different engines); the playlist/block editors keep their own order lists (not
|
||||
`MULTI_COLLECTION_ORDERS`) and already omit `WeightedShuffle`, so nothing extra was needed there.
|
||||
- **`WeightedShuffle` joins `ShuffleInOrder` in the `fillWithGroup` exclusion.** `fillWithGroupModeEligible`
|
||||
already excluded `ShuffleInOrder`; WeightedShuffle is excluded for the same structural reason —
|
||||
`PlayoutBuilder` splits a fill-with-group item into per-group enumerators scheduled one group at a time,
|
||||
which is incompatible with WeightedShuffle's *whole-multi-collection* per-source share of airtime. Offering
|
||||
the combination would silently not do what the name says.
|
||||
- **Fair-share is a "Reset to fair share" button, not a stateful toggle.** decisions.md 2026-07-17 established
|
||||
that fair-share is not a separate mode — it is `WeightedShuffle` with all weights left at the default 1. So
|
||||
there is nothing to toggle *on*: the button just sets every source's weight to 1 (disabled when already
|
||||
there). A persistent toggle was rejected because "fair share" has no stored representation distinct from
|
||||
"the weights all happen to be 1".
|
||||
- **Weights round-trip through the editor draft or they silently reset.** The multi-collection PUT replaces
|
||||
the whole item list, so `weight` is read in `itemsFromMultiCollection` and written in `toItemRequest`;
|
||||
dropping it would reset every source to 1 on the next unrelated save (rename, add a source). Covered by a
|
||||
round-trip test. This is the general replace-all-DTO trap now recorded in `spa-conventions.md` §4.
|
||||
- **Percentages are display-only; the wire format is the integer weight.** A 3:1 weight shows as 75% / 25%,
|
||||
computed from the clamped weights; the request always carries the integer relative share (1..1000). The
|
||||
weight field is held as a string in the editor draft so it edits smoothly, clamped to the API validator's
|
||||
1..1000 on blur and again at save — an out-of-range value never reaches the server as a raw 400. `Input`
|
||||
gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
|
||||
|
||||
@@ -66,11 +66,11 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
|
||||
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
|
||||
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `/app/schedules` (weight UI pending, gated on #388) |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
|
||||
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
|
||||
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
|
||||
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. | `MultiCollection` | `/app/multi-collections` (#151) |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
|
||||
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
|
||||
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
|
||||
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
|
||||
|
||||
@@ -172,6 +172,18 @@ fresher edit:
|
||||
ETag and the draft data come from one read and stay consistent (the fail-safe ordering; don't pair a
|
||||
list-row draft with a separately-fetched ETag). Editors that navigate back to the list on save (Playout,
|
||||
Multi/Rerun, Collection reorder) need only the 412 branch — no post-save ETag rotation.
|
||||
- **Replace-all PUT → round-trip every persisted field, even ones the editor doesn't visibly edit.** A
|
||||
replace-all update (Multi/Rerun collection, schedule items, block items) submits the *whole* item list,
|
||||
so any field the draft mapping drops is silently reset to its default server-side. The multi-collection
|
||||
editor's per-source `weight` (#404) is the canonical trap: it must be read in `itemsFromMultiCollection`
|
||||
and written in `toItemRequest`, or every source resets to weight 1 on the next save of an unrelated edit
|
||||
(rename, add a source). When you add a field to a replace-all DTO, add it to **both** the response→draft
|
||||
and draft→request mappings and cover the round-trip in a test.
|
||||
- **Numeric inputs with API bounds**: hold the value as a string in the draft so the field edits smoothly
|
||||
(transient empty/partial), pass `type="number"` + `min`/`max`/`inputMode="numeric"` to `Input` for the
|
||||
native stepper, clamp to the API's validator range on `onBlur` (immediate feedback) **and** again at save
|
||||
(authoritative) — never let an out-of-range value reach the server and surface a raw 400. Mirror the
|
||||
server's bound as a shared const (e.g. multi-collection weight `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000, #404).
|
||||
|
||||
## 5. Artwork rendering
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChangeEvent, CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||
import type { ChangeEvent, CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react';
|
||||
import { Check, ChevronDown, Minus } from 'lucide-react';
|
||||
import { Spinner } from './feedback';
|
||||
|
||||
@@ -112,6 +112,13 @@ export interface InputProps {
|
||||
type?: string;
|
||||
fullWidth?: boolean;
|
||||
style?: CSSProperties;
|
||||
// Native <input> passthroughs — added for numeric fields (e.g. the multi-collection weight input,
|
||||
// #404): min/max bound the stepper, inputMode hints the mobile keyboard, onBlur normalizes on commit.
|
||||
min?: number;
|
||||
max?: number;
|
||||
inputMode?: 'numeric' | 'decimal' | 'text';
|
||||
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
@@ -126,7 +133,12 @@ export function Input({
|
||||
size = 'md',
|
||||
type = 'text',
|
||||
fullWidth = true,
|
||||
style
|
||||
style,
|
||||
min,
|
||||
max,
|
||||
inputMode,
|
||||
onBlur,
|
||||
ariaLabel
|
||||
}: InputProps) {
|
||||
return (
|
||||
<label className={classNames('ctv-field', fullWidth && 'ctv-field-full')}>
|
||||
@@ -146,8 +158,13 @@ export function Input({
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
inputMode={inputMode}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
{trailing && <span className="ctv-field-trailing">{trailing}</span>}
|
||||
</span>
|
||||
|
||||
@@ -31,8 +31,25 @@ function draft(overrides: Partial<DraftItem> = {}): DraftItem {
|
||||
}
|
||||
|
||||
describe('playbackOrderOptions', () => {
|
||||
it('MultiCollection offers Shuffle + ShuffleInOrder only', () => {
|
||||
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder']);
|
||||
it('MultiCollection offers Shuffle + ShuffleInOrder + WeightedShuffle only', () => {
|
||||
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder', 'WeightedShuffle']);
|
||||
});
|
||||
|
||||
it('WeightedShuffle is offered ONLY on MultiCollection (needs per-source weights)', () => {
|
||||
const others: DraftItem['collectionType'][] = [
|
||||
'Collection',
|
||||
'SmartCollection',
|
||||
'SearchQuery',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist',
|
||||
'Playlist',
|
||||
'RerunFirstRun',
|
||||
'RerunRerun'
|
||||
];
|
||||
for (const t of others) {
|
||||
expect(playbackOrderOptions(t)).not.toContain('WeightedShuffle');
|
||||
}
|
||||
});
|
||||
|
||||
it('Collection / SmartCollection / SearchQuery offer the collection-like set incl. Marathon', () => {
|
||||
@@ -160,10 +177,13 @@ describe('fillWithGroupModeEligible', () => {
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'SmartCollection' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ineligible for One/Flood, ShuffleInOrder, or non-collection types', () => {
|
||||
it('ineligible for One/Flood, ShuffleInOrder, WeightedShuffle, or non-collection types', () => {
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'One', collectionType: 'Collection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Flood', collectionType: 'Collection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'ShuffleInOrder', collectionType: 'Collection' }))).toBe(false);
|
||||
// WeightedShuffle joins ShuffleInOrder in the exclusion (#404): fillWithGroup schedules per-group,
|
||||
// which conflicts with WeightedShuffle's whole-multi-collection per-source airtime share.
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'WeightedShuffle', collectionType: 'MultiCollection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'TelevisionShow' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'None', collectionType: 'Playlist' }))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,13 @@ export function nextDraftKey(): string {
|
||||
|
||||
// ---- Option lists --------------------------------------------------------
|
||||
|
||||
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder'];
|
||||
// WeightedShuffle (#70) is offered ONLY for MultiCollection: it distributes airtime across per-source
|
||||
// weights, which live on MultiCollectionItem/SmartItem, so it is only *meaningful* with 2+ weighted
|
||||
// sources. On a single collection the classic engine still accepts it but it degrades to fair-share
|
||||
// (≈ Shuffle), a confusing no-op — hence MultiCollection-only here. (Distinct concern: the playlist and
|
||||
// block editors — which keep their own order lists, not this one — must also omit it because those
|
||||
// write paths reject it outright; docs/decisions.md 2026-07-17.)
|
||||
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder', 'WeightedShuffle'];
|
||||
const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
|
||||
const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle'];
|
||||
const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle'];
|
||||
@@ -153,11 +159,14 @@ export function shufflePlaylistItemsVisible(item: DraftItem): boolean {
|
||||
return item.collectionType === 'Playlist';
|
||||
}
|
||||
|
||||
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder != ShuffleInOrder &&
|
||||
// collectionType in {Collection, MultiCollection, SmartCollection}.
|
||||
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder not in {ShuffleInOrder,
|
||||
// WeightedShuffle} && collectionType in {Collection, MultiCollection, SmartCollection}.
|
||||
// WeightedShuffle joins ShuffleInOrder in the exclusion: fillWithGroup splits the multi-collection
|
||||
// into per-group enumerators scheduled one group at a time (PlayoutBuilder), which is incompatible
|
||||
// with WeightedShuffle's whole-collection per-source share of airtime (#70/#404).
|
||||
export function fillWithGroupModeEligible(item: DraftItem): boolean {
|
||||
const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration';
|
||||
const orderOk = item.playbackOrder !== 'ShuffleInOrder';
|
||||
const orderOk = item.playbackOrder !== 'ShuffleInOrder' && item.playbackOrder !== 'WeightedShuffle';
|
||||
const typeOk =
|
||||
item.collectionType === 'Collection' ||
|
||||
item.collectionType === 'MultiCollection' ||
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('MultiCollectionsScreen', () => {
|
||||
);
|
||||
expect(postCall).toBeDefined();
|
||||
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({
|
||||
items: [{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null }],
|
||||
items: [{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1 }],
|
||||
name: 'New Bundle'
|
||||
});
|
||||
});
|
||||
@@ -155,13 +155,132 @@ describe('MultiCollectionsScreen', () => {
|
||||
expect(parsed.name).toBe('Saturday Bundle');
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null },
|
||||
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9 }
|
||||
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1 },
|
||||
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Weighted multi-collection fixture reused by the round-trip tests: 3:1 split (Favorites : Action).
|
||||
const weightedMulti = [
|
||||
{
|
||||
id: 1,
|
||||
items: [
|
||||
{ collectionId: 5, name: 'Favorites', playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
|
||||
{ collectionId: null, name: 'Action', playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
|
||||
],
|
||||
name: 'Saturday Bundle'
|
||||
}
|
||||
];
|
||||
|
||||
it('preserves weights when only an unrelated field is edited (the silent-reset trap)', async () => {
|
||||
// The canonical #404 trap: the replace-all PUT resubmits the whole item list, so editing ONLY the
|
||||
// name must still round-trip each source's weight untouched — dropping it in the draft mapping
|
||||
// would silently reset every weight to 1 on this save (docs/decisions.md 2026-07-17).
|
||||
const fetchMock = mockApi({
|
||||
multi: weightedMulti,
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/v1/multi-collections/1' && method === 'PUT'
|
||||
? jsonResponse({ id: 1, items: [], name: 'Weekend Bundle' })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<MultiCollectionsScreen />);
|
||||
fireEvent.click(await screen.findByText('Saturday Bundle'));
|
||||
await screen.findByLabelText('Weight for Favorites');
|
||||
|
||||
// Edit ONLY the name — never touch a weight input.
|
||||
fireEvent.change(screen.getByPlaceholderText('Multi-collection name'), { target: { value: 'Weekend Bundle' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save multi-collection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/v1/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
const parsed = JSON.parse(String(putCall?.[1]?.body));
|
||||
expect(parsed.name).toBe('Weekend Bundle');
|
||||
// Both original weights survive unchanged — NOT reset to 1.
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
|
||||
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips per-source weights on save (no silent reset), and edits them', async () => {
|
||||
// Weighted multi-collection: the API returns each item's weight and the update REPLACES the item
|
||||
// list, so the editor must carry weight through untouched or every source silently resets to 1
|
||||
// (docs/decisions.md 2026-07-17, #404).
|
||||
const weighted = weightedMulti;
|
||||
|
||||
const fetchMock = mockApi({
|
||||
multi: weighted,
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/v1/multi-collections/1' && method === 'PUT'
|
||||
? jsonResponse({ id: 1, items: [], name: 'Saturday Bundle' })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<MultiCollectionsScreen />);
|
||||
fireEvent.click(await screen.findByText('Saturday Bundle'));
|
||||
|
||||
// The persisted weights hydrate the inputs (75% / 25% for a 3:1 split).
|
||||
const favoritesWeight = await screen.findByLabelText('Weight for Favorites');
|
||||
expect((favoritesWeight as HTMLInputElement).value).toBe('3');
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
|
||||
// Edit one weight; an out-of-range value clamps to the API ceiling on blur (mirrors the validator).
|
||||
fireEvent.change(favoritesWeight, { target: { value: '5000' } });
|
||||
fireEvent.blur(favoritesWeight);
|
||||
expect((favoritesWeight as HTMLInputElement).value).toBe('1000');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save multi-collection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/v1/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
const parsed = JSON.parse(String(putCall?.[1]?.body));
|
||||
// Favorites clamped to 1000; Action's weight of 1 rides through unchanged (not reset).
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1000 },
|
||||
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets all weights to fair share', async () => {
|
||||
const weighted = [
|
||||
{
|
||||
id: 1,
|
||||
items: [
|
||||
{ collectionId: 5, name: 'Favorites', playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
|
||||
{ collectionId: null, name: 'Action', playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
|
||||
],
|
||||
name: 'Saturday Bundle'
|
||||
}
|
||||
];
|
||||
|
||||
mockApi({ multi: weighted });
|
||||
render(<MultiCollectionsScreen />);
|
||||
fireEvent.click(await screen.findByText('Saturday Bundle'));
|
||||
|
||||
const favoritesWeight = (await screen.findByLabelText('Weight for Favorites')) as HTMLInputElement;
|
||||
expect(favoritesWeight.value).toBe('3');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to fair share' }));
|
||||
expect(favoritesWeight.value).toBe('1');
|
||||
expect((screen.getByLabelText('Weight for Action') as HTMLInputElement).value).toBe('1');
|
||||
});
|
||||
|
||||
it('confirms and DELETEs a multi-collection', async () => {
|
||||
const fetchMock = mockApi();
|
||||
|
||||
|
||||
@@ -70,20 +70,53 @@ function useMultiCollectionsData() {
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
|
||||
// One row in the editor: a manual collection or a smart collection, plus its
|
||||
// schedule-as-group flag. Names are resolved from the fetched collection lists.
|
||||
// Per-source weight bounds. Must mirror the API's MultiCollectionItemWeight validator (1..1000) so a
|
||||
// client-side value never provokes a raw 400 (docs/decisions.md 2026-07-17, #404).
|
||||
const WEIGHT_MIN = 1;
|
||||
const WEIGHT_MAX = 1000;
|
||||
|
||||
// Coerce the editor's free-text weight to a valid integer share. Empty / non-numeric / out-of-range
|
||||
// all snap to the [1,1000] bound — 1 (the floor, = fair share) when the field is blank or invalid.
|
||||
function clampWeight(raw: string): number {
|
||||
const parsed = Math.trunc(Number(raw));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return WEIGHT_MIN;
|
||||
}
|
||||
return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, parsed));
|
||||
}
|
||||
|
||||
// One row in the editor: a manual collection or a smart collection, plus its schedule-as-group flag
|
||||
// and per-source weight. `weight` is held as a string so the number field edits smoothly (transient
|
||||
// empty / partial values are allowed); it is clamped to the API bounds on blur and again at save.
|
||||
// Names are resolved from the fetched collection lists.
|
||||
interface DraftItem {
|
||||
id: number;
|
||||
kind: 'manual' | 'smart';
|
||||
name: string;
|
||||
scheduleAsGroup: boolean;
|
||||
weight: string;
|
||||
}
|
||||
|
||||
function itemsFromMultiCollection(mc: MultiCollection): DraftItem[] {
|
||||
return mc.items.map((item) =>
|
||||
item.collectionId != null
|
||||
? { id: item.collectionId, kind: 'manual', name: item.name, scheduleAsGroup: item.scheduleAsGroup }
|
||||
: { id: item.smartCollectionId ?? 0, kind: 'smart', name: item.name, scheduleAsGroup: item.scheduleAsGroup }
|
||||
? {
|
||||
id: item.collectionId,
|
||||
kind: 'manual',
|
||||
name: item.name,
|
||||
scheduleAsGroup: item.scheduleAsGroup,
|
||||
// Round-trip the persisted weight — the update REPLACES the item list, so dropping it here
|
||||
// would silently reset every weight to 1 on save (the API returns it for exactly this
|
||||
// reason). Default 1 for rows created before weights existed (docs/decisions.md, #404).
|
||||
weight: String(item.weight ?? WEIGHT_MIN)
|
||||
}
|
||||
: {
|
||||
id: item.smartCollectionId ?? 0,
|
||||
kind: 'smart',
|
||||
name: item.name,
|
||||
scheduleAsGroup: item.scheduleAsGroup,
|
||||
weight: String(item.weight ?? WEIGHT_MIN)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +125,8 @@ function toItemRequest(item: DraftItem): MultiCollectionItemRequest {
|
||||
collectionId: item.kind === 'manual' ? item.id : null,
|
||||
playbackOrder: 'Chronological',
|
||||
scheduleAsGroup: item.scheduleAsGroup,
|
||||
smartCollectionId: item.kind === 'smart' ? item.id : null
|
||||
smartCollectionId: item.kind === 'smart' ? item.id : null,
|
||||
weight: clampWeight(item.weight)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +196,10 @@ function MultiCollectionEditor({
|
||||
}
|
||||
|
||||
const collection = collections.find((entry) => entry.id === id);
|
||||
setItems((current) => [...current, { id, kind: 'manual', name: collection?.name ?? `#${id}`, scheduleAsGroup: false }]);
|
||||
setItems((current) => [
|
||||
...current,
|
||||
{ id, kind: 'manual', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
|
||||
]);
|
||||
setSelectedCollection('');
|
||||
};
|
||||
|
||||
@@ -173,7 +210,10 @@ function MultiCollectionEditor({
|
||||
}
|
||||
|
||||
const collection = smartCollections.find((entry) => entry.id === id);
|
||||
setItems((current) => [...current, { id, kind: 'smart', name: collection?.name ?? `#${id}`, scheduleAsGroup: false }]);
|
||||
setItems((current) => [
|
||||
...current,
|
||||
{ id, kind: 'smart', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
|
||||
]);
|
||||
setSelectedSmart('');
|
||||
};
|
||||
|
||||
@@ -187,6 +227,28 @@ function MultiCollectionEditor({
|
||||
);
|
||||
};
|
||||
|
||||
// Free-text while typing (allow transient empty / partial); the value is clamped to the API bounds
|
||||
// on blur (normalizeWeight) and again at save (clampWeight in toItemRequest).
|
||||
const setWeight = (target: DraftItem, next: string) => {
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.kind === target.kind && item.id === target.id ? { ...item, weight: next } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeWeight = (target: DraftItem) => {
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.kind === target.kind && item.id === target.id ? { ...item, weight: String(clampWeight(item.weight)) } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fair share = every source at weight 1 (docs/decisions.md 2026-07-17: fair-share is not a separate
|
||||
// mode, just WeightedShuffle with equal weights — so this resets, it does not write a different order).
|
||||
const resetToFairShare = () => {
|
||||
setItems((current) => current.map((item) => ({ ...item, weight: String(WEIGHT_MIN) })));
|
||||
};
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
const save = async () => {
|
||||
@@ -240,6 +302,15 @@ function MultiCollectionEditor({
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
|
||||
// Percentages are a DISPLAY concern (the wire format is integer relative shares) — a 3:1 weight
|
||||
// shows as 75% / 25% (#404). Computed from clamped weights so a transient out-of-range edit never
|
||||
// skews the shares mid-type.
|
||||
const totalWeight = items.reduce((sum, item) => sum + clampWeight(item.weight), 0);
|
||||
const weightPercent = (item: DraftItem): number =>
|
||||
totalWeight > 0 ? Math.round((clampWeight(item.weight) / totalWeight) * 100) : 0;
|
||||
// Fair share = every source already at weight 1; nothing to reset in that state.
|
||||
const alreadyFairShare = items.length > 0 && items.every((item) => clampWeight(item.weight) === WEIGHT_MIN);
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -316,33 +387,72 @@ function MultiCollectionEditor({
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No collections added yet.</div>
|
||||
) : (
|
||||
sortedItems.map((item, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={`${item.kind}:${item.id}`}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.name}</span>
|
||||
<Badge tone="neutral">{item.kind === 'smart' ? 'Smart' : 'Manual'}</Badge>
|
||||
<label className="ctv-collections-order-toggle" title="Schedule as group">
|
||||
<Switch
|
||||
checked={item.scheduleAsGroup}
|
||||
onChange={(next) => setScheduleAsGroup(item, next)}
|
||||
<>
|
||||
{sortedItems.map((item, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={`${item.kind}:${item.id}`}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.name}</span>
|
||||
<Badge tone="neutral">{item.kind === 'smart' ? 'Smart' : 'Manual'}</Badge>
|
||||
<div
|
||||
className="ctv-collections-weight"
|
||||
title="Relative share of airtime under the Weighted Shuffle playback order"
|
||||
>
|
||||
<span className="ctv-collections-weight-label" aria-hidden="true">
|
||||
Weight
|
||||
</span>
|
||||
<Input
|
||||
ariaLabel={`Weight for ${item.name}`}
|
||||
fullWidth={false}
|
||||
inputMode="numeric"
|
||||
max={WEIGHT_MAX}
|
||||
min={WEIGHT_MIN}
|
||||
onBlur={() => normalizeWeight(item)}
|
||||
onChange={(event) => setWeight(item, event.target.value)}
|
||||
size="sm"
|
||||
style={{ width: 68 }}
|
||||
type="number"
|
||||
value={item.weight}
|
||||
/>
|
||||
<span className="ctv-collections-weight-pct">{weightPercent(item)}%</span>
|
||||
</div>
|
||||
<label className="ctv-collections-order-toggle" title="Schedule as group">
|
||||
<Switch
|
||||
checked={item.scheduleAsGroup}
|
||||
onChange={(next) => setScheduleAsGroup(item, next)}
|
||||
size="sm"
|
||||
/>
|
||||
<span>Schedule as group</span>
|
||||
</label>
|
||||
<IconButton
|
||||
onClick={() => removeItem(item)}
|
||||
size="sm"
|
||||
/>
|
||||
<span>Schedule as group</span>
|
||||
</label>
|
||||
<IconButton
|
||||
onClick={() => removeItem(item)}
|
||||
title={`Remove ${item.name}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="ctv-settings-flush-footer ctv-collections-weight-footer">
|
||||
<span className="ctv-collections-weight-hint">
|
||||
Weights set each source’s share of airtime under the <strong>Weighted Shuffle</strong> playback
|
||||
order (set on the schedule item). Equal weights = fair share.
|
||||
</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={alreadyFairShare}
|
||||
onClick={resetToFairShare}
|
||||
size="sm"
|
||||
title={`Remove ${item.name}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
Reset to fair share
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -4369,6 +4369,37 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* per-source weight input + computed share (#404) */
|
||||
.ctv-collections-weight {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 6px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.ctv-collections-weight-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-collections-weight-pct {
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
font: var(--text-2xs) / 1 var(--font-mono);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.ctv-collections-weight-footer {
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-collections-weight-hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-collections-query {
|
||||
margin-top: 3px;
|
||||
font: var(--text-2xs) / 1.3 var(--font-mono);
|
||||
|
||||
Reference in New Issue
Block a user