using System; using System.IO; using System.Linq; using NUnit.Framework; using ProjectQuint.Core.Bms; using ProjectQuint.Core.Progression; using ProjectQuint.Library; using ProjectQuint.Persistence; using ProjectQuint.Bootstrap; using ProjectQuint.Features.Profile; using UnityEngine; namespace ProjectQuint.Tests { public sealed class ProfileProgressionTests { private string _temporaryDirectory; [SetUp] public void SetUp() { _temporaryDirectory = Path.Combine( Path.GetTempPath(), "ProjectQuint-progression-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_temporaryDirectory); } [TearDown] public void TearDown() { if (Directory.Exists(_temporaryDirectory)) Directory.Delete(_temporaryDirectory, true); } [Test] public void Rating_UsesDifficultyConstantAccuracyAndHardClearBonus() { Assert.That(RatingRules.TryParseChartConstant("15", out var level15), Is.True); Assert.That(RatingRules.TryParseChartConstant("15+", out var level15Plus), Is.True); Assert.That(level15, Is.EqualTo(15.0)); Assert.That(level15Plus, Is.EqualTo(15.5)); Assert.That(RatingRules.CalculatePlayRating(17.0, 100.0, false), Is.EqualTo(17.0).Within(0.000001)); Assert.That(RatingRules.CalculatePlayRating(17.0, 100.0, true), Is.EqualTo(17.5).Within(0.000001)); Assert.That(RatingRules.CalculatePlayRating(15.0, 99.0, false), Is.EqualTo(14.55).Within(0.000001)); } [TestCase("4UE20", 20.0)] [TestCase("4UE19+", 19.5)] [TestCase("★10", 10.0)] [TestCase("LEVEL 14", 14.0)] [TestCase("st3", 3.0)] [TestCase("sl0", 0.5)] [TestCase("0", 0.5)] [TestCase("12.75", 12.75)] [TestCase("100", 100.0)] public void Rating_ParsesTheTrailingDifficultyTokenInsteadOfProviderDigits( string difficultyText, double expected) { Assert.That(RatingRules.TryParseChartConstant(difficultyText, out var parsed), Is.True); Assert.That(parsed, Is.EqualTo(expected).Within(0.000001)); } [TestCase("")] [TestCase("4UEΩ")] [TestCase("SPECIAL 12")] [TestCase("DUMMY 20")] [TestCase("20 EX")] [TestCase("-1")] public void Rating_RejectsNonNumericSpecialOrAmbiguousDifficultyValues(string difficultyText) { Assert.That(RatingRules.TryParseChartConstant(difficultyText, out var parsed), Is.False); Assert.That(parsed, Is.Zero); } [Test] public void Rating_RawTableValueIsAuthoritativeOverTheDisplayLabel() { Assert.That( RatingRules.TryResolveChartConstant("20", "4UE20", out var numeric), Is.True); Assert.That(numeric, Is.EqualTo(20.0)); Assert.That( RatingRules.TryResolveChartConstant("Ω", "4UEΩ", out var symbolic), Is.False); Assert.That(symbolic, Is.Zero); } [Test] public void Rating_CatalogAnnotationSeparatesRawDifficultyFromNumericProviderSymbol() { const string numericMd5 = "10101010101010101010101010101010"; const string symbolicMd5 = "20202020202020202020202020202020"; var table = BmsDifficultyTableIndex.Parse( "4ue-test", "{\"name\":\"4UE Test\",\"symbol\":\"4UE\",\"data_url\":\"data.json\"}", "[" + $"{{\"md5\":\"{numericMd5}\",\"level\":\"20\",\"title\":\"Numeric\"}}," + $"{{\"md5\":\"{symbolicMd5}\",\"level\":\"Ω\",\"title\":\"Symbolic\"}}" + "]"); var numeric = CollectionSong(numericMd5, string.Empty, 4, false); var symbolic = CollectionSong(symbolicMd5, string.Empty, 4, false); ChartCatalogAnnotator.Apply(new[] { numeric, symbolic }, table); Assert.That(numeric.Catalog.DifficultyLabel, Is.EqualTo("4UE20")); Assert.That(numeric.Catalog.DifficultyValue, Is.EqualTo("20")); Assert.That(numeric.Catalog.ChartConstant, Is.EqualTo(20.0)); Assert.That(symbolic.Catalog.DifficultyLabel, Is.EqualTo("4UEΩ")); Assert.That(symbolic.Catalog.DifficultyValue, Is.EqualTo("Ω")); Assert.That(symbolic.Catalog.ChartConstant, Is.Zero); } [Test] public void Rating_Best30UsesOneBestResultPerChartAndEmptySlotsAreZero() { var summary = RatingRules.CalculateBest30( "aery", "AERY", 5, false, new[] { new RatingContribution("chart-a", 12.0), new RatingContribution("chart-a", 15.0), new RatingContribution("chart-b", 9.0), }); Assert.That(summary.FilledSlots, Is.EqualTo(2)); Assert.That(summary.Value, Is.EqualTo(24.0 / 30.0).Within(0.000001)); Assert.That(summary.ContextLabel, Is.EqualTo("AERY 5K RATING")); } [Test] public void Rating_CountsDistinctChartsEvenWhenTitleAndLevelAreIdentical() { var repository = new SongRecordRepository( Path.Combine(_temporaryDirectory, "same-title-level-rating-records.json")); var first = CollectionSong( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "20", 4, false); var second = CollectionSong( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "20", 4, false); RecordRating(repository, first, "halcyon-a", "aery", 20.0, 4, false, "Halcyon"); RecordRating(repository, second, "halcyon-b", "aery", 20.0, 4, false, "Halcyon"); var summary = repository.CalculateRating("aery", "AERY", 4, false); Assert.That(summary.FilledSlots, Is.EqualTo(2)); Assert.That(summary.BestPlays.Count, Is.EqualTo(2)); Assert.That(summary.BestPlays[0].Title, Is.EqualTo("Halcyon")); Assert.That(summary.BestPlays[1].Title, Is.EqualTo("Halcyon")); Assert.That(summary.BestPlays[0].ChartId, Is.Not.EqualTo(summary.BestPlays[1].ChartId)); } [Test] public void Rating_SeparatesDifficultyTablesAndKeyForms() { var repository = new SongRecordRepository(Path.Combine(_temporaryDirectory, "key-rating-records.json")); var aeryFive = CollectionSong("11111111111111111111111111111111", "15", 5, false); var stellaFive = CollectionSong("22222222222222222222222222222222", "14", 5, false); stellaFive.Catalog.ProviderId = "stella"; stellaFive.Catalog.ProviderDisplayName = "STELLA"; var aeryOnePlusSeven = CollectionSong("33333333333333333333333333333333", "16", 7, true); RecordRating(repository, aeryFive, "aery-five", "aery", 15.0, 5, false); RecordRating(repository, stellaFive, "stella-five", "stella", 14.0, 5, false); RecordRating(repository, aeryOnePlusSeven, "aery-one-plus-seven", "aery", 16.0, 7, true); var aeryFiveKey = repository.CalculateRating("aery", "AERY", 5, false); var stellaFiveKey = repository.CalculateRating("stella", "STELLA", 5, false); var onePlusSeven = repository.CalculateRating("aery", "AERY", 7, true); var contexts = repository.CalculateRatingContexts(); Assert.That(aeryFiveKey.FilledSlots, Is.EqualTo(1)); Assert.That(aeryFiveKey.Value, Is.EqualTo(15.0 / 30.0).Within(0.000001)); Assert.That(aeryFiveKey.ContextLabel, Is.EqualTo("AERY 5K RATING")); Assert.That(stellaFiveKey.FilledSlots, Is.EqualTo(1)); Assert.That(stellaFiveKey.Value, Is.EqualTo(14.0 / 30.0).Within(0.000001)); Assert.That(stellaFiveKey.ContextLabel, Is.EqualTo("STELLA 5K RATING")); Assert.That(onePlusSeven.FilledSlots, Is.EqualTo(1)); Assert.That(onePlusSeven.ContextLabel, Is.EqualTo("AERY 1+7K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Is.EqualTo(new[] { "AERY 5K RATING", "AERY 1+7K RATING", "STELLA 5K RATING", })); } [Test] public void RatingContexts_PrioritizeTheMostPlayedDifficultyTableAndKeyForm() { var repository = new SongRecordRepository(Path.Combine(_temporaryDirectory, "active-rating-records.json")); var aeryFive = CollectionSong("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "15", 5, false); var stellaSix = CollectionSong("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "16", 6, false); stellaSix.Catalog.ProviderId = "stella"; stellaSix.Catalog.ProviderDisplayName = "STELLA"; RecordRating(repository, aeryFive, "aery-once", "aery", 15.0, 5, false); RecordRating(repository, stellaSix, "stella-one", "stella", 16.0, 6, false); RecordRating(repository, stellaSix, "stella-two", "stella", 16.0, 6, false); RecordRating(repository, stellaSix, "stella-three", "stella", 16.0, 6, false); var contexts = repository.CalculateRatingContexts(); Assert.That(contexts[0].ContextLabel, Is.EqualTo("STELLA 6K RATING")); Assert.That(contexts[0].ActivityPlayCount, Is.EqualTo(3)); Assert.That(contexts[1].ContextLabel, Is.EqualTo("AERY 5K RATING")); Assert.That(contexts[1].ActivityPlayCount, Is.EqualTo(1)); } [Test] public void ProfileRatingContexts_IncludeRegisteredPresetScopesWithoutPlayRecords() { var recorded = new RatingSummary( "aery", "AERY", 5, false, 12.5, 29, activityPlayCount: 34); var tables = BmsDifficultyTablePresets.All .Select(preset => new BmsDifficultyTableDefinition { Id = preset.Id, DisplayName = preset.DisplayName, HeaderUrl = preset.HeaderUrl, }) .ToArray(); var contexts = ProfileRatingContextBuilder.Build( new[] { recorded }, tables, Array.Empty(), "aery", default, (id, name, keyCount, hasScratch) => new RatingSummary(id, name, keyCount, hasScratch, 0.0, 0)); Assert.That(contexts[0].ContextLabel, Is.EqualTo("AERY 5K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Does.Contain("4UE DIFFICULTY TABLE 4K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Does.Contain("SATELLITE 1+7K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Does.Contain("STELLA 1+7K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Does.Contain("GENOCIDE INSANE BMS (LEGACY MIRROR) 1+7K RATING")); Assert.That(contexts.Select(context => context.ContextLabel), Does.Contain("6UE DIFFICULTY TABLE 6K RATING")); Assert.That(contexts.Count, Is.EqualTo(BmsDifficultyTablePresets.All.Length)); } [Test] public void Rating_SameChartRetainsIndependentBestForEachDifficultyTable() { var repository = new SongRecordRepository(Path.Combine(_temporaryDirectory, "multi-table-records.json")); var song = CollectionSong("44444444444444444444444444444444", "15", 5, false); RecordRating(repository, song, "aery-run", "aery", 15.0, 5, false); song.Catalog.ProviderId = "stella"; song.Catalog.ProviderDisplayName = "STELLA"; song.Catalog.DifficultyLabel = "14"; RecordRating(repository, song, "stella-run", "stella", 14.0, 5, false); var record = repository.LoadPlayRecord(song); Assert.That(record.BestRatings.Count, Is.EqualTo(2)); Assert.That(repository.CalculateRating("aery", "AERY", 5, false).Value, Is.EqualTo(15.0 / 30.0).Within(0.000001)); Assert.That(repository.CalculateRating("stella", "STELLA", 5, false).Value, Is.EqualTo(14.0 / 30.0).Within(0.000001)); } [Test] public void Rating_LegacyPackProviderMigratesIntoGlobalDifficultyTableScope() { var path = Path.Combine(_temporaryDirectory, "legacy-pack-provider-rating.json"); var repository = new SongRecordRepository(path); var song = CollectionSong("45454545454545454545454545454545", "15", 5, false); RecordRating(repository, song, "legacy-provider-run", "legacy-pack", 15.0, 5, false); var providerMap = new System.Collections.Generic.Dictionary( StringComparer.OrdinalIgnoreCase) { ["legacy-pack"] = new BmsDifficultyTableDefinition { Id = "global-table", DisplayName = "GLOBAL TABLE", HeaderUrl = "https://example.com/header.json", }, }; var migrated = repository.MigrateDifficultyTableProviders(providerMap); var reloaded = new SongRecordRepository(path); Assert.That(migrated, Is.EqualTo(1)); Assert.That(reloaded.CalculateRating("legacy-pack", "LEGACY", 5, false).FilledSlots, Is.Zero); Assert.That(reloaded.CalculateRating("global-table", "GLOBAL TABLE", 5, false).FilledSlots, Is.EqualTo(1)); Assert.That(reloaded.CalculateRatingContexts()[0].ContextLabel, Is.EqualTo("GLOBAL TABLE 5K RATING")); } [Test] public void ProfileProgression_IsIdempotentAndAwardsFirstMilestonesOnce() { var path = Path.Combine(_temporaryDirectory, "profile.json"); var repository = new PlayerProgressionRepository(path); var playEvent = PerfectHardClearEvent("event-one"); var first = repository.Record(playEvent); var duplicate = repository.Record(playEvent); var snapshot = repository.Snapshot(); Assert.That(first.Applied, Is.True); Assert.That(first.XpAwarded, Is.EqualTo(450)); Assert.That(first.FirstPlay, Is.True); Assert.That(first.FirstNormalClear, Is.True); Assert.That(first.FirstHardClear, Is.True); Assert.That(first.FirstFullCombo, Is.True); Assert.That(first.FirstAllPure, Is.True); Assert.That(duplicate.Applied, Is.False); Assert.That(duplicate.XpAwarded, Is.Zero); Assert.That(snapshot.TotalXp, Is.EqualTo(450)); Assert.That(snapshot.ValidPlayCount, Is.EqualTo(1)); Assert.That(snapshot.Level, Is.EqualTo(ProfileLevelRules.LevelForTotalXp(450))); } [Test] public void FeaturedCourseBadgeSelection_PersistsInTheLocalProfile() { var path = Path.Combine(_temporaryDirectory, "featured-badge-profile.json"); var repository = new PlayerProgressionRepository(path); repository.SetFeaturedCourseBadge("AERY-SHA256:ABCDEF"); Assert.That(repository.FeaturedCourseBadgeId, Is.EqualTo("aery-sha256:abcdef")); Assert.That(new PlayerProgressionRepository(path).FeaturedCourseBadgeId, Is.EqualTo("aery-sha256:abcdef")); Assert.That(File.ReadAllText(path), Does.Contain("\"FeaturedCourseBadgeId\"")); } [Test] public void CommitCoordinator_AtomicallyUpdatesRecordLampProfileAndRating() { var songPath = Path.Combine(_temporaryDirectory, "records.json"); var profilePath = Path.Combine(_temporaryDirectory, "profile.json"); var pendingPath = Path.Combine(_temporaryDirectory, "pending.json"); var songRepository = new SongRecordRepository(songPath); var progressionRepository = new PlayerProgressionRepository(profilePath); var coordinator = new PlayResultCommitCoordinator( pendingPath, songRepository, progressionRepository); var song = new SongLibraryEntry { ChartPath = "/virtual/aery/chart.bms", PackId = "aery-pack", Metadata = new BmsMetadata { Md5 = "0123456789abcdef0123456789abcdef", KeyCount = 5, }, Catalog = new ChartCatalogAnnotation { ProviderId = "aery", ProviderDisplayName = "AERY", DifficultyLabel = "15+", }, }; var playEvent = PerfectHardClearEvent("commit-event"); var first = coordinator.Commit(song, playEvent); var duplicate = coordinator.Commit(song, playEvent); Assert.That(songRepository.LoadPlayRecord(song).PlayCount, Is.EqualTo(1)); Assert.That(songRepository.LoadPlayRecord(song).BestRating.PlayRating, Is.EqualTo(16.0).Within(0.000001)); Assert.That(songRepository.LoadClearLamp(song), Is.EqualTo(SongClearLamp.HardClear)); Assert.That(progressionRepository.Snapshot().ValidPlayCount, Is.EqualTo(1)); Assert.That(first.Rating.Current.Value, Is.EqualTo(16.0 / 30.0).Within(0.000001)); Assert.That(first.Rating.Current.BestPlays.Count, Is.EqualTo(1)); Assert.That(first.Rating.Current.BestPlays[0].Title, Is.EqualTo("Test Chart")); Assert.That(duplicate.Profile.Applied, Is.False); Assert.That(duplicate.Rating.Delta, Is.Zero.Within(0.000001)); Assert.That(File.Exists(pendingPath), Is.False); } [Test] public void CommitCoordinator_NormalizesAnOlderPendingRatingBeforeRecovery() { const string md5 = "30303030303030303030303030303030"; var songPath = Path.Combine(_temporaryDirectory, "pending-rating-records.json"); var profilePath = Path.Combine(_temporaryDirectory, "pending-rating-profile.json"); var pendingPath = Path.Combine(_temporaryDirectory, "pending-rating-journal.json"); var songRepository = new SongRecordRepository(songPath); var coordinator = new PlayResultCommitCoordinator( pendingPath, songRepository, new PlayerProgressionRepository(profilePath)); var song = CollectionSong(md5, "4UE20", 4, false); song.Catalog.ProviderId = "4ue"; song.Catalog.ProviderDisplayName = "4UE"; var playEvent = PerfectHardClearEvent("pending-legacy-4ue"); playEvent.ProviderId = "4ue"; playEvent.ProviderDisplayName = "4UE"; playEvent.DifficultyLabel = "4UE20"; playEvent.DifficultyValue = string.Empty; playEvent.ChartConstant = 4.0; playEvent.KeyCount = 4; playEvent.RatingRulesVersion = 3; var outcome = coordinator.Commit(song, playEvent); var stored = songRepository.LoadPlayRecord(song).BestRatings.Single(); Assert.That(playEvent.ChartConstant, Is.EqualTo(20.0)); Assert.That(playEvent.RatingRulesVersion, Is.EqualTo(RatingRules.RulesVersion)); Assert.That(outcome.Rating.PlayRating, Is.EqualTo(20.5).Within(0.000001)); Assert.That(outcome.Rating.Current.Value, Is.EqualTo(20.5 / 30.0).Within(0.000001)); Assert.That(stored.ChartConstant, Is.EqualTo(20.0)); Assert.That(stored.PlayRating, Is.EqualTo(20.5)); } [Test] public void Collection_AggregatesPackDifficultyKeyModeAndMilestones() { var repository = new SongRecordRepository(Path.Combine(_temporaryDirectory, "collection-records.json")); var hardClear = CollectionSong("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "15", 5, false); var failed = CollectionSong("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "14", 5, false); repository.RecordPlay(hardClear, new SongPlayResult( 1_000_100, 100.0, 100, 100, 0, 0, 0, 0, 0, eventId: "collection-hard", purePerfectCount: 100, judgedUnits: 100, totalScoringUnits: 100, cleared: true, hardClear: true, packId: "aery-pack", packDisplayName: "AERY FULL PACK", providerId: "aery", providerDisplayName: "AERY", difficultyLabel: "15", title: "Hard Chart", chartConstant: 15.0, keyCount: 5, laneModifier: "OFF")); repository.RecordClearLamp(hardClear, SongClearLamp.HardClear); repository.RecordPlay(failed, new SongPlayResult( 700_000, 70.0, 20, 40, 10, 5, 5, 10, 12, eventId: "collection-failed", judgedUnits: 60, totalScoringUnits: 100, packId: "aery-pack", packDisplayName: "AERY FULL PACK", providerId: "aery", providerDisplayName: "AERY", difficultyLabel: "14", title: "Failed Chart", keyCount: 5, laneModifier: "OFF")); repository.RecordClearLamp(failed, SongClearLamp.Failed); var collection = repository.CalculateCollection(); Assert.That(collection.Total.Played, Is.EqualTo(2)); Assert.That(collection.Total.Cleared, Is.EqualTo(1)); Assert.That(collection.Total.HardClear, Is.EqualTo(1)); Assert.That(collection.Total.Failed, Is.EqualTo(1)); Assert.That(collection.Total.FullCombo, Is.EqualTo(1)); Assert.That(collection.Total.AllPure, Is.EqualTo(1)); Assert.That(collection.Packs.Count, Is.EqualTo(1)); Assert.That(collection.Packs[0].DisplayName, Is.EqualTo("AERY FULL PACK")); Assert.That(collection.KeyModes[0].DisplayName, Is.EqualTo("5K")); Assert.That(collection.Difficulties.Count, Is.EqualTo(2)); } [Test] public void CourseCollection_ExposesCertificationAndCourseMetadata() { var path = Path.Combine(_temporaryDirectory, "course-collection.json"); var course = new AeryCourseDefinition { name = "段位認定 AERY 5th / ver.1", md5 = new[] { "stage-a", "stage-b" }, }; File.WriteAllText(path, UnityEngine.JsonUtility.ToJson(new CourseRecordsDocument { Records = new System.Collections.Generic.List { new CourseRecordData { CourseId = CourseRecordRepository.BuildCourseId(course), Record = new AeryCourseRecord { HasRecord = true, Cleared = true, PlayCount = 3, ClearCount = 1, BestStageReached = 2, BestAccuracyPercent = 98.5, BestTrophyRank = 2, BestTrophyName = "SILVER", }, }, }, })); var repository = new CourseRecordRepository(path); repository.Load(course); var collection = repository.CalculateCollection(); Assert.That(collection.RecordedCourses, Is.EqualTo(1)); Assert.That(collection.ClearedCourses, Is.EqualTo(1)); Assert.That(collection.TotalPlayCount, Is.EqualTo(3)); Assert.That(collection.BadgeCount, Is.EqualTo(1)); Assert.That(collection.HasFeaturedBadge, Is.True); Assert.That(collection.FeaturedBadge.CourseName, Is.EqualTo("AERY 5th")); Assert.That(collection.FeaturedBadge.ProviderDisplayName, Is.EqualTo("AERY")); Assert.That(collection.FeaturedBadge.TrophyRank, Is.EqualTo(2)); Assert.That(collection.FeaturedBadge.TrophyName, Is.EqualTo("SILVER")); Assert.That(collection.Entries[0].CourseName, Is.EqualTo("AERY 5th")); Assert.That(collection.Entries[0].BestTrophyName, Is.EqualTo("SILVER")); } [Test] public void Course_LegacyPackProviderMigratesIntoGlobalDifficultyTableScope() { var path = Path.Combine(_temporaryDirectory, "legacy-pack-provider-course.json"); File.WriteAllText(path, JsonUtility.ToJson(new CourseRecordsDocument { Records = new System.Collections.Generic.List { new CourseRecordData { CourseId = "legacy-pack-sha256:abcdef", ProviderId = "legacy-pack", ProviderDisplayName = "LEGACY PACK", CourseName = "Legacy Course", Record = new AeryCourseRecord { HasRecord = true, Cleared = true, PlayCount = 2, ClearCount = 1, BestStageReached = 4, BestAccuracyPercent = 98.75, }, }, }, }, true)); var repository = new CourseRecordRepository(path); var providerMap = new System.Collections.Generic.Dictionary( StringComparer.OrdinalIgnoreCase) { ["legacy-pack"] = new BmsDifficultyTableDefinition { Id = "global-table", DisplayName = "GLOBAL TABLE", HeaderUrl = "https://example.com/header.json", }, }; var migrated = repository.MigrateDifficultyTableProviders(providerMap); var collection = new CourseRecordRepository(path).CalculateCollection(); Assert.That(migrated, Is.EqualTo(1)); Assert.That(collection.Entries.Count, Is.EqualTo(1)); Assert.That(collection.Entries[0].CourseId, Is.EqualTo("global-table-sha256:abcdef")); Assert.That(collection.Entries[0].ProviderDisplayName, Is.EqualTo("GLOBAL TABLE")); Assert.That(collection.Entries[0].Cleared, Is.True); } [Test] public void CourseCertificationBadges_AreGrantedOnlyForClearedCourses() { var cleared = new CourseCollectionEntry( "aery-clear", "AERY", "AERY 6th", true, 2, 1, 4, 97.5, 1, "BRONZE"); var failed = new CourseCollectionEntry( "stella-failed", "STELLA", "STELLA 2nd", false, 3, 0, 3, 89.0, 0, string.Empty); var snapshot = new ProfileCourseSnapshot(2, 1, 5, new[] { cleared, failed }); Assert.That(snapshot.BadgeCount, Is.EqualTo(1)); Assert.That(snapshot.CertificationBadges[0].CourseId, Is.EqualTo("aery-clear")); Assert.That(snapshot.CertificationBadges[0].CertificationLabel, Is.EqualTo("AERY // AERY 6th")); } [Test] public void CourseCertificationBadges_ResolveRequestedFeaturedBadgeAndFallbackSafely() { var first = new CourseCollectionEntry( "aery-first", "AERY", "AERY 5th", true, 1, 1, 4, 95.0, 0, string.Empty); var second = new CourseCollectionEntry( "stella-second", "STELLA", "STELLA 3rd", true, 1, 1, 4, 98.0, 2, "SILVER"); var selected = new ProfileCourseSnapshot(2, 2, 2, new[] { first, second }, "stella-second"); var missing = new ProfileCourseSnapshot(2, 2, 2, new[] { first, second }, "removed-course"); Assert.That(selected.FeaturedBadge.CourseId, Is.EqualTo("stella-second")); Assert.That(missing.FeaturedBadge.CourseId, Is.EqualTo("aery-first")); } [Test] public void AutoScratchClear_RemainsRatingEligibleWithoutHardClearBonus() { var playEvent = new PlayResultEvent { ProviderId = "stella", ProviderDisplayName = "STELLA", ChartConstant = 12.4, KeyCount = 7, HasScratch = true, LaneModifier = "OFF", Completed = true, Cleared = true, HardClear = false, ClearLamp = (int)SongClearLamp.AutoScratchClear, AccuracyPercent = 87.5, MissCount = 24, }; Assert.That(playEvent.RatingEligible, Is.True); Assert.That( playEvent.PlayRating, Is.EqualTo(RatingRules.CalculatePlayRating(12.4, 87.5, hardClear: false)) .Within(0.000001)); } [Test] public void ZeroTierSatelliteClear_NormalizesIntoRatingEligibility() { var playEvent = new PlayResultEvent { ProviderId = "satellite", ProviderDisplayName = "Satellite", DifficultyLabel = "sl0", DifficultyValue = "0", ChartConstant = 0.0, KeyCount = 7, HasScratch = true, LaneModifier = "OFF", Completed = true, Cleared = true, HardClear = false, ClearLamp = (int)SongClearLamp.AutoScratchClear, AccuracyPercent = 80.0, MissCount = 114, RatingRulesVersion = 4, }; Assert.That(playEvent.NormalizeRatingRules(), Is.True); Assert.That(playEvent.ChartConstant, Is.EqualTo(RatingRules.MinimumChartConstant)); Assert.That(playEvent.RatingEligible, Is.True); Assert.That(playEvent.PlayRating, Is.GreaterThan(0.0)); Assert.That(playEvent.RatingRulesVersion, Is.EqualTo(RatingRules.RulesVersion)); } private static PlayResultEvent PerfectHardClearEvent(string eventId) => new PlayResultEvent { EventId = eventId, ChartId = "md5:0123456789abcdef0123456789abcdef", PackId = "aery-pack", ProviderId = "aery", ProviderDisplayName = "AERY", DifficultyLabel = "15+", Title = "Test Chart", Artist = "Test Artist", ChartConstant = 15.5, KeyCount = 5, LaneModifier = "OFF", GaugeType = "HARD", Completed = true, Cleared = true, HardClear = true, ClearLamp = (int)SongClearLamp.HardClear, Score = 1_003_200, AccuracyPercent = 100.0, MaxCombo = 3200, KoolCount = 3200, PurePerfectCount = 3200, JudgedUnits = 3200, TotalScoringUnits = 3200, ActivePlaySeconds = 120.0, OccurredUtcTicks = DateTime.UtcNow.Ticks, }; private static SongLibraryEntry CollectionSong( string md5, string difficulty, int keyCount, bool hasScratch) => new SongLibraryEntry { ChartPath = "/virtual/collection/" + md5 + ".bms", PackId = "aery-pack", Metadata = new BmsMetadata { Md5 = md5, KeyCount = keyCount, HasScratch = hasScratch, }, Catalog = new ChartCatalogAnnotation { ProviderId = "aery", ProviderDisplayName = "AERY", DifficultyLabel = difficulty, }, }; private static void RecordRating( SongRecordRepository repository, SongLibraryEntry song, string eventId, string providerId, double chartConstant, int keyCount, bool hasScratch, string title = null) { repository.RecordPlay(song, new SongPlayResult( 1_000_000, 100.0, 100, 100, 0, 0, 0, 0, 0, eventId: eventId, judgedUnits: 100, totalScoringUnits: 100, cleared: true, providerId: providerId, providerDisplayName: providerId.ToUpperInvariant(), difficultyLabel: chartConstant.ToString(System.Globalization.CultureInfo.InvariantCulture), title: title ?? eventId, chartConstant: chartConstant, keyCount: keyCount, hasScratch: hasScratch, laneModifier: "OFF")); } } }