Skip to content

Commit bcb45dc

Browse files
committed
feat(switches): scaffold Features.Switches project + EN/FR localizer and component ids
1 parent f45714b commit bcb45dc

8 files changed

Lines changed: 238 additions & 0 deletions

File tree

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1212
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1313
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
14+
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
1415
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1516
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1617
</Folder>
@@ -21,6 +22,7 @@
2122
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
2223
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
2324
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
25+
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
2426
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2527
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
2628
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace RustPlusBot.Features.Switches.Rendering;
2+
3+
/// <summary>Resolves localized smart-switch strings by key and culture, falling back to English.</summary>
4+
internal interface ISwitchLocalizer
5+
{
6+
/// <summary>Gets the localized string for a key, or the key itself if not found.</summary>
7+
/// <param name="key">The string key.</param>
8+
/// <param name="culture">The BCP-47 culture tag (e.g. "en", "fr").</param>
9+
string Get(string key, string culture);
10+
11+
/// <summary>Gets the localized, format-applied string.</summary>
12+
/// <param name="key">The string key.</param>
13+
/// <param name="culture">The BCP-47 culture tag.</param>
14+
/// <param name="args">Format arguments.</param>
15+
string Get(string key, string culture, params object[] args);
16+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace RustPlusBot.Features.Switches.Rendering;
2+
3+
/// <summary>Custom ids for switch components. Tails encode "{serverId}:{entityId}".</summary>
4+
internal static class SwitchComponentIds
5+
{
6+
/// <summary>Pairing-prompt Accept button; tail "{serverId}:{entityId}".</summary>
7+
public const string AcceptPrefix = "switch:accept:";
8+
9+
/// <summary>Pairing-prompt Dismiss button; tail "{serverId}:{entityId}".</summary>
10+
public const string DismissPrefix = "switch:dismiss:";
11+
12+
/// <summary>Turn-on button; tail "{serverId}:{entityId}".</summary>
13+
public const string OnPrefix = "switch:on:";
14+
15+
/// <summary>Turn-off button; tail "{serverId}:{entityId}".</summary>
16+
public const string OffPrefix = "switch:off:";
17+
18+
/// <summary>Strobe button; tail "{serverId}:{entityId}".</summary>
19+
public const string StrobePrefix = "switch:strobe:";
20+
21+
/// <summary>Rename button (opens the modal); tail "{serverId}:{entityId}".</summary>
22+
public const string RenamePrefix = "switch:rename:";
23+
24+
/// <summary>Rename modal id; tail "{serverId}:{entityId}".</summary>
25+
public const string RenameModalPrefix = "switch:rename:modal:";
26+
27+
/// <summary>The rename modal's text input id.</summary>
28+
public const string RenameInputId = "switch:rename:input";
29+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace RustPlusBot.Features.Switches.Rendering;
2+
3+
/// <summary>The in-memory string catalog for Smart Switches: culture -> (key -> value). English is the fallback.</summary>
4+
internal sealed class SwitchLocalizationCatalog
5+
{
6+
/// <summary>culture -> key -> value.</summary>
7+
public required IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Strings { get; init; }
8+
9+
/// <summary>The built-in EN/FR catalog.</summary>
10+
public static SwitchLocalizationCatalog Default { get; } = new()
11+
{
12+
Strings = new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal)
13+
{
14+
["en"] = new Dictionary<string, string>(StringComparer.Ordinal)
15+
{
16+
["switch.status.on"] = "⚡ ON",
17+
["switch.status.off"] = "⭘ OFF",
18+
["switch.status.unreachable"] = "⚠️ Unreachable",
19+
["switch.button.on"] = "Turn on",
20+
["switch.button.off"] = "Turn off",
21+
["switch.button.strobe"] = "Strobe",
22+
["switch.button.rename"] = "Rename",
23+
["switch.embed.footer"] = "Entity {0}",
24+
["switch.prompt.title"] = "New switch detected",
25+
["switch.prompt.body"] = "Detected a new Smart Switch ({0}). Add it?",
26+
["switch.prompt.accept"] = "Accept",
27+
["switch.prompt.dismiss"] = "Dismiss",
28+
["switch.prompt.dismissed"] = "Dismissed.",
29+
["switch.rename.modal.title"] = "Rename switch",
30+
["switch.rename.input.label"] = "Switch name",
31+
["switch.unreachable.ephemeral"] = "Switch is unreachable right now.",
32+
},
33+
["fr"] = new Dictionary<string, string>(StringComparer.Ordinal)
34+
{
35+
["switch.status.on"] = "⚡ ALLUMÉ",
36+
["switch.status.off"] = "⭘ ÉTEINT",
37+
["switch.status.unreachable"] = "⚠️ Injoignable",
38+
["switch.button.on"] = "Allumer",
39+
["switch.button.off"] = "Éteindre",
40+
["switch.button.strobe"] = "Stroboscope",
41+
["switch.button.rename"] = "Renommer",
42+
["switch.embed.footer"] = "Entité {0}",
43+
["switch.prompt.title"] = "Nouvel interrupteur détecté",
44+
["switch.prompt.body"] = "Nouvel interrupteur connecté détecté ({0}). L'ajouter ?",
45+
["switch.prompt.accept"] = "Accepter",
46+
["switch.prompt.dismiss"] = "Ignorer",
47+
["switch.prompt.dismissed"] = "Ignoré.",
48+
["switch.rename.modal.title"] = "Renommer l'interrupteur",
49+
["switch.rename.input.label"] = "Nom de l'interrupteur",
50+
["switch.unreachable.ephemeral"] = "L'interrupteur est injoignable pour le moment.",
51+
},
52+
},
53+
};
54+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Globalization;
2+
3+
namespace RustPlusBot.Features.Switches.Rendering;
4+
5+
/// <summary>Dictionary-backed <see cref="ISwitchLocalizer"/> with English fallback and region normalization.</summary>
6+
/// <remarks>Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.</remarks>
7+
/// <param name="catalog">The string catalog.</param>
8+
internal sealed class SwitchLocalizer(SwitchLocalizationCatalog catalog) : ISwitchLocalizer
9+
{
10+
private const string FallbackCulture = "en";
11+
12+
/// <inheritdoc />
13+
public string Get(string key, string culture)
14+
{
15+
var normalized = Normalize(culture);
16+
if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
17+
{
18+
return value;
19+
}
20+
21+
if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
22+
fallback.TryGetValue(key, out var fallbackValue))
23+
{
24+
return fallbackValue;
25+
}
26+
27+
return key;
28+
}
29+
30+
/// <inheritdoc />
31+
public string Get(string key, string culture, params object[] args)
32+
{
33+
var format = Get(key, culture);
34+
var provider = ResolveFormatProvider(Normalize(culture));
35+
return string.Format(provider, format, args);
36+
}
37+
38+
private static string Normalize(string culture)
39+
{
40+
if (string.IsNullOrWhiteSpace(culture))
41+
{
42+
return FallbackCulture;
43+
}
44+
45+
var dash = culture.IndexOf('-', StringComparison.Ordinal);
46+
var primary = dash >= 0 ? culture[..dash] : culture;
47+
return primary.ToLowerInvariant();
48+
}
49+
50+
private static CultureInfo ResolveFormatProvider(string culture)
51+
{
52+
try
53+
{
54+
return CultureInfo.GetCultureInfo(culture);
55+
}
56+
catch (CultureNotFoundException)
57+
{
58+
return CultureInfo.InvariantCulture;
59+
}
60+
}
61+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<ItemGroup>
4+
<InternalsVisibleTo Include="RustPlusBot.Features.Switches.Tests" />
5+
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
6+
</ItemGroup>
7+
8+
<ItemGroup>
9+
<ProjectReference Include="..\RustPlusBot.Abstractions\RustPlusBot.Abstractions.csproj" />
10+
<ProjectReference Include="..\RustPlusBot.Persistence\RustPlusBot.Persistence.csproj" />
11+
<ProjectReference Include="..\RustPlusBot.Discord\RustPlusBot.Discord.csproj" />
12+
<ProjectReference Include="..\RustPlusBot.Domain\RustPlusBot.Domain.csproj" />
13+
<ProjectReference Include="..\RustPlusBot.Features.Workspace\RustPlusBot.Features.Workspace.csproj" />
14+
<ProjectReference Include="..\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="Discord.Net" />
19+
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
20+
</ItemGroup>
21+
22+
</Project>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<ItemGroup>
4+
<PackageReference Include="coverlet.collector" />
5+
<PackageReference Include="Discord.Net" />
6+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
7+
<PackageReference Include="NSubstitute" />
8+
<PackageReference Include="xunit" />
9+
<PackageReference Include="xunit.runner.visualstudio" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<Using Include="Xunit" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\src\RustPlusBot.Features.Switches\RustPlusBot.Features.Switches.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using RustPlusBot.Features.Switches.Rendering;
2+
3+
namespace RustPlusBot.Features.Switches.Tests;
4+
5+
public sealed class SwitchLocalizationCatalogTests
6+
{
7+
[Theory]
8+
[InlineData("en")]
9+
[InlineData("fr")]
10+
public void Catalog_contains_all_required_keys(string culture)
11+
{
12+
var map = SwitchLocalizationCatalog.Default.Strings[culture];
13+
foreach (var key in new[]
14+
{
15+
"switch.status.on", "switch.status.off", "switch.status.unreachable",
16+
"switch.button.on", "switch.button.off", "switch.button.strobe", "switch.button.rename",
17+
"switch.prompt.title", "switch.prompt.body", "switch.prompt.accept", "switch.prompt.dismiss",
18+
"switch.rename.modal.title", "switch.rename.input.label",
19+
"switch.unreachable.ephemeral",
20+
})
21+
{
22+
Assert.True(map.ContainsKey(key), $"Missing key '{key}' for culture '{culture}'.");
23+
}
24+
}
25+
26+
[Fact]
27+
public void Localizer_falls_back_to_english()
28+
{
29+
var localizer = new SwitchLocalizer(SwitchLocalizationCatalog.Default);
30+
Assert.Equal(
31+
SwitchLocalizationCatalog.Default.Strings["en"]["switch.status.on"],
32+
localizer.Get("switch.status.on", "de"));
33+
}
34+
}

0 commit comments

Comments
 (0)