branding credit: run in every scene and in recordings, not only while live
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.
It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.
Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.
Tests (12 facts in BrandFlashOutputTests, +2):
Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
never-live VM past the 5s first-flash delay and asks the frame the pump would
composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
advances the cadence while the presenter is running, a credit coming out
proves Start() happened at construction.
ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
30-60s interval, which a synchronous test body cannot observe.
Full suite 364/364.
This commit is contained in:
+25
-10
@@ -13,6 +13,24 @@ compositing from **local recordings**. They assumed the live path needed a separ
|
|||||||
(go-live) and `:199` (record) call that same `StartAsync`. Record+simulcast is one ffmpeg with two
|
(go-live) and `:199` (record) call that same `StartAsync`. Record+simulcast is one ffmpeg with two
|
||||||
outputs. Five items, tracked in `TASKS.md` → "Creator-reported batch".
|
outputs. Five items, tracked in `TASKS.md` → "Creator-reported batch".
|
||||||
|
|
||||||
|
### Done: the branding flash is no longer a go-live-only behaviour
|
||||||
|
|
||||||
|
The presenter was `Start()`/`Stop()`-ed from `UpdateLiveVisuals()`'s `IsLive` branch, so a
|
||||||
|
recording made **without ever going live** carried no credit — exactly the creator's complaint.
|
||||||
|
It is now started **once in the `MainViewModel` ctor** and never stopped on live-state churn, so it
|
||||||
|
runs in every scene, reaches the preview, and rides the same `FramePump` into the stream and the
|
||||||
|
recording. The licence gate needs no live branch: `IsPremium`'s setter already pushes
|
||||||
|
`BrandFlashPresenter.Enabled`.
|
||||||
|
|
||||||
|
**Trap that came with it (fixed):** `Enabled = false` stops the presenter's `DispatcherTimer`.
|
||||||
|
When `Start()` was per-go-live, the next go-live restarted it; with one app-lifetime `Start()`
|
||||||
|
nothing would, so a key entered mid-session would leave the credit dead until the process
|
||||||
|
restarted. The setter now restarts the timer when re-enabling while `_running`.
|
||||||
|
|
||||||
|
Test-arithmetic trap, hit twice now: `Advance(5.1)` in one call can never observe a credit —
|
||||||
|
`Advance` opens *and* ages the presentation by the same delta, so it jumps the 2s window. Step at
|
||||||
|
1/30s like the real timer (the `Step` helpers).
|
||||||
|
|
||||||
### Done this unit: recording save dialog — Cancel now discards
|
### Done this unit: recording save dialog — Cancel now discards
|
||||||
|
|
||||||
`RenameRecordingDialog` was always correct (Enter → `DialogResult=true`, Cancel/X/Escape → `false`).
|
`RenameRecordingDialog` was always correct (Enter → `DialogResult=true`, Cancel/X/Escape → `false`).
|
||||||
@@ -97,19 +115,16 @@ The entire implementation is inside `#if DEBUG`. Release compiles to `DataRoot =
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. **Ungate the brand flash from `IsLive`** (TASK batch #2) — it must run in every scene and appear
|
1. **Confine the ticker to the alert box** (#3) — it is still a global 1920×48 top overlay
|
||||||
in recordings; today it only starts in `UpdateLiveVisuals()`'s live branch, so a recording made
|
|
||||||
without ever going live carries no credit.
|
|
||||||
2. **Confine the ticker to the alert box** (#3) — it is still a global 1920×48 top overlay
|
|
||||||
(`BlitOverlay(…, 0, 0)`); needs a rect + scale/clip decision in both preview and output.
|
(`BlitOverlay(…, 0, 0)`); needs a rect + scale/clip decision in both preview and output.
|
||||||
3. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm
|
2. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm
|
||||||
the launch method: `dotnet run` while the first app holds `bin/…/ytLive.exe` fails with MSB3027
|
the launch method: `dotnet run` while the first app holds `bin/…/ytLive.exe` fails with MSB3027
|
||||||
*before any instance starts* (a build-output lock, not a log conflict). `$env:YTLIVE_INSTANCE=2`
|
*before any instance starts* (a build-output lock, not a log conflict). `$env:YTLIVE_INSTANCE=2`
|
||||||
+ `dotnet run --no-build` in a second shell is the known-good path.
|
+ `dotnet run --no-build` in a second shell is the known-good path.
|
||||||
4. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health
|
3. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health
|
||||||
message) when a stream or recording ends.
|
message) when a stream or recording ends.
|
||||||
5. Push the commits when the creator asks.
|
4. Push the commits when the creator asks.
|
||||||
6. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out
|
5. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out
|
||||||
input, Enter inserts a newline.
|
input, Enter inserts a newline.
|
||||||
7. Decide the alert-gating copy question above.
|
6. Decide the alert-gating copy question above.
|
||||||
8. Ship-checklist items live in `TASKS.md` → "1.0 gates".
|
7. Ship-checklist items live in `TASKS.md` → "1.0 gates".
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ public sealed class BrandFlashPresenter : IDisposable
|
|||||||
get { lock (_gate) return _enabled; }
|
get { lock (_gate) return _enabled; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
|
bool restart;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_enabled == value) return;
|
if (_enabled == value) return;
|
||||||
@@ -108,8 +109,19 @@ public sealed class BrandFlashPresenter : IDisposable
|
|||||||
// presentation short instead of running it out ungated.
|
// presentation short instead of running it out ungated.
|
||||||
_elapsed = PresentationSeconds;
|
_elapsed = PresentationSeconds;
|
||||||
_timer?.Stop();
|
_timer?.Stop();
|
||||||
|
restart = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// …but a DOWNGRADE (key entered mid-session) must bring the cadence
|
||||||
|
// back. The presenter now runs for the life of the app — Start() is
|
||||||
|
// called once at startup, not per go-live — so stopping the timer on
|
||||||
|
// the premium edge without restarting it here would leave the credit
|
||||||
|
// dead until the process is restarted, even after a valid key.
|
||||||
|
restart = _running;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (restart) _timer?.Start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +131,14 @@ public sealed class BrandFlashPresenter : IDisposable
|
|||||||
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
|
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Test-only: is the <see cref="DispatcherTimer"/> that drives
|
||||||
|
/// <see cref="Advance"/> actually running? The premium/downgrade edge is the only
|
||||||
|
/// thing that ever stops and restarts it now that the presenter is started once for
|
||||||
|
/// the life of the app, and a synchronous test body cannot observe a real timer
|
||||||
|
/// tick — so assert the decision directly instead of sleeping through a 30–60s
|
||||||
|
/// interval. Never call this from product code.</summary>
|
||||||
|
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
|
||||||
|
|
||||||
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
|
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
|
||||||
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
|
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
|
||||||
public void Start()
|
public void Start()
|
||||||
|
|||||||
@@ -79,9 +79,13 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
|
|||||||
1. ✅ **Recording save dialog: Cancel discards** — was silently saving under the default name. Enter
|
1. ✅ **Recording save dialog: Cancel discards** — was silently saving under the default name. Enter
|
||||||
accepts the default; Cancel/Escape/X **deletes** the footage and names the file if the delete fails.
|
accepts the default; Cancel/Escape/X **deletes** the footage and names the file if the delete fails.
|
||||||
`MainViewModel.CompleteRecordingSave` (internal) + `ytLive.Tests/RecordingSaveDialogTests.cs`.
|
`MainViewModel.CompleteRecordingSave` (internal) + `ytLive.Tests/RecordingSaveDialogTests.cs`.
|
||||||
2. ☐ **Brand flash must run in ALL scenes, not only while live, and must be in recordings.** Currently
|
2. ✅ **Brand flash in ALL scenes + in recordings** — was gated on `IsLive` via
|
||||||
started/stopped from `UpdateLiveVisuals()`'s `IsLive` branch, so recording-only (never go-live)
|
`UpdateLiveVisuals()`, so a recording made without ever going live carried no credit. The
|
||||||
shows no credit at all. Ungate the presenter from `IsLive`; the pump already carries it to both.
|
presenter is now `Start()`ed once in the `MainViewModel` ctor and never stopped on live-state
|
||||||
|
churn; the licence gate rides on `IsPremium` → `Enabled` as before. Also fixed the
|
||||||
|
premium-edge/restart trap that app-lifetime exposed. Tests: `BrandFlashOutputTests`
|
||||||
|
(12 facts) incl. `Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt` and
|
||||||
|
`ADowngradeMidSession_RestartsTheCadenceTimer`.
|
||||||
3. ☐ **Ticker confined to the alert box.** The announcement strip is a **global 1920×48 top overlay**
|
3. ☐ **Ticker confined to the alert box.** The announcement strip is a **global 1920×48 top overlay**
|
||||||
(`AlertOverlayLayer` comment + `BlitOverlay(…, 0, 0)`), but the creator wants it over the *Stream
|
(`AlertOverlayLayer` comment + `BlitOverlay(…, 0, 0)`), but the creator wants it over the *Stream
|
||||||
Alerts video* — i.e. inside the `SourceType.AlertBox` rect, in preview AND output. Needs a rect
|
Alerts video* — i.e. inside the `SourceType.AlertBox` rect, in preview AND output. Needs a rect
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ public partial class MainViewModel
|
|||||||
/// the licence check lives inside the presenter so no caller can bypass it.</summary>
|
/// the licence check lives inside the presenter so no caller can bypass it.</summary>
|
||||||
public VideoFrame? BrandFlashFrame() => _brandFlash.Frame;
|
public VideoFrame? BrandFlashFrame() => _brandFlash.Frame;
|
||||||
|
|
||||||
|
/// <summary>Test-only seam: advances the credit's clock deterministically, exactly
|
||||||
|
/// like <see cref="Services.BrandFlashPresenter.Advance"/>. It proves the wiring
|
||||||
|
/// (the presenter is started, and NOT gated on <c>IsLive</c>) without waiting out
|
||||||
|
/// the real 5s DispatcherTimer — and because <c>Advance</c> only advances the
|
||||||
|
/// cadence while the presenter is running, a credit coming out of here is proof
|
||||||
|
/// that <c>Start()</c> happened at construction. Never call this from product code.</summary>
|
||||||
|
internal void AdvanceBrandFlash(double seconds) => _brandFlash.Advance(seconds);
|
||||||
|
|
||||||
/// <summary>Publish one credit frame to the preview pane, UI thread only (the
|
/// <summary>Publish one credit frame to the preview pane, UI thread only (the
|
||||||
/// presenter fires this from its own dispatcher tick, so the bitmap write is legal).
|
/// presenter fires this from its own dispatcher tick, so the bitmap write is legal).
|
||||||
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
|
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
|
||||||
|
|||||||
@@ -429,18 +429,15 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "Transparent";
|
PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "Transparent";
|
||||||
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
||||||
|
|
||||||
if (live)
|
if (!live) _healthPollTimer.Stop();
|
||||||
{
|
|
||||||
// Cadence lives in the presenter: first credit 5s after go-live, then
|
// NOTE: the branding credit is deliberately NOT started/stopped here.
|
||||||
// every rand(30s)+30s. It refuses to emit while premium.
|
// CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in
|
||||||
_brandFlash.Enabled = !IsPremium;
|
// recordings, not only while streaming. It used to be gated on IsLive, which
|
||||||
_brandFlash.Start();
|
// meant a recording made without ever going live carried no credit at all.
|
||||||
}
|
// The presenter is started once for the life of the app (MainViewModel ctor);
|
||||||
else
|
// the licence gate rides on BrandFlashPresenter.Enabled via IsPremium, so it
|
||||||
{
|
// needs no live branch — IsPremium's setter pushes it from any thread.
|
||||||
_healthPollTimer.Stop();
|
|
||||||
_brandFlash.Stop();
|
|
||||||
}
|
|
||||||
UpdateSessionTimer();
|
UpdateSessionTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
_brandFlash = new BrandFlashPresenter();
|
_brandFlash = new BrandFlashPresenter();
|
||||||
_brandFlash.OnFrame = PublishBrandFlashPreview;
|
_brandFlash.OnFrame = PublishBrandFlashPreview;
|
||||||
|
// Start the cadence ONCE for the life of the app, here. CREATOR RULING
|
||||||
|
// 2026-09-26: the credit must show in every scene and in recordings, so it
|
||||||
|
// cannot be tied to IsLive (that gate meant a recording made without going
|
||||||
|
// live carried no credit). The pump reads BrandFlashFrame() whenever it runs
|
||||||
|
// — go-live and local recording are the same FramePump, so one start covers
|
||||||
|
// output, recording and the preview pane alike. Enabled is seeded from the
|
||||||
|
// current licence state; ValidateLicenseAtStartupAsync re-pushes it later.
|
||||||
|
_brandFlash.Enabled = !IsPremium;
|
||||||
|
_brandFlash.Start();
|
||||||
|
|
||||||
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
|
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
|
||||||
_healthPollTimer.Tick += OnHealthPollTick;
|
_healthPollTimer.Tick += OnHealthPollTick;
|
||||||
|
|||||||
@@ -1480,6 +1480,24 @@ crooked.
|
|||||||
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting.
|
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting.
|
||||||
License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes
|
License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes
|
||||||
between free and paid, ever.
|
between free and paid, ever.
|
||||||
|
**Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is
|
||||||
|
`Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be
|
||||||
|
gated on `IsLive`, which meant a recording made without ever going live carried no credit — the
|
||||||
|
creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"*
|
||||||
|
and *"should also appear in recordings"*. One start now covers every scene, the preview, the
|
||||||
|
stream and the recording, because go-live and local recording are the **same `FramePump`**
|
||||||
|
(`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:`
|
||||||
|
delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The
|
||||||
|
licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere.
|
||||||
|
**Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops
|
||||||
|
the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer
|
||||||
|
called per go-live, nothing would ever restart it, so a key entered mid-session would leave the
|
||||||
|
credit dead until the process restarted. The setter therefore restarts it when re-enabling while
|
||||||
|
`_running` (`IsCadenceTimerEnabled` is the test seam).
|
||||||
|
**Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a
|
||||||
|
credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single
|
||||||
|
5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer
|
||||||
|
(the `Step` helpers in `BrandFlashOutputTests`).
|
||||||
- **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a
|
- **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a
|
||||||
**derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on
|
**derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on
|
||||||
every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick
|
every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick
|
||||||
|
|||||||
@@ -381,6 +381,97 @@ public sealed class BrandFlashOutputTests
|
|||||||
Assert.True(prop.CanRead, "the preview needs to read the gate state");
|
Assert.True(prop.CanRead, "the preview needs to read the gate state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- the credit is NOT a go-live-only behaviour ----------
|
||||||
|
|
||||||
|
/// <summary>Reported 2026-09-26: "the made with llamacasty flash should appear in
|
||||||
|
/// all scenes, not just live" and "should also appear in recordings". The presenter
|
||||||
|
/// used to be Start/Stop-ed from <c>UpdateLiveVisuals</c>'s IsLive branch, so a
|
||||||
|
/// recording made WITHOUT ever going live carried no credit at all.
|
||||||
|
/// <para>This drives a real, freshly-constructed ViewModel that has never gone
|
||||||
|
/// live, advances past the 5s first-flash delay and asks the frame the frame pump
|
||||||
|
/// would composite. A non-null frame here is the whole fix: the cadence is running
|
||||||
|
/// for the life of the app, and since go-live and local recording are the SAME
|
||||||
|
/// FramePump (both call <c>StartAsync</c> with the same <c>brandFlash:</c> delegate)
|
||||||
|
/// the recording carries the credit too.</para></summary>
|
||||||
|
[Fact]
|
||||||
|
public void Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt()
|
||||||
|
{
|
||||||
|
_app.Run(() =>
|
||||||
|
{
|
||||||
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||||
|
MainViewModel.LayoutPathOverride = tempDb;
|
||||||
|
var window = new MainWindow();
|
||||||
|
var vm = (MainViewModel)window.DataContext;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
// The precondition that used to guarantee an empty credit: offline,
|
||||||
|
// not recording, never broadcast.
|
||||||
|
Assert.False(vm.IsLive, "this test is about the NOT-live case");
|
||||||
|
Assert.False(vm.IsRecording);
|
||||||
|
Assert.Equal(StreamStatus.Offline, vm.StreamStatus);
|
||||||
|
|
||||||
|
// No credit is due yet — the first one lands 5s in.
|
||||||
|
Assert.Null(vm.BrandFlashFrame());
|
||||||
|
|
||||||
|
// Step it in 1/30s increments like the real DispatcherTimer. Do NOT
|
||||||
|
// pass 5.1 in one call: Advance both opens the presentation AND ages
|
||||||
|
// it by the same delta, so a single 5.1s step would blow clean through
|
||||||
|
// the 2s window and the credit would be gone before it was ever read.
|
||||||
|
Step(vm, 5.1);
|
||||||
|
|
||||||
|
var frame = vm.BrandFlashFrame();
|
||||||
|
Assert.True(frame != null,
|
||||||
|
"no credit with no live session: the cadence is still gated on "
|
||||||
|
+ "IsLive, so recordings made without going live carry no branding");
|
||||||
|
Assert.Equal(1920, frame!.Width);
|
||||||
|
Assert.Equal(1080, frame.Height);
|
||||||
|
|
||||||
|
// And it reaches the preview pane in this state too, not just the encoder.
|
||||||
|
vm.PublishBrandFlashPreview(frame);
|
||||||
|
window.UpdateLayout();
|
||||||
|
var pane = (UserControl)window.FindName("PreviewPane")!;
|
||||||
|
var holder = Find(pane, e => e is FrameworkElement f
|
||||||
|
&& f.Name == "BrandFlashElement") as FrameworkElement;
|
||||||
|
Assert.Equal(Visibility.Visible, holder!.Visibility);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
MainViewModel.LayoutPathOverride = null;
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The presenter is started ONCE for the life of the app now, so the
|
||||||
|
/// premium edge stopping its timer is no longer rescued by the next go-live
|
||||||
|
/// calling <c>Start()</c> again: a key entered mid-session used to leave the credit
|
||||||
|
/// dead until the process restarted. Re-enabling must resume the cadence.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ADowngradeMidSession_RestartsTheCadenceTimer()
|
||||||
|
{
|
||||||
|
_app.Run(() =>
|
||||||
|
{
|
||||||
|
using var presenter = new BrandFlashPresenter(new Random(11));
|
||||||
|
presenter.Start();
|
||||||
|
Assert.True(presenter.IsCadenceTimerEnabled,
|
||||||
|
"Start() must leave the cadence timer running");
|
||||||
|
|
||||||
|
presenter.Enabled = false; // premium -> the advertisement is cut and the timer stops
|
||||||
|
Assert.False(presenter.IsCadenceTimerEnabled);
|
||||||
|
|
||||||
|
presenter.Enabled = true; // key entered mid-session: no second Start() will come
|
||||||
|
Assert.True(presenter.IsCadenceTimerEnabled,
|
||||||
|
"the credit stayed dead after a downgrade — the premium edge stopped the "
|
||||||
|
+ "timer and nothing restarted it, because Start() is now a one-time "
|
||||||
|
+ "app-startup call rather than a per-go-live one");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
|
|
||||||
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
||||||
@@ -401,6 +492,12 @@ public sealed class BrandFlashOutputTests
|
|||||||
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0);
|
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same stepping, against the ViewModel's own presenter.</summary>
|
||||||
|
private static void Step(MainViewModel vm, double seconds)
|
||||||
|
{
|
||||||
|
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) vm.AdvanceBrandFlash(1.0 / 30.0);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
|
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
|
||||||
private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame)
|
private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user