fix: tighten validation heuristics and restore locale integrity
- tighten sentence fallback detection to handle placeholders/HTML without false positives - align --fix behavior/docs and short-key hotspot coverage - add and wire validator unit test project with focused rule coverage - fix confirmed Hindi/Indonesian/Italian/Russian/Turkish translation issues
This commit is contained in:
parent
604b92657a
commit
64f8751364
@ -4,8 +4,13 @@
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="BTCPayTranslator.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
|
||||
@ -422,7 +422,7 @@ class Program
|
||||
{
|
||||
var fixOption = new Option<bool>(
|
||||
"--fix",
|
||||
"Automatically replaces suspicious entries with the source English key value.");
|
||||
"Automatically fixes suspicious entries by restoring English fallback text or removing hotspot keys.");
|
||||
|
||||
var command = new Command(
|
||||
"validate-packs",
|
||||
|
||||
@ -49,7 +49,7 @@ public class LanguagePackValidator
|
||||
JObject json;
|
||||
var fileChanged = false;
|
||||
|
||||
void ApplyFix(JProperty property, string key)
|
||||
void ApplyFix(JProperty property, string key, string currentValue, bool sentenceFallback = false)
|
||||
{
|
||||
if (TranslationValidationRules.IsShortKeyFallbackHotspot(key))
|
||||
{
|
||||
@ -57,7 +57,16 @@ public class LanguagePackValidator
|
||||
}
|
||||
else
|
||||
{
|
||||
property.Value = key;
|
||||
var fallbackText = TranslationValidationRules.ResolveSentenceFallback(key);
|
||||
if (sentenceFallback && string.Equals(currentValue, fallbackText, StringComparison.Ordinal))
|
||||
{
|
||||
// Avoid a no-op for sentence fallbacks: remove the entry so runtime falls back cleanly.
|
||||
property.Remove();
|
||||
}
|
||||
else
|
||||
{
|
||||
property.Value = fallbackText;
|
||||
}
|
||||
}
|
||||
|
||||
fileChanged = true;
|
||||
@ -94,7 +103,7 @@ public class LanguagePackValidator
|
||||
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Suspicious LLM/meta-response content"));
|
||||
if (fix)
|
||||
{
|
||||
ApplyFix(property, key);
|
||||
ApplyFix(property, key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@ -104,7 +113,7 @@ public class LanguagePackValidator
|
||||
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Suspicious source fallback (sentence-like value equals source key)"));
|
||||
if (fix)
|
||||
{
|
||||
ApplyFix(property, key);
|
||||
ApplyFix(property, key, value, sentenceFallback: true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@ -114,7 +123,7 @@ public class LanguagePackValidator
|
||||
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Placeholder/token mismatch between source key and translation"));
|
||||
if (fix)
|
||||
{
|
||||
ApplyFix(property, key);
|
||||
ApplyFix(property, key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@ -124,7 +133,7 @@ public class LanguagePackValidator
|
||||
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Common UI label left untranslated (value equals English key)"));
|
||||
if (fix)
|
||||
{
|
||||
ApplyFix(property, key);
|
||||
ApplyFix(property, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,12 @@ internal static class TranslationValidationRules
|
||||
private static readonly Regex PlaceholderRegex =
|
||||
new(@"\{[A-Za-z0-9_]+\}", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex HtmlTagRegex =
|
||||
new(@"<[^>]+>", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WhitespaceRegex =
|
||||
new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex TokenRegex =
|
||||
new(@"[A-Za-z0-9+./_-]+", RegexOptions.Compiled);
|
||||
|
||||
@ -91,15 +97,18 @@ internal static class TranslationValidationRules
|
||||
// Allowlist of short labels that can legitimately appear unchanged in many locales.
|
||||
private static readonly HashSet<string> ShortKeyAllowlist = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Confirm", "Reset", "Yes",
|
||||
"No", "Start", "Source", "Done", "Save", "Send", "Image", "Edit",
|
||||
"Reset",
|
||||
"No", "Start", "Source", "Done", "Save", "Send", "Image",
|
||||
"API", "URL", "URI", "JSON", "CSV", "PSBT", "BTC", "LNURL", "Tor",
|
||||
};
|
||||
|
||||
// Focused hotspot keys that have repeatedly been contaminated with English fallback values.
|
||||
private static readonly HashSet<string> ShortKeyHotspotKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Change Role",
|
||||
"Confirm",
|
||||
"Continue",
|
||||
"Edit",
|
||||
"Edit plan",
|
||||
"here",
|
||||
"Inputs",
|
||||
@ -112,6 +121,8 @@ internal static class TranslationValidationRules
|
||||
"Retry",
|
||||
"Text",
|
||||
"Translations",
|
||||
"Update Role",
|
||||
"Yes",
|
||||
"RESET",
|
||||
"Role updated",
|
||||
"Role created",
|
||||
@ -203,6 +214,11 @@ internal static class TranslationValidationRules
|
||||
return ShortKeyHotspotKeys.Contains(trimmed);
|
||||
}
|
||||
|
||||
public static string ResolveSentenceFallback(string source)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
public static bool HasMatchingPlaceholders(string source, string translation)
|
||||
{
|
||||
var sourceTokens = ExtractTokenCounts(source);
|
||||
@ -230,17 +246,21 @@ internal static class TranslationValidationRules
|
||||
if (string.IsNullOrWhiteSpace(source) || source.Length < 20)
|
||||
return false;
|
||||
|
||||
if (PlaceholderRegex.IsMatch(source))
|
||||
var sourceForAnalysis = HtmlTagRegex.Replace(source, " ");
|
||||
sourceForAnalysis = PlaceholderRegex.Replace(sourceForAnalysis, " ");
|
||||
sourceForAnalysis = WhitespaceRegex.Replace(sourceForAnalysis, " ").Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceForAnalysis) || sourceForAnalysis.Length < 20)
|
||||
return false;
|
||||
|
||||
var words = source.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var words = sourceForAnalysis.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length < 4)
|
||||
return false;
|
||||
|
||||
if (!source.Any(char.IsLower))
|
||||
if (!sourceForAnalysis.Any(char.IsLower))
|
||||
return false;
|
||||
|
||||
var tokens = TokenRegex.Matches(source).Select(match => match.Value).ToList();
|
||||
var tokens = TokenRegex.Matches(sourceForAnalysis).Select(match => match.Value).ToList();
|
||||
if (tokens.Count == 0)
|
||||
return false;
|
||||
|
||||
|
||||
25
tests/BTCPayTranslator.Tests/BTCPayTranslator.Tests.csproj
Normal file
25
tests/BTCPayTranslator.Tests/BTCPayTranslator.Tests.csproj
Normal file
@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\BTCPayTranslator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
392
tests/BTCPayTranslator.Tests/LanguagePackValidatorTests.cs
Normal file
392
tests/BTCPayTranslator.Tests/LanguagePackValidatorTests.cs
Normal file
@ -0,0 +1,392 @@
|
||||
using BTCPayTranslator.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace BTCPayTranslator.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal IConfiguration backed by a dictionary. Avoids a dependency on
|
||||
/// the Microsoft.Extensions.Configuration.Memory NuGet package.
|
||||
/// </summary>
|
||||
internal sealed class DictionaryConfiguration : IConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, string?> _data;
|
||||
|
||||
public DictionaryConfiguration(Dictionary<string, string?> data) => _data = data;
|
||||
|
||||
public string? this[string key]
|
||||
{
|
||||
get => _data.GetValueOrDefault(key);
|
||||
set => _data[key] = value;
|
||||
}
|
||||
|
||||
public IEnumerable<IConfigurationSection> GetChildren() => [];
|
||||
public IChangeToken GetReloadToken() => new CancellationChangeToken(CancellationToken.None);
|
||||
public IConfigurationSection GetSection(string key) =>
|
||||
throw new NotSupportedException("Not needed for tests");
|
||||
}
|
||||
|
||||
public class LanguagePackValidatorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly LanguagePackValidator _validator;
|
||||
|
||||
public LanguagePackValidatorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"validator_tests_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
var config = new DictionaryConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Translation:OutputDirectory"] = _tempDir
|
||||
});
|
||||
|
||||
_validator = new LanguagePackValidator(
|
||||
config,
|
||||
NullLogger<LanguagePackValidator>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Missing directory handling
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MissingDirectory_ReturnsIssueWithMessage()
|
||||
{
|
||||
var config = new DictionaryConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Translation:OutputDirectory"] = Path.Combine(_tempDir, "missing_subdir_" + Guid.NewGuid().ToString("N"))
|
||||
});
|
||||
|
||||
var validator = new LanguagePackValidator(
|
||||
config,
|
||||
NullLogger<LanguagePackValidator>.Instance);
|
||||
|
||||
var result = await validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(0, result.FilesScanned);
|
||||
Assert.Single(result.Issues);
|
||||
Assert.Contains("does not exist", result.Issues[0].Reason);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Empty directory
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_EmptyDirectory_ReturnsZeroFilesZeroIssues()
|
||||
{
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(0, result.FilesScanned);
|
||||
Assert.Equal(0, result.EntriesScanned);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Invalid JSON reporting
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidJson_ReportsErrorAndContinues()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "bad.json"),
|
||||
"{ NOT VALID JSON }");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "good.json"),
|
||||
"""{ "Save": "Speichern" }""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(2, result.FilesScanned);
|
||||
Assert.Equal(1, result.EntriesScanned);
|
||||
|
||||
var jsonIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("bad.json", jsonIssue.FileName);
|
||||
Assert.Contains("Invalid JSON", jsonIssue.Reason);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Meta-response detection in pack files
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsMetaResponse()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "french.json"),
|
||||
"""
|
||||
{
|
||||
"Save": "Please provide the English text",
|
||||
"Cancel": "Annuler"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(1, result.FilesScanned);
|
||||
Assert.Equal(2, result.EntriesScanned);
|
||||
|
||||
var metaIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("Save", metaIssue.Key);
|
||||
Assert.Contains("meta-response", metaIssue.Reason);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Placeholder mismatch detection
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsPlaceholderMismatch()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "spanish.json"),
|
||||
"""
|
||||
{
|
||||
"Hello {0}": "Hola",
|
||||
"Goodbye {0}": "Adiós {0}"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
var mismatchIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("Hello {0}", mismatchIssue.Key);
|
||||
Assert.Contains("Placeholder", mismatchIssue.Reason);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Sentence-like fallback detection
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsSentenceLikeFallback()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "korean.json"),
|
||||
"""
|
||||
{
|
||||
"Allow anyone to create invoice": "Allow anyone to create invoice",
|
||||
"Save": "저장"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
var fallbackIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("Allow anyone to create invoice", fallbackIssue.Key);
|
||||
Assert.Contains("fallback", fallbackIssue.Reason);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// --fix mode: rewrite + clean second validation pass
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FixMode_RewritesSuspiciousEntries()
|
||||
{
|
||||
var filePath = Path.Combine(_tempDir, "german.json");
|
||||
await File.WriteAllTextAsync(filePath,
|
||||
"""
|
||||
{
|
||||
"Save": "I am ready to translate",
|
||||
"Cancel": "Abbrechen"
|
||||
}
|
||||
""");
|
||||
|
||||
var firstPass = await _validator.ValidateAsync(fix: true);
|
||||
|
||||
Assert.Single(firstPass.Issues);
|
||||
Assert.Equal("Save", firstPass.Issues[0].Key);
|
||||
|
||||
// Verify the file was rewritten: the suspicious value should now be the key
|
||||
var rewrittenContent = await File.ReadAllTextAsync(filePath);
|
||||
Assert.Contains("\"Save\": \"Save\"", rewrittenContent);
|
||||
Assert.Contains("\"Cancel\": \"Abbrechen\"", rewrittenContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FixMode_SecondPassReturnsClearForFixedEntries()
|
||||
{
|
||||
var filePath = Path.Combine(_tempDir, "german.json");
|
||||
// Short key ("Save") replaced with itself won't trigger IsLikelySentenceFallback
|
||||
// because it's < 20 chars. This is the expected behavior.
|
||||
await File.WriteAllTextAsync(filePath,
|
||||
"""
|
||||
{
|
||||
"Save": "I am ready to translate"
|
||||
}
|
||||
""");
|
||||
|
||||
// Fix pass rewrites "Save" value to "Save" (the key)
|
||||
await _validator.ValidateAsync(fix: true);
|
||||
|
||||
// Second pass: "Save": "Save" should not flag (< 20 chars → not a sentence fallback)
|
||||
var secondPass = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Empty(secondPass.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FixMode_PlaceholderMismatchReplacedWithKey()
|
||||
{
|
||||
var filePath = Path.Combine(_tempDir, "italian.json");
|
||||
await File.WriteAllTextAsync(filePath,
|
||||
"""
|
||||
{
|
||||
"Hello {0} and {1}": "Ciao",
|
||||
"Goodbye": "Arrivederci"
|
||||
}
|
||||
""");
|
||||
|
||||
await _validator.ValidateAsync(fix: true);
|
||||
|
||||
var rewrittenContent = await File.ReadAllTextAsync(filePath);
|
||||
Assert.Contains("\"Hello {0} and {1}\": \"Hello {0} and {1}\"", rewrittenContent);
|
||||
Assert.Contains("\"Goodbye\": \"Arrivederci\"", rewrittenContent);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Clean file passes validation with zero issues
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_CleanFile_ZeroIssues()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "french.json"),
|
||||
"""
|
||||
{
|
||||
"Save": "Enregistrer",
|
||||
"Cancel": "Annuler",
|
||||
"Hello {0}": "Bonjour {0}",
|
||||
"API": "API"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(1, result.FilesScanned);
|
||||
Assert.Equal(4, result.EntriesScanned);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Multiple issues across multiple files
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MultipleFiles_AggregatesIssuesAcrossAll()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "french.json"),
|
||||
"""
|
||||
{
|
||||
"Save": "Please provide the English text"
|
||||
}
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "german.json"),
|
||||
"""
|
||||
{
|
||||
"Hello {0}": "Hallo"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
Assert.Equal(2, result.FilesScanned);
|
||||
Assert.Equal(2, result.EntriesScanned);
|
||||
Assert.Equal(2, result.Issues.Count);
|
||||
|
||||
Assert.Contains(result.Issues, i => i.FileName == "french.json" && i.Reason.Contains("meta-response"));
|
||||
Assert.Contains(result.Issues, i => i.FileName == "german.json" && i.Reason.Contains("Placeholder"));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Localized (non-English) meta-response detection
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsLocalizedMetaResponse()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "italian.json"),
|
||||
"""
|
||||
{
|
||||
"Confirm": "Per favore fornisci il testo da tradurre.",
|
||||
"Cancel": "Annulla"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
var metaIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("Confirm", metaIssue.Key);
|
||||
Assert.Contains("meta-response", metaIssue.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("กรุณาให้ข้อความที่ต้องการแปล")] // Thai
|
||||
[InlineData("翻訳する英語のテキストを提供してください")] // Japanese
|
||||
[InlineData("Molim vas dajte mi tekst za prevod")] // Serbian
|
||||
[InlineData("menunggu teks bahasa Inggris")] // Indonesian
|
||||
[InlineData("geben Sie den zu übersetzenden")] // German
|
||||
public void IsSuspiciousMetaResponse_DetectsLocalizedVariants(string text)
|
||||
{
|
||||
Assert.True(TranslationValidationRules.IsSuspiciousMetaResponse(text));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Short-key English fallback detection
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Change Role", "Change Role", true)]
|
||||
[InlineData("Confirm", "Confirm", true)]
|
||||
[InlineData("Continue", "Continue", true)]
|
||||
[InlineData("Edit", "Edit", true)]
|
||||
[InlineData("Update Role", "Update Role", true)]
|
||||
[InlineData("Yes", "Yes", true)]
|
||||
[InlineData("Role created", "Role created", true)]
|
||||
[InlineData("Confirm", "Bevestigen", false)] // translated = no issue
|
||||
[InlineData("PSBT", "PSBT", false)] // technical term = not in denylist
|
||||
[InlineData("Save", "Save", false)] // not in the denylist
|
||||
[InlineData("No", "No", false)] // cognate, same in many languages
|
||||
[InlineData("Start", "Start", false)] // cognate, same in many languages
|
||||
[InlineData("Source", "Source", false)] // cognate, same in French
|
||||
public void IsShortKeyEnglishFallback_DetectsHotspotKeys(string key, string value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TranslationValidationRules.IsShortKeyEnglishFallback(key, value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DetectsShortKeyEnglishFallback()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "thai.json"),
|
||||
"""
|
||||
{
|
||||
"Confirm": "Confirm",
|
||||
"Cancel": "ยกเลิก"
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _validator.ValidateAsync(fix: false);
|
||||
|
||||
var fallbackIssue = Assert.Single(result.Issues);
|
||||
Assert.Equal("Confirm", fallbackIssue.Key);
|
||||
Assert.Contains("untranslated", fallbackIssue.Reason);
|
||||
}
|
||||
}
|
||||
209
tests/BTCPayTranslator.Tests/TranslationValidationRulesTests.cs
Normal file
209
tests/BTCPayTranslator.Tests/TranslationValidationRulesTests.cs
Normal file
@ -0,0 +1,209 @@
|
||||
using BTCPayTranslator.Services;
|
||||
|
||||
namespace BTCPayTranslator.Tests;
|
||||
|
||||
public class TranslationValidationRulesTests
|
||||
{
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// IsSuspiciousMetaResponse
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Please provide the English text")]
|
||||
[InlineData("please provide english text")]
|
||||
[InlineData("I am ready to translate")]
|
||||
[InlineData("I'm ready to translate")]
|
||||
[InlineData("Ready to translate English to French")]
|
||||
[InlineData("Ready to translate English to Brazilian Portuguese (pt-BR)")]
|
||||
[InlineData("Translate English text to Spanish")]
|
||||
[InlineData("Waiting for the English text")]
|
||||
[InlineData("Please provide the text you'd like me to translate")]
|
||||
[InlineData("Please provide the text you want me to translate")]
|
||||
[InlineData("Please provide the text to translate")]
|
||||
[InlineData("I understand the instructions")]
|
||||
[InlineData("I understand")]
|
||||
[InlineData("I don't see any text")]
|
||||
[InlineData("You haven't provided any text")]
|
||||
[InlineData("I am a professional translator for BTCPay Server")]
|
||||
[InlineData("As an AI, I can help with translations")]
|
||||
public void IsSuspiciousMetaResponse_Detects_MetaPatterns(string text)
|
||||
{
|
||||
Assert.True(TranslationValidationRules.IsSuspiciousMetaResponse(text));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Paramètres")]
|
||||
[InlineData("Créer une facture")]
|
||||
[InlineData("Connexion au nœud Lightning réussie.")]
|
||||
[InlineData("Save")]
|
||||
[InlineData("Cancel")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsSuspiciousMetaResponse_Passes_NormalTranslations(string text)
|
||||
{
|
||||
Assert.False(TranslationValidationRules.IsSuspiciousMetaResponse(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSuspiciousMetaResponse_Null_ReturnsFalse()
|
||||
{
|
||||
Assert.False(TranslationValidationRules.IsSuspiciousMetaResponse(null!));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// HasMatchingPlaceholders
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hello {0}", "Bonjour {0}")]
|
||||
[InlineData("{0} items for {1}", "{0} articles pour {1}")]
|
||||
[InlineData("{OrderId} confirmed", "{OrderId} confirmé")]
|
||||
[InlineData("No placeholders here", "Pas de paramètres ici")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("Plain text", "Texte simple")]
|
||||
public void HasMatchingPlaceholders_Matching_ReturnsTrue(string source, string translation)
|
||||
{
|
||||
Assert.True(TranslationValidationRules.HasMatchingPlaceholders(source, translation));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{0} items", "articles")] // placeholder dropped
|
||||
[InlineData("{0} for {1}", "{0} pour")] // one placeholder dropped
|
||||
[InlineData("No placeholder", "Pas de {0} paramètre")] // placeholder introduced
|
||||
[InlineData("{OrderId}", "{InvoiceId}")] // different placeholder name
|
||||
[InlineData("{0} {0}", "{0}")] // duplicate count mismatch
|
||||
public void HasMatchingPlaceholders_Mismatching_ReturnsFalse(string source, string translation)
|
||||
{
|
||||
Assert.False(TranslationValidationRules.HasMatchingPlaceholders(source, translation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasMatchingPlaceholders_MultipleSamePlaceholder_MatchesCounts()
|
||||
{
|
||||
// Source has {0} twice, translation must also have it twice
|
||||
Assert.True(TranslationValidationRules.HasMatchingPlaceholders(
|
||||
"{0} and {0} again", "{0} et {0} encore"));
|
||||
|
||||
Assert.False(TranslationValidationRules.HasMatchingPlaceholders(
|
||||
"{0} and {0} again", "{0} et encore"));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// IsLikelySentenceFallback
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("This is a complete English sentence that was not translated")]
|
||||
[InlineData("The invoice has been paid successfully and confirmed")]
|
||||
[InlineData("Allow anyone to create invoice")]
|
||||
public void IsLikelySentenceFallback_DetectsUntranslatedSentences(string text)
|
||||
{
|
||||
// source == translation for sentence-like strings → flagged
|
||||
Assert.True(TranslationValidationRules.IsLikelySentenceFallback(text, text));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Save", "Save")] // too short (< 20 chars)
|
||||
[InlineData("API", "API")] // too short, all uppercase
|
||||
[InlineData("BTC", "BTC")] // technical token, too short
|
||||
[InlineData("Cancel", "Annuler")] // different → not a fallback
|
||||
[InlineData("Hello world test", "Hello world test")] // < 20 chars
|
||||
[InlineData("NO LOWER CASE LETTERS IN THIS", "NO LOWER CASE LETTERS IN THIS")] // no lowercase
|
||||
public void IsLikelySentenceFallback_IgnoresShortAndTechnicalStrings(string source, string translation)
|
||||
{
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback(source, translation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelySentenceFallback_DetectsSentenceWithPlaceholder()
|
||||
{
|
||||
// Placeholder-bearing English sentences should still be analyzed and flagged.
|
||||
var text = "The payment {OrderId} was confirmed successfully";
|
||||
Assert.True(TranslationValidationRules.IsLikelySentenceFallback(text, text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelySentenceFallback_IgnoresMarkupFragmentsWithPlaceholder()
|
||||
{
|
||||
// Markup-heavy snippets with a short remaining phrase should not be treated as sentence fallbacks.
|
||||
var text = "<span class=\"currency\">{0}</span> on-chain";
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback(text, text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelySentenceFallback_IgnoresTechnicalOnlyStrings()
|
||||
{
|
||||
// A string composed entirely of allowed technical tokens + uppercase should not be flagged
|
||||
var text = "BTC LNURL BOLT11 GRPC SSL";
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback(text, text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelySentenceFallback_DifferentSourceAndTranslation_ReturnsFalse()
|
||||
{
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback(
|
||||
"Allow anyone to create invoice",
|
||||
"Autoriser tout le monde à créer des factures"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelySentenceFallback_NullOrEmpty_ReturnsFalse()
|
||||
{
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback("", ""));
|
||||
Assert.False(TranslationValidationRules.IsLikelySentenceFallback(" ", " "));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Edge-case / integration-style guardrail tests
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MetaResponse_CaseInsensitive()
|
||||
{
|
||||
Assert.True(TranslationValidationRules.IsSuspiciousMetaResponse(
|
||||
"I AM READY TO TRANSLATE"));
|
||||
Assert.True(TranslationValidationRules.IsSuspiciousMetaResponse(
|
||||
"PLEASE PROVIDE THE ENGLISH TEXT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetaResponse_EmbeddedInLongerString()
|
||||
{
|
||||
// Even embedded inside a larger string, the pattern should match
|
||||
Assert.True(TranslationValidationRules.IsSuspiciousMetaResponse(
|
||||
"Sure! I am ready to translate your text. Please send it."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Placeholders_HtmlEntitiesPreserved()
|
||||
{
|
||||
// Ensure HTML tags don't interfere (no false positives from angle brackets)
|
||||
Assert.True(TranslationValidationRules.HasMatchingPlaceholders(
|
||||
"<strong>{0}</strong> items",
|
||||
"<strong>{0}</strong> articles"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Placeholders_ComplexMixedContent()
|
||||
{
|
||||
var source = "Available placeholders: <code>{StoreName} {ItemDescription} {OrderId}</code>";
|
||||
var translation = "Paramètres disponibles : <code>{StoreName} {ItemDescription} {OrderId}</code>";
|
||||
Assert.True(TranslationValidationRules.HasMatchingPlaceholders(source, translation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Placeholders_ComplexMixedContent_Mismatch()
|
||||
{
|
||||
var source = "Available placeholders: <code>{StoreName} {ItemDescription} {OrderId}</code>";
|
||||
var translation = "Paramètres disponibles : <code>{StoreName} {OrderId}</code>";
|
||||
Assert.False(TranslationValidationRules.HasMatchingPlaceholders(source, translation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SentenceFallback_RealWorldContaminatedEntry()
|
||||
{
|
||||
// Simulates a real contamination case: a full English sentence that slipped through untranslated
|
||||
var text = "Check releases on GitHub and notify when new BTCPay Server version is available";
|
||||
Assert.True(TranslationValidationRules.IsLikelySentenceFallback(text, text));
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@
|
||||
"{0} invoices unarchived.": "{0} चालान अनआर्काइव किए गए।",
|
||||
"{0} is not fully synched": "{0} पूरी तरह से सिंक नहीं है",
|
||||
"{0} left": "{0} बाकी",
|
||||
"{0} Lightning": "{0} Lightning",
|
||||
"{0} Lightning": "{0} लाइटनिंग",
|
||||
"{0} Lightning Node": "{0} लाइटनिंग नोड",
|
||||
"{0} Lightning node updated.": "{0} लाइटनिंग नोड अपडेट किया गया।",
|
||||
"{0} Lightning Settings": "{0} लाइटनिंग सेटिंग्स",
|
||||
@ -44,7 +44,7 @@
|
||||
"<span class=\"currency\">{0}</span> local balance": "स्थानीय शेष <span class=\"currency\">{0}</span>",
|
||||
"<span class=\"currency\">{0}</span> on-chain": "चेन पर <span class=\"currency\">{0}</span>",
|
||||
"<span class=\"currency\">{0}</span> opening channels": "<span class=\"currency\">{0}</span> चैनल खोल रहा है",
|
||||
"<span class=\"currency\">{0}</span> remote balance": "<span class=\"currency\">{0}</span> रिमोट बैलेंस",
|
||||
"<span class=\"currency\">{0}</span> remote balance": "<span class=\"currency\">{0}</span> रिमोट संतुलन",
|
||||
"<span class=\"currency\">{0}</span> reserved": "<span class=\"currency\">{0}</span> आरक्षित",
|
||||
"<span class=\"currency\">{0}</span> unconfirmed": "<span class=\"currency\">{0}</span> अपुष्ट",
|
||||
"<strong>Never</strong> trust anything but <code>id</code>, <strong>ignore</strong> the other fields completely, an attacker can spoof those, they are present only for backward compatibility reason:": "कभी भी <code>id</code> के अलावा किसी और चीज़ पर भरोसा न करें, अन्य फ़ील्ड्स को पूरी तरह से <strong>नज़रअंदाज़</strong> करें, एक हमलावर उन्हें स्पूफ कर सकता है, वे केवल पिछली संगतता के कारण मौजूद हैं।",
|
||||
|
||||
@ -561,7 +561,7 @@
|
||||
"Dependencies not met.": "Dependensi tidak terpenuhi.",
|
||||
"Derivation scheme": "Skema derivasi",
|
||||
"Description": "Deskripsi",
|
||||
"Description template of the lightning invoice": "Template deskripsi invoice lightning\n# iqbalhasandev/iqbalhasandev\n# README.md\n<h1 align=\"center\">Hi 👋, I'm Iqbal Hasan</h1>\n<h3 align=\"center\">A passionate Full Stack Developer from Bangladesh</h3>\n\n<p align=\"left\"> <img src=\"https://komarev.com/ghpvc/?username=iqbalhasandev&label=Profile%20views&color=0e75b6&style=flat\" alt=\"iqbalhasandev\" /> </p>\n\n<p align=\"left\"> <a href=\"https",
|
||||
"Description template of the lightning invoice": "Template deskripsi untuk invoice Lightning",
|
||||
"Destination": "Tujuan",
|
||||
"Destination Address": "Alamat Tujuan",
|
||||
"Details": "Rincian",
|
||||
@ -635,7 +635,7 @@
|
||||
"Each payment method shows the total excess amount.": "Setiap metode pembayaran menampilkan jumlah kelebihan pembayaran.",
|
||||
"Easily filter the different items using categories, used only in the product list with cart.": "Mudah memfilter berbagai item menggunakan kategori, hanya digunakan dalam daftar produk dengan keranjang.",
|
||||
"Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.": "Masuk dengan mudah ke BTCPay Server di perangkat lain menggunakan kode login sederhana dari perangkat yang sudah terautentikasi.",
|
||||
"Edit": "Edit",
|
||||
"Edit": "Ubah",
|
||||
"Edit Field": "Edit Kolom",
|
||||
"Edit Form": "Edit Formulir",
|
||||
"Edit Item": "Edit Item",
|
||||
@ -2078,7 +2078,7 @@
|
||||
"Update Crowdfund": "Perbarui Crowdfund",
|
||||
"Update Password": "Perbarui Kata Sandi",
|
||||
"Update Point of Sale": "Perbarui Point of Sale",
|
||||
"Update Role": "Saya siap untuk menerjemahkan teks dari Bahasa Inggris ke Bahasa Indonesia untuk BTCPay Server. Saya akan mengikuti pedoman yang diberikan untuk menghasilkan terjemahan yang profesional dan sesuai untuk perangkat lunak keuangan.",
|
||||
"Update Role": "Perbarui Peran",
|
||||
"Update to the latest version of BTCPay Server.": "Perbarui ke versi terbaru BTCPay Server.",
|
||||
"Update Webhook": "Perbarui Webhook",
|
||||
"Update your account": "Perbarui akun Anda",
|
||||
|
||||
@ -646,6 +646,20 @@
|
||||
"Edit plan ({0})": "Modifica piano ({0})",
|
||||
"Edit pull payment": "Modifica pagamento pull",
|
||||
"Edit Pull Payment": "Modifica Pagamento Pull",
|
||||
"Editor": "Editor",
|
||||
"Either your {0} wallet is not configured, or it is not a hot wallet. This processor cannot function until a hot wallet is configured in your store.": "Il tuo portafoglio {0} non è configurato o non è un hot wallet. Questo processore non può funzionare finché non viene configurato un hot wallet nel tuo negozio.",
|
||||
"Email": "Email",
|
||||
"Email address": "Indirizzo email",
|
||||
"Email address is confirmed": "L'indirizzo email è stato confermato",
|
||||
"Email Configuration": "Configurazione Email",
|
||||
"Email confirmation required": "È richiesta la conferma via email",
|
||||
"Email Confirmed": "Email Confermata",
|
||||
"Email confirmed?": "Email confermato?",
|
||||
"Email Notifications": "Notifiche Email",
|
||||
"Email password reset functionality is not configured for this server. Please contact the server administrator to assist with account recovery.": "La funzionalità di reimpostazione della password via email non è configurata per questo server. Si prega di contattare l'amministratore del server per assistenza nel recupero dell'account.",
|
||||
"Email Reminder Days Before Due": "Giorni di promemoria email prima della scadenza",
|
||||
"Email rule successfully created": "Regola email creata con successo",
|
||||
"Email rule successfully deleted": "Regola email eliminata con successo",
|
||||
"Email rule successfully updated": "Regola email aggiornata con successo",
|
||||
"Email rules": "Regole email",
|
||||
"Email Rules": "Regole Email",
|
||||
|
||||
@ -642,7 +642,7 @@
|
||||
"Edit Label": "Редактировать метку",
|
||||
"Edit payment request": "Редактировать запрос на оплату",
|
||||
"Edit Payment Request": "Редактировать запрос платежа",
|
||||
"Edit plan": "Редактировать план",
|
||||
"Edit plan": "Изменить план",
|
||||
"Edit plan ({0})": "Изменить план ({0})",
|
||||
"Edit pull payment": "Редактировать pull payment",
|
||||
"Edit Pull Payment": "Редактировать Pull Payment",
|
||||
|
||||
@ -1475,7 +1475,7 @@
|
||||
"Resetting Boltcard...": "Boltcard sıfırlanıyor...",
|
||||
"Resources": "Kaynaklar",
|
||||
"REST Uri": "REST Uri",
|
||||
"Restart": "Restart",
|
||||
"Restart": "Yeniden başlat",
|
||||
"Restart BTCPay Server and related services.": "BTCPay Server'ı ve ilgili servisleri yeniden başlat.",
|
||||
"Restart now": "Şimdi yeniden başlat",
|
||||
"Retired": "Emekli",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user