Files
LlamaCasty/ytLive.Tests/YppProgressTests.cs
T
gramps a1d861608f feat(ypp): the three eligibility bars, with dated thresholds (TASK 39·2)
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against
a threshold table that carries the date it was read.

- `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which
  encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria
  met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread.
- `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours
  (trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows
  means NO DATA, not zero.
- **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a
  test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h,
  10M → 20M Shorts views) resolves from the date, not from an edit.
- **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing
  two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle.
- Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path.

Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying
route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data),
and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
2026-11-05 16:41:09 -08:00

100 lines
3.7 KiB
C#

using System;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 39·2 — the three eligibility bars. The rules worth pinning: the view criteria are EITHER/OR (so
/// "2 of 3" can mean eligible), the long-form bar doubles on 2027-02-01, and the threshold table carries a
/// publication date that fails a test when it goes stale — YouTube moves the goalposts and a stale bar
/// must not look current.
/// </summary>
public class YppProgressTests
{
[Fact]
public void TwoOfThreeCanBeEligible_BecauseTheViewRoutesAreEitherOr()
{
var progress = new YppProgress(
Subscribers: 1_200, SubscriberTarget: 1_000,
WatchHours: 4_500, WatchHoursTarget: 4_000,
ShortsViews: 10_000, ShortsViewsTarget: 10_000_000);
Assert.Equal(2, progress.CriteriaMet);
Assert.True(progress.MeetsPublishedRule);
Assert.Equal("2 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText);
}
[Fact]
public void SubscribersWithoutEitherViewRoute_IsNotEnough()
{
var progress = new YppProgress(2_000, 1_000, 100, 4_000, 5_000, 10_000_000);
Assert.Equal(1, progress.CriteriaMet);
Assert.False(progress.MeetsPublishedRule);
Assert.Equal("1 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText);
}
[Fact]
public void ShortsAlone_IsAQualifyingRoute()
{
var progress = new YppProgress(1_000, 1_000, 0, 4_000, 12_000_000, 10_000_000);
Assert.Equal(2, progress.CriteriaMet);
Assert.True(progress.MeetsPublishedRule);
}
[Fact]
public void BarsClampAndNeverOverflow()
{
var progress = new YppProgress(5_000, 1_000, 20_000, 4_000, 50_000_000, 10_000_000);
Assert.Equal(1.0, progress.SubscribersFraction);
Assert.Equal(1.0, progress.WatchHoursFraction);
Assert.Equal(1.0, progress.ShortsViewsFraction);
Assert.Equal(3, progress.CriteriaMet);
}
[Fact]
public void TheLongFormBarDoublesOnTheAnnouncedDate()
{
var before = YppThresholds.Tier2For(new DateTime(2027, 1, 31));
var after = YppThresholds.Tier2For(new DateTime(2027, 2, 1));
Assert.Equal(4_000, before.WatchHours);
Assert.Equal(8_000, after.WatchHours);
Assert.Equal(10_000_000, before.ShortsViews);
Assert.Equal(20_000_000, after.ShortsViews);
}
[Fact]
public void ThresholdsCarryAPublicationDate_ThatFailsWhenStale()
{
// YouTube changes these. If the reading is over 180 days old, the drawer would be presenting a
// stale bar as current — so the suite refuses to pass until someone re-checks the numbers.
var age = DateTime.UtcNow - YppThresholds.AsOfUtc;
Assert.True(age < TimeSpan.FromDays(180),
$"YPP thresholds were last read on {YppThresholds.AsOfUtc:yyyy-MM-dd} — re-check YouTube's published requirements.");
}
[Fact]
public void AnalyticsResponse_ParsesTheTotal_AndTreatsNoRowsAsNoData()
{
Assert.Equal(123_456, YouTubeAnalyticsService.ParseTotal(
"""{"columnHeaders":[{"name":"estimatedMinutesWatched"}],"rows":[[123456]]}"""));
// A channel with nothing in the window returns no rows: that is "no data", not zero.
Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"columnHeaders":[]}"""));
Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"rows":[]}"""));
}
[Theory]
[InlineData(640, "640")]
[InlineData(12_345, "12.3k")]
[InlineData(1_234_567, "1.23M")]
public void ValuesStayCompactAndStableInWidth(long value, string expected)
=> Assert.Equal(expected, YppProgress.Compact(value));
}