using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; using AibisDream.Framework; using AibisDream.MiniGame.Language; using AibisDream.Utility; using NUnit.Framework; using UnityEditor; using UnityEditor.Localization; using UnityEngine; using UnityEngine.Localization; using UnityEngine.Localization.Settings; using UnityEngine.Localization.Tables; using UnityEngine.TestTools; namespace AibisDream.EditorTests.Huoshan { public sealed class ExpressionLocalizationTests { private const string CatalogPath = "Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset"; private const string ParticleProfilePath = "Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset"; [TestCase(null, false)] [TestCase("", false)] [TestCase("plain", false)] [TestCase("L10N.key", false)] [TestCase("l10n.key", true)] public void LocalizedParamPrefix_IsStrict(string value, bool expected) { Assert.That(LocalizationKit.IsLocalizedParam(value), Is.EqualTo(expected)); } [Test] public void LocalizedParamKey_StripsOnlyKnownPrefix() { Assert.That(LocalizationKit.GetL10NParamKey("l10n.hs.exp.log1.target"), Is.EqualTo("hs.exp.log1.target")); Assert.That(LocalizationKit.GetL10NParamKey("raw"), Is.EqualTo("raw")); } [Test] public void MissingParamResult_RequiresMatchingLocalizedReference() { Assert.That( LocalizationKit.IsMissingParamResult( "l10n.hs.exp.pool", "⟦hs.exp.pool⟧"), Is.True); Assert.That( LocalizationKit.IsMissingParamResult( "l10n.hs.exp.pool", "⟦another.key⟧"), Is.False); Assert.That( LocalizationKit.IsMissingParamResult( "raw", "⟦raw⟧"), Is.False); } [UnityTest] public IEnumerator LocalizeParamAsync_UsesExplicitLocaleAndDoesNotFallback() { Locale chinese = LocalizationEditorSettings.GetLocale( new LocaleIdentifier("zh-Hans")); Locale english = LocalizationEditorSettings.GetLocale( new LocaleIdentifier("en")); Assert.That(chinese, Is.Not.Null); Assert.That(english, Is.Not.Null); Locale previous = LocalizationSettings.SelectedLocale; LocalizationSettings.SelectedLocale = english; Task chineseTask = LocalizationKit.LocalizeParamAsync( "l10n.hs.exp.log1.target", chinese); Task englishTask = LocalizationKit.LocalizeParamAsync( "l10n.hs.exp.log1.target", english); while (!chineseTask.IsCompleted || !englishTask.IsCompleted) yield return null; LocalizationSettings.SelectedLocale = previous; Assert.That(chineseTask.Result, Is.EqualTo("我就是个笑话")); Assert.That(englishTask.Result, Is.EqualTo("I am nothing but a joke")); } [UnityTest] public IEnumerator LocalizeParamAsync_ReturnsRawTextAndMissingKeyMarker() { Task rawTask = LocalizationKit.LocalizeParamAsync("raw text"); while (!rawTask.IsCompleted) yield return null; Assert.That(rawTask.Result, Is.EqualTo("raw text")); Locale chinese = LocalizationEditorSettings.GetLocale( new LocaleIdentifier("zh-Hans")); LogAssert.Expect( LogType.Error, new Regex(@"Params 缺少条目.*Key=hs\.exp\.missing.*Locale=zh-Hans")); Task missingTask = LocalizationKit.LocalizeParamAsync( "l10n.hs.exp.missing", chinese); while (!missingTask.IsCompleted) yield return null; Assert.That(missingTask.Result, Is.EqualTo("⟦hs.exp.missing⟧")); } [Test] public void LegacyCommonParams_ArePresentInUnityParams() { StringTableCollection collection = LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable); Assert.That(collection, Is.Not.Null); string[] legacyKeys = { "天空", "海报", "植物", "朋友", "情绪", "记忆", "逻辑" }; foreach (string key in legacyKeys) Assert.That(collection.SharedData.GetEntry(key), Is.Not.Null, key); Assert.That(AssetDatabase.LoadAssetAtPath( "Assets/StreamingAssets/Config/params.csv"), Is.Null); } [Test] public void ExpressionCatalog_IsValidAndCaseSensitive() { ExpressionContentCatalog catalog = AssetDatabase.LoadAssetAtPath(CatalogPath); Assert.That(catalog, Is.Not.Null); Assert.That(catalog.GetValidationErrors(), Is.Empty); Assert.That(catalog.Rounds.Select(round => round.Id), Is.EqualTo(new[] { "log1", "log2", "log3" })); Assert.That(catalog.TryGetRound("log1", out ExpressionRoundDefinition log1), Is.True); Assert.That(log1.NonTargetParticleCount, Is.EqualTo(16)); Assert.That(catalog.TryGetRound("LOG1", out _), Is.False); Assert.That(catalog.TryGetRound("missing", out _), Is.False); } [Test] public void ExpressionCatalog_RejectsDuplicateIdsAndNonLocalizedReferences() { ExpressionContentCatalog catalog = ScriptableObject.CreateInstance(); try { var first = new ExpressionRoundDefinition(); var second = new ExpressionRoundDefinition(); ConfigureRound(first, "log1", "l10n.target", "l10n.tokens", 1); ConfigureRound(second, "log1", "raw", "l10n.tokens", 1); SetPrivateField( catalog, "rounds", new List { first, second }); string errors = string.Join("\n", catalog.GetValidationErrors()); Assert.That(errors, Does.Contain("重复")); Assert.That(errors, Does.Contain("l10n.*")); } finally { Object.DestroyImmediate(catalog); } } [Test] public void TokenParser_TrimsDropsEmptyAndRejectsFullwidthSeparator() { Assert.That( LanguageYarnCommand.TryParseExpressionTokens( " 哈哈 | | 嘿嘿|呵呵 ", out var tokens), Is.True); Assert.That(tokens, Is.EqualTo(new[] { "哈哈", "嘿嘿", "呵呵" })); LogAssert.Expect(LogType.Error, new Regex("全角分隔符")); Assert.That( LanguageYarnCommand.TryParseExpressionTokens("哈哈|嘿嘿", out _), Is.False); Assert.That( LanguageYarnCommand.TryParseExpressionTokens(" | | ", out _), Is.False); } [Test] public void CharacterPoolParser_PreservesUnicodeDuplicatesAndRejectsSeparators() { string combining = "e\u0301"; Assert.That( LanguageYarnCommand.TryParseExpressionPool( $" 中 中 あ {combining} 😀。 ", out List pool), Is.True); Assert.That( pool, Is.EqualTo(new[] { "中", "中", "あ", combining, "😀", "。" })); Assert.That( LanguageYarnCommand.TryParseExpressionPool("中|あ", out _), Is.False); Assert.That( LanguageYarnCommand.TryParseExpressionPool("中|あ", out _), Is.False); Assert.That( LanguageYarnCommand.TryParseExpressionPool(" \t ", out _), Is.False); } [Test] public void ParticleLanguageProfile_ResolvesExactPrefixAndFallbackModes() { ExpressionParticleLanguageProfile profile = AssetDatabase.LoadAssetAtPath( ParticleProfilePath); Assert.That(profile, Is.Not.Null); Assert.That(profile.GetValidationErrors(), Is.Empty); Assert.That( profile.Resolve(new LocaleIdentifier("zh-Hans")).UnitMode, Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); Assert.That( profile.Resolve(new LocaleIdentifier("ja-JP")).UnitMode, Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); foreach (string localeCode in new[] { "en", "es", "ru", "pt-BR" }) { Assert.That( profile.Resolve(new LocaleIdentifier(localeCode)).UnitMode, Is.EqualTo(ExpressionParticleUnitMode.Word), localeCode); } Assert.That( profile.Resolve(new LocaleIdentifier("en-US")).LocaleCode, Is.EqualTo("en")); LogAssert.Expect( LogType.Warning, new Regex(@"Locale 'zz-ZZ'.*Grapheme")); Assert.That( profile.Resolve(new LocaleIdentifier("zz-ZZ")).UnitMode, Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); } [Test] public void WordTokenizer_UsesWhitespaceBoundariesAndKeepsPunctuation() { Assert.That( ExpressionTextTokenizer.TokenizeText( "I am not a joke", ExpressionParticleUnitMode.Word), Is.EqualTo(new[] { "I", "am", "not", "a", "joke" })); Assert.That( ExpressionTextTokenizer.TokenizeText( "don't self-doubt ¿Por qué? веришь?", ExpressionParticleUnitMode.Word), Is.EqualTo(new[] { "don't", "self-doubt", "¿Por", "qué?", "веришь?" })); Assert.That( ExpressionTextTokenizer.TokenizeText( "one \t\r\n two", ExpressionParticleUnitMode.Word), Is.EqualTo(new[] { "one", "two" })); } [Test] public void GraphemeTokenizer_PreservesCjkKanaAndCombiningCharacters() { const string combining = "e\u0301"; Assert.That( ExpressionTextTokenizer.TokenizeText( $"汉 あ {combining}", ExpressionParticleUnitMode.Grapheme), Is.EqualTo(new[] { "汉", "あ", combining })); } [Test] public void WordPool_PreservesDuplicateWeightsAndRejectsPipeSeparators() { Assert.That( LanguageYarnCommand.TryParseExpressionPool( "noise doubt noise", ExpressionParticleUnitMode.Word, out List pool), Is.True); Assert.That(pool, Is.EqualTo(new[] { "noise", "doubt", "noise" })); Assert.That( LanguageYarnCommand.TryParseExpressionPool( "noise|doubt", ExpressionParticleUnitMode.Word, out _), Is.False); Assert.That( LanguageYarnCommand.TryParseExpressionPool( "noise|doubt", ExpressionParticleUnitMode.Word, out _), Is.False); } [Test] public void RoundSnapshot_IsParsedOnceAndKeepsItsLocaleAndWordUnits() { ExpressionParticleLanguageProfile languageProfile = AssetDatabase.LoadAssetAtPath( ParticleProfilePath); ExpressionParticleLocaleSettings english = languageProfile.Resolve(new LocaleIdentifier("en")); Assert.That( ExpressionRoundTextSnapshot.TryCreate( new LocaleIdentifier("en"), english, "I am not a joke", new[] { "false alarm", "false alarm" }, "noise doubt noise", out ExpressionRoundTextSnapshot snapshot, out string error), Is.True, error); Locale previous = LocalizationSettings.SelectedLocale; try { LocalizationSettings.SelectedLocale = LocalizationEditorSettings.GetLocale( new LocaleIdentifier("zh-Hans")); Assert.That(snapshot.Locale.Code, Is.EqualTo("en")); Assert.That( snapshot.TargetUnits, Is.EqualTo(new[] { "I", "am", "not", "a", "joke" })); Assert.That( snapshot.InterferencePoolUnits, Is.EqualTo(new[] { "false", "alarm", "false", "alarm" })); Assert.That( snapshot.DefaultPoolUnits, Is.EqualTo(new[] { "noise", "doubt", "noise" })); } finally { LocalizationSettings.SelectedLocale = previous; } } [Test] public void WordProfile_ScalesConfiguredNonTargetCounts() { ExpressionParticleLanguageProfile profile = AssetDatabase.LoadAssetAtPath( ParticleProfilePath); ExpressionParticleLocaleSettings english = profile.Resolve(new LocaleIdentifier("en")); Assert.That(english.ScaleNonTargetCount(16), Is.EqualTo(10)); Assert.That(english.ScaleNonTargetCount(28), Is.EqualTo(18)); Assert.That(english.ScaleNonTargetCount(38), Is.EqualTo(25)); } [Test] public void ParticleGeometry_UsesVisualEdgeDistanceAndShortestSeparationAxis() { var left = new Bounds(Vector3.zero, new Vector3(4f, 1f, 0.01f)); var right = new Bounds( new Vector3(4.15f, 0f, 0f), new Vector3(4f, 1f, 0.01f)); Assert.That( ExpressionParticleGeometry.BoundsDistance(left, right), Is.EqualTo(0.15f).Within(0.0001f)); right.center = new Vector3(3.8f, 0f, 0f); Assert.That( ExpressionParticleGeometry.TryGetSeparation( left, right, 0.1f, out Vector2 direction, out float overlap), Is.True); Assert.That(direction, Is.EqualTo(Vector2.left)); Assert.That(overlap, Is.EqualTo(0.3f).Within(0.0001f)); } [Test] public void TextParticle_VisualBoundsIncludesTheWholeWordForPointerDistance() { var gameObject = new GameObject("TextParticleBoundsTest"); try { CandidateParticle particle = gameObject.AddComponent(); particle.ForceSetUnitText("self-doubt"); Bounds bounds = particle.GetVisualWorldBounds(); Assert.That(bounds.size.x, Is.GreaterThan(0f)); Assert.That( particle.DistanceToVisualBounds( new Vector2(bounds.max.x, bounds.center.y)), Is.EqualTo(0f).Within(0.0001f)); } finally { Object.DestroyImmediate(gameObject); } } [Test] public void Params_ContainsEnglishWordPoolAndPendingPoolsForOtherLocales() { StringTableCollection collection = LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable); Assert.That(collection, Is.Not.Null); string key = LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam); Assert.That(collection.SharedData.GetEntry(key), Is.Not.Null); string[] localeCodes = { "zh-Hans", "en", "ja-JP", "es", "ru", "pt-BR" }; foreach (string localeCode in localeCodes) { StringTable table = collection.GetTable(new LocaleIdentifier(localeCode)) as StringTable; Assert.That(table, Is.Not.Null, localeCode); Assert.That(table.GetEntry(key), Is.Not.Null, localeCode); } StringTable chinese = collection.GetTable(new LocaleIdentifier("zh-Hans")) as StringTable; Assert.That( ExpressionTextTokenizer.GetVisibleElements( chinese.GetEntry(key).LocalizedValue), Is.Not.Empty); StringTable english = collection.GetTable(new LocaleIdentifier("en")) as StringTable; Assert.That( LanguageYarnCommand.TryParseExpressionPool( english.GetEntry(key).LocalizedValue, ExpressionParticleUnitMode.Word, out List englishPool), Is.True); Assert.That(englishPool, Does.Contain("truth")); Assert.That(englishPool, Does.Contain("believe")); foreach (string localeCode in localeCodes.Skip(2)) { StringTable table = collection.GetTable(new LocaleIdentifier(localeCode)) as StringTable; Assert.That(table.GetEntry(key).LocalizedValue, Is.Empty, localeCode); } } [Test] public void TextParticle_UsesConfiguredRoleAndOverridePoolsWithoutFirstFrameFallback() { var gameObject = new GameObject("TextParticlePoolTest"); try { CandidateParticle particle = gameObject.AddComponent(); Assert.That(particle.CurrentUnitText, Is.Null.Or.Empty); string combining = "e\u0301"; particle.SetUnitPools( new[] { "😀" }, new[] { combining }); particle.originalIsRed = true; particle.InitializeRandomUnit(); Assert.That(particle.CurrentUnitText, Is.EqualTo("😀")); particle.originalIsRed = false; particle.InitializeRandomUnit(); Assert.That(particle.CurrentUnitText, Is.EqualTo(combining)); particle.SetOverrideUnitPool(new[] { "。" }); particle.InitializeRandomUnit(); Assert.That(particle.CurrentUnitText, Is.EqualTo("。")); particle.SetOverrideUnitPool((IReadOnlyList)null); particle.InitializeRandomUnit(); Assert.That(particle.CurrentUnitText, Is.EqualTo(combining)); } finally { Object.DestroyImmediate(gameObject); } } [Test] public void UnicodeTokenizer_PreservesTextElementsAndDropsWhitespace() { string combining = "e\u0301"; var elements = ExpressionTextTokenizer.GetVisibleElements($"中 A あ {combining} 😀。"); Assert.That( elements, Is.EqualTo(new[] { "中", "A", "あ", combining, "😀", "。" })); } [Test] public void UnicodeLayout_PreservesWordGapWithoutWhitespaceParticle() { ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout("A B", 1f, 0.6f); Assert.That(layout.VisibleElements, Is.EqualTo(new[] { "A", "B" })); Assert.That(layout.Offsets[0], Is.EqualTo(-0.8f).Within(0.0001f)); Assert.That(layout.Offsets[1], Is.EqualTo(0.8f).Within(0.0001f)); } [Test] public void UnicodeSequenceMatch_UsesWholeTextElements() { var source = ExpressionTextTokenizer.GetVisibleElements("A😀e\u0301。"); var fragment = ExpressionTextTokenizer.GetVisibleElements("😀e\u0301"); Assert.That(ExpressionTextTokenizer.FindVisibleSequence(source, fragment), Is.EqualTo(1)); Assert.That( ExpressionTextTokenizer.FindVisibleSequence( source, ExpressionTextTokenizer.GetVisibleElements("😀x")), Is.EqualTo(-1)); } [Test] public void ExpressContentValidation_HasNoBlockingErrors() { var issues = ExpressionLocalizationValidator.Validate(); string errors = string.Join( "\n", issues .Where(issue => issue.Severity == ExpressionLocalizationIssueSeverity.Error) .Select(issue => issue.Message)); Assert.That(errors, Is.Empty); Assert.That( issues.Any(issue => issue.Severity == ExpressionLocalizationIssueSeverity.Warning), Is.True, "未翻译 Locale 的空条目应作为待翻译警告保留。"); } private static void ConfigureRound( ExpressionRoundDefinition round, string id, string target, string tokens, int nonTargetCount) { SetPrivateField(round, "id", id); SetPrivateField(round, "targetReference", target); SetPrivateField(round, "tokenReference", tokens); SetPrivateField(round, "nonTargetParticleCount", nonTargetCount); } private static void SetPrivateField(object target, string fieldName, object value) { FieldInfo field = target.GetType().GetField( fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null, fieldName); field.SetValue(target, value); } } }