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.
100 lines
3.7 KiB
C#
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));
|
|
}
|