743 lines
33 KiB
C#
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"));
|
|
}
|
|
}
|
|
}
|