Files
ProjectQuint/Assets/ProjectQuint/Tests/EditMode/ProfileProgressionTests.cs
T

743 lines
33 KiB
C#

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<SongLibraryEntry>(),
"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<string, BmsDifficultyTableDefinition>(
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<CourseRecordData>
{
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<CourseRecordData>
{
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<string, BmsDifficultyTableDefinition>(
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"));
}
}
}