diff --git a/Models/Source.cs b/Models/Source.cs index 24c870a..4e9479b 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -41,6 +41,13 @@ public enum MediaPlaybackState Paused } +public enum TextLayerAlignment +{ + Left, + Center, + Right +} + public enum ClipShape { Traditional, @@ -391,6 +398,58 @@ public class Source : SceneElement public override bool IsChatBox => Type == SourceType.ChatBox; + private string _textContent = "Text"; + public string TextContent + { + get => _textContent; + set => Set(ref _textContent, value ?? string.Empty); + } + + private string _textFontFamily = "Segoe UI"; + public string TextFontFamily + { + get => _textFontFamily; + set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim()); + } + + private double _textFontSize = 48; + public double TextFontSize + { + get => _textFontSize; + set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48); + } + + private string _textColor = "#FFFFFF"; + public string TextColor + { + get => _textColor; + set => Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF"); + } + + private bool _textBold; + public bool TextBold { get => _textBold; set => Set(ref _textBold, value); } + + private bool _textItalic; + public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); } + + private bool _textUnderline; + public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); } + + private TextLayerAlignment _textAlignment; + public TextLayerAlignment TextAlignment + { + get => _textAlignment; + set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left); + } + + private static bool IsValidTextColor(string? value) + { + if (value is not { Length: 7 or 9 } || value[0] != '#') return false; + for (var i = 1; i < value.Length; i++) + if (!Uri.IsHexDigit(value[i])) return false; + return true; + } + public override bool IsCountdown => Type == SourceType.Countdown; private bool _chatPreviewEnabled = true; diff --git a/Models/index.md b/Models/index.md index cf8302d..a3e147d 100644 --- a/Models/index.md +++ b/Models/index.md @@ -7,7 +7,7 @@ Plain data types. No logic beyond what a property can carry. See |------|---------| | `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor | | `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` | -| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row | +| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row | | `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker | | `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them | | `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) | diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 5c536d2..68a8135 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -111,7 +111,9 @@ public partial class LayoutStore : IDisposable ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder, - AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop + AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, + TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, + TextUnderline, TextAlignment FROM Source ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -167,6 +169,16 @@ public partial class LayoutStore : IDisposable BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")), AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")), AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0, + TextContent = reader.IsDBNull(41) ? "Text" : reader.GetString(41), + TextFontFamily = reader.IsDBNull(42) ? "Segoe UI" : reader.GetString(42), + TextFontSize = reader.IsDBNull(43) ? 48 : reader.GetDouble(43), + TextColor = reader.IsDBNull(44) ? "#FFFFFF" : reader.GetString(44), + TextBold = !reader.IsDBNull(45) && reader.GetInt32(45) != 0, + TextItalic = !reader.IsDBNull(46) && reader.GetInt32(46) != 0, + TextUnderline = !reader.IsDBNull(47) && reader.GetInt32(47) != 0, + TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48)) + ? TextLayerAlignment.Left + : (TextLayerAlignment)reader.GetInt32(48), }; if (!sourcesByScene.TryGetValue(sceneId, out var list)) sourcesByScene[sceneId] = list = new List<(Source, int)>(); diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 741c3ed..209680a 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -290,6 +290,56 @@ public partial class LayoutStore : IDisposable cmd.ExecuteNonQuery(); } + // TASK 3.17: independent static text-layer content and formatting. + if (!columns.Contains("TextContent")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextContent TEXT NOT NULL DEFAULT 'Text';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextFontFamily")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontFamily TEXT NOT NULL DEFAULT 'Segoe UI';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextFontSize")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontSize REAL NOT NULL DEFAULT 48;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextColor")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextColor TEXT NOT NULL DEFAULT '#FFFFFF';"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextBold")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextBold INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextItalic")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextItalic INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextUnderline")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextUnderline INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("TextAlignment")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("WebUri")) { using var cmd = _connection.CreateCommand(); diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 874d172..54e2c7d 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -92,7 +92,9 @@ public partial class LayoutStore : IDisposable ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor, WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState, AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, - AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop) + AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop, + TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, + TextUnderline, TextAlignment) VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, $x, $y, $w, $h, $opacity, $monitor, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, @@ -100,7 +102,9 @@ public partial class LayoutStore : IDisposable $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor, $webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState, $alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume, - $alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop) + $alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop, + $textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic, + $textUnderline, $textAlignment) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -144,6 +148,14 @@ public partial class LayoutStore : IDisposable cmd.Parameters.Add("$blurAmount", SqliteType.Real); var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text); var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer); + var textContentP = cmd.Parameters.Add("$textContent", SqliteType.Text); + var textFontFamilyP = cmd.Parameters.Add("$textFontFamily", SqliteType.Text); + var textFontSizeP = cmd.Parameters.Add("$textFontSize", SqliteType.Real); + var textColorP = cmd.Parameters.Add("$textColor", SqliteType.Text); + var textBoldP = cmd.Parameters.Add("$textBold", SqliteType.Integer); + var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer); + var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer); + var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer); // Source and WebcamSceneConfig share ONE z-order space per scene: // each element gets its index within scene.Elements regardless of @@ -196,6 +208,14 @@ public partial class LayoutStore : IDisposable cmd.Parameters["$blurAmount"].Value = source.BlurAmount; alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value; alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0; + textContentP.Value = source.TextContent; + textFontFamilyP.Value = source.TextFontFamily; + textFontSizeP.Value = source.TextFontSize; + textColorP.Value = source.TextColor; + textBoldP.Value = source.TextBold ? 1 : 0; + textItalicP.Value = source.TextItalic ? 1 : 0; + textUnderlineP.Value = source.TextUnderline ? 1 : 0; + textAlignmentP.Value = (int)source.TextAlignment; cmd.ExecuteNonQuery(); } } diff --git a/Services/index.md b/Services/index.md index ca13b04..3eae847 100644 --- a/Services/index.md +++ b/Services/index.md @@ -15,7 +15,7 @@ External-facing logic: YouTube API, persistence. See | `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) | | `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) | | `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake | -| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | +| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds eight additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/alignment), also without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes | | `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) | diff --git a/ytLive.Tests/LayoutStorePersistenceTests.cs b/ytLive.Tests/LayoutStorePersistenceTests.cs index 4eb5c9c..c54d1e1 100644 --- a/ytLive.Tests/LayoutStorePersistenceTests.cs +++ b/ytLive.Tests/LayoutStorePersistenceTests.cs @@ -15,6 +15,97 @@ namespace ytLive.Tests; /// public class LayoutStorePersistenceTests { + [Fact] + public void Text_Layers_RoundTrip_Independently_And_Legacy_Rows_Get_Defaults() + { + var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db"); + try + { + var scene = new Scene { Name = "Starting" }; + scene.Elements.Add(new Source + { + Name = "Headline", + Type = SourceType.TextOverlay, + TextContent = "First line\nSecond line", + TextFontFamily = "Arial", + TextFontSize = 72, + TextColor = "#12AB34", + TextBold = true, + TextAlignment = TextLayerAlignment.Center, + }); + scene.Elements.Add(new Source + { + Name = "Caption", + Type = SourceType.TextOverlay, + TextContent = "Independent caption", + TextFontFamily = "Segoe UI", + TextFontSize = 24, + TextColor = "#80445566", + TextItalic = true, + TextUnderline = true, + TextAlignment = TextLayerAlignment.Right, + }); + + using (var store = new LayoutStore(path)) + store.Save(new[] { scene }, null, null); + + using (var reopened = new LayoutStore(path)) + { + var sources = Assert.Single(reopened.Load()).Elements.OfType() + .Where(source => source.Type == SourceType.TextOverlay).ToArray(); + Assert.Equal(2, sources.Length); + Assert.Equal("First line\nSecond line", sources[0].TextContent); + Assert.Equal("Arial", sources[0].TextFontFamily); + Assert.Equal(72, sources[0].TextFontSize); + Assert.Equal("#12AB34", sources[0].TextColor); + Assert.True(sources[0].TextBold); + Assert.False(sources[0].TextItalic); + Assert.False(sources[0].TextUnderline); + Assert.Equal(TextLayerAlignment.Center, sources[0].TextAlignment); + Assert.Equal("Independent caption", sources[1].TextContent); + Assert.Equal(24, sources[1].TextFontSize); + Assert.Equal("#80445566", sources[1].TextColor); + Assert.False(sources[1].TextBold); + Assert.True(sources[1].TextItalic); + Assert.True(sources[1].TextUnderline); + Assert.Equal(TextLayerAlignment.Right, sources[1].TextAlignment); + } + + SqliteConnection.ClearAllPools(); + using (var raw = new SqliteConnection($"Data Source={path}")) + { + raw.Open(); + using var cmd = raw.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source DROP COLUMN TextContent; ALTER TABLE Source DROP COLUMN TextFontFamily; ALTER TABLE Source DROP COLUMN TextFontSize; ALTER TABLE Source DROP COLUMN TextColor; ALTER TABLE Source DROP COLUMN TextBold; ALTER TABLE Source DROP COLUMN TextItalic; ALTER TABLE Source DROP COLUMN TextUnderline; ALTER TABLE Source DROP COLUMN TextAlignment;"; + cmd.ExecuteNonQuery(); + } + SqliteConnection.ClearAllPools(); + + using (var legacy = new LayoutStore(path)) + { + var sources = Assert.Single(legacy.Load()).Elements.OfType() + .Where(source => source.Type == SourceType.TextOverlay).ToArray(); + Assert.Equal(2, sources.Length); + foreach (var source in sources) + { + Assert.Equal("Text", source.TextContent); + Assert.Equal("Segoe UI", source.TextFontFamily); + Assert.Equal(48, source.TextFontSize); + Assert.Equal("#FFFFFF", source.TextColor); + Assert.False(source.TextBold); + Assert.False(source.TextItalic); + Assert.False(source.TextUnderline); + Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); + } + } + } + finally + { + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + /// TASK 47: the alert announcement display method must survive a /// restart. This one is easy to get quietly wrong — the Source row is read back /// by POSITIONAL index, so a column inserted anywhere but the end silently diff --git a/ytLive.Tests/SourceTextSettingsTests.cs b/ytLive.Tests/SourceTextSettingsTests.cs new file mode 100644 index 0000000..e2c8469 --- /dev/null +++ b/ytLive.Tests/SourceTextSettingsTests.cs @@ -0,0 +1,46 @@ +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +public class SourceTextSettingsTests +{ + [Fact] + public void Text_Layer_Settings_Have_Safe_Defaults() + { + var source = new Source { Type = SourceType.TextOverlay }; + + Assert.Equal("Text", source.TextContent); + Assert.Equal("Segoe UI", source.TextFontFamily); + Assert.Equal(48, source.TextFontSize); + Assert.Equal("#FFFFFF", source.TextColor); + Assert.False(source.TextBold); + Assert.False(source.TextItalic); + Assert.False(source.TextUnderline); + Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); + } + + [Fact] + public void Text_Layer_Settings_Normalize_Invalid_Values() + { + var source = new Source + { + TextFontFamily = " ", + TextFontSize = double.NaN, + TextColor = "not-a-color", + TextAlignment = (TextLayerAlignment)999, + }; + + Assert.Equal("Segoe UI", source.TextFontFamily); + Assert.Equal(48, source.TextFontSize); + Assert.Equal("#FFFFFF", source.TextColor); + Assert.Equal(TextLayerAlignment.Left, source.TextAlignment); + + source.TextFontSize = 1; + Assert.Equal(8, source.TextFontSize); + source.TextFontSize = 1000; + Assert.Equal(300, source.TextFontSize); + source.TextColor = "#abcdef"; + Assert.Equal("#ABCDEF", source.TextColor); + } +}