Skip to content

Commit 3d09010

Browse files
authored
Merge pull request #54 from teamssUTXO/main
Add CI to generate manifest.json (closes #52) Generates manifest.json with BCP47 codes, native names, file names, SHA-256 hashes, maintainer, and SHA-stable updated timestamps. CI workflow at .github/workflows/manifest.yml watches Translator/translations/**.json and re-runs on merge or manual workflow_dispatch.
2 parents 45850a0 + f24efb3 commit 3d09010

7 files changed

Lines changed: 629 additions & 2 deletions

File tree

.github/workflows/manifest.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Generate Manifest
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'Translator/translations/**/*.json'
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: write
12+
13+
jobs:
14+
manifest-generation:
15+
runs-on: ubuntu-latest
16+
env:
17+
CI: true
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Setup .NET 10
24+
uses: actions/setup-dotnet@v4
25+
with:
26+
dotnet-version: 10.0.x
27+
28+
- name: Build solution
29+
run: dotnet build --configuration Release
30+
31+
- name: Generate manifest.json
32+
run: dotnet run -- generate-manifest
33+
working-directory: Translator
34+
35+
- name: Commit
36+
uses: EndBug/add-and-commit@v10
37+
with:
38+
default_author: github_actor
39+
add: ./manifest.json # if the working directory is the repo root dir
40+
message: "Update manifest.json"
41+
commit: ""
42+
push: true
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
using System.Security.Cryptography;
2+
using System.Text.Json;
3+
using BTCPayTranslator.Models;
4+
using BTCPayTranslator.Services;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
using Xunit;
7+
8+
namespace BTCPayTranslator.Tests.Services;
9+
10+
public class ManifestGeneratorTests
11+
{
12+
[Fact]
13+
public async Task GenerateManifest_WritesManifest_ForValidTranslationFile()
14+
{
15+
var tempDir = CreateTempDirectory();
16+
var translationsDir = Path.Combine(tempDir, "translations");
17+
Directory.CreateDirectory(translationsDir);
18+
var translationFile = Path.Combine(translationsDir, "French.json");
19+
var manifestPath = Path.Combine(tempDir, "manifest.json");
20+
21+
try
22+
{
23+
await File.WriteAllTextAsync(translationFile, """
24+
{
25+
"_maintainer": "alice|https://github.com/alice",
26+
"hello": "bonjour"
27+
}
28+
""");
29+
30+
var sut = CreateSut();
31+
var result = await sut.GenerateManifest(translationsDir, manifestPath);
32+
33+
Assert.True(result);
34+
Assert.True(File.Exists(manifestPath));
35+
36+
var manifest = await ReadManifest(manifestPath);
37+
var entry = Assert.Single(manifest.Languages);
38+
39+
Assert.Equal("fr", entry.Code);
40+
Assert.Equal("fr-FR", entry.Bcp47);
41+
Assert.Equal("French", entry.Name);
42+
Assert.Equal("Français", entry.Native);
43+
Assert.Equal("translations/French.json", entry.File);
44+
Assert.Equal("alice|https://github.com/alice", entry.Maintainer);
45+
Assert.Equal(ComputeSha256(translationFile), entry.Sha);
46+
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
47+
}
48+
finally
49+
{
50+
if (Directory.Exists(tempDir))
51+
{
52+
Directory.Delete(tempDir, recursive: true);
53+
}
54+
}
55+
}
56+
57+
[Fact]
58+
public async Task GenerateManifest_ReturnsFalse_WhenNoTranslationFilesExist()
59+
{
60+
var tempDir = CreateTempDirectory();
61+
var manifestPath = Path.Combine(tempDir, "manifest.json");
62+
63+
try
64+
{
65+
var sut = CreateSut();
66+
67+
var result = await sut.GenerateManifest(tempDir, manifestPath);
68+
69+
Assert.False(result);
70+
Assert.False(File.Exists(manifestPath));
71+
}
72+
finally
73+
{
74+
if (Directory.Exists(tempDir))
75+
{
76+
Directory.Delete(tempDir, recursive: true);
77+
}
78+
}
79+
}
80+
81+
[Fact]
82+
public async Task GenerateManifest_ReturnsFalse_WhenTranslationDirectoryDoesNotExist()
83+
{
84+
var translationsDir = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
85+
var manifestPath = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"), "manifest.json");
86+
var sut = CreateSut();
87+
88+
var result = await sut.GenerateManifest(translationsDir, manifestPath);
89+
90+
Assert.False(result);
91+
}
92+
93+
[Fact]
94+
public async Task GenerateManifest_RetainsUpdated_WhenExistingShaMatches()
95+
{
96+
var tempDir = CreateTempDirectory();
97+
var translationsDir = Path.Combine(tempDir, "translations");
98+
Directory.CreateDirectory(translationsDir);
99+
var translationFile = Path.Combine(translationsDir, "French.json");
100+
var manifestPath = Path.Combine(tempDir, "manifest.json");
101+
102+
try
103+
{
104+
await File.WriteAllTextAsync(translationFile, """
105+
{
106+
"_maintainer": "alice|https://github.com/alice",
107+
"hello": "bonjour"
108+
}
109+
""");
110+
111+
var existingSha = ComputeSha256(translationFile);
112+
var expectedUpdated = "2024-01-02T03:04:05Z";
113+
var existingManifest = new Manifest(
114+
new List<ManifestEntry>
115+
{
116+
new(
117+
Code: "fr",
118+
Bcp47: "fr-FR",
119+
Name: "French",
120+
Native: "Français",
121+
File: "translations/French.json",
122+
Sha: existingSha,
123+
Maintainer: "old",
124+
Updated: expectedUpdated)
125+
},
126+
Redirect: null);
127+
128+
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));
129+
130+
var sut = CreateSut();
131+
var result = await sut.GenerateManifest(translationsDir, manifestPath);
132+
133+
Assert.True(result);
134+
var generated = await ReadManifest(manifestPath);
135+
var entry = Assert.Single(generated.Languages);
136+
Assert.Equal(expectedUpdated, entry.Updated);
137+
}
138+
finally
139+
{
140+
if (Directory.Exists(tempDir))
141+
{
142+
Directory.Delete(tempDir, recursive: true);
143+
}
144+
}
145+
}
146+
147+
[Fact]
148+
public async Task GenerateManifest_UpdatesUpdated_WhenExistingShaDiffers()
149+
{
150+
var tempDir = CreateTempDirectory();
151+
var translationsDir = Path.Combine(tempDir, "translations");
152+
Directory.CreateDirectory(translationsDir);
153+
var translationFile = Path.Combine(translationsDir, "French.json");
154+
var manifestPath = Path.Combine(tempDir, "manifest.json");
155+
156+
try
157+
{
158+
await File.WriteAllTextAsync(translationFile, """
159+
{
160+
"_maintainer": "alice|https://github.com/alice",
161+
"hello": "bonjour"
162+
}
163+
""");
164+
165+
var previousUpdated = "2024-01-02T03:04:05Z";
166+
var existingManifest = new Manifest(
167+
new List<ManifestEntry>
168+
{
169+
new(
170+
Code: "fr",
171+
Bcp47: "fr-FR",
172+
Name: "French",
173+
Native: "Français",
174+
File: "translations/French.json",
175+
Sha: "deadbeef",
176+
Maintainer: "old",
177+
Updated: previousUpdated)
178+
},
179+
Redirect: null);
180+
181+
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));
182+
183+
var sut = CreateSut();
184+
var result = await sut.GenerateManifest(translationsDir, manifestPath);
185+
186+
Assert.True(result);
187+
var generated = await ReadManifest(manifestPath);
188+
var entry = Assert.Single(generated.Languages);
189+
190+
Assert.NotEqual(previousUpdated, entry.Updated);
191+
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
192+
}
193+
finally
194+
{
195+
if (Directory.Exists(tempDir))
196+
{
197+
Directory.Delete(tempDir, recursive: true);
198+
}
199+
}
200+
}
201+
202+
[Fact]
203+
public async Task GenerateManifest_SetsMaintainerToNull_WhenFieldMissing()
204+
{
205+
var tempDir = CreateTempDirectory();
206+
var translationFile = Path.Combine(tempDir, "French.json");
207+
var manifestPath = Path.Combine(tempDir, "manifest.json");
208+
209+
try
210+
{
211+
await File.WriteAllTextAsync(translationFile, """
212+
{
213+
"hello": "bonjour"
214+
}
215+
""");
216+
217+
var sut = CreateSut();
218+
var result = await sut.GenerateManifest(tempDir, manifestPath);
219+
220+
Assert.True(result);
221+
var manifest = await ReadManifest(manifestPath);
222+
var entry = Assert.Single(manifest.Languages);
223+
Assert.Null(entry.Maintainer);
224+
}
225+
finally
226+
{
227+
if (Directory.Exists(tempDir))
228+
{
229+
Directory.Delete(tempDir, recursive: true);
230+
}
231+
}
232+
}
233+
234+
[Fact]
235+
public async Task GenerateManifest_ReturnsFalse_WhenLanguageNameMappingIsMissing()
236+
{
237+
var tempDir = CreateTempDirectory();
238+
var translationFile = Path.Combine(tempDir, "Klingon.json");
239+
var manifestPath = Path.Combine(tempDir, "manifest.json");
240+
241+
try
242+
{
243+
await File.WriteAllTextAsync(translationFile, """
244+
{
245+
"_maintainer": "alice|https://github.com/alice",
246+
"hello": "nuqneH"
247+
}
248+
""");
249+
250+
var sut = CreateSut();
251+
var result = await sut.GenerateManifest(tempDir, manifestPath);
252+
253+
Assert.False(result);
254+
}
255+
finally
256+
{
257+
if (Directory.Exists(tempDir))
258+
{
259+
Directory.Delete(tempDir, recursive: true);
260+
}
261+
}
262+
}
263+
264+
[Fact]
265+
public async Task GenerateManifest_ReturnsFalse_WhenTranslationFileHasInvalidJson()
266+
{
267+
var tempDir = CreateTempDirectory();
268+
var translationFile = Path.Combine(tempDir, "French.json");
269+
var manifestPath = Path.Combine(tempDir, "manifest.json");
270+
271+
try
272+
{
273+
await File.WriteAllTextAsync(translationFile, "{\"_maintainer\":");
274+
275+
var sut = CreateSut();
276+
var result = await sut.GenerateManifest(tempDir, manifestPath);
277+
278+
Assert.False(result);
279+
}
280+
finally
281+
{
282+
if (Directory.Exists(tempDir))
283+
{
284+
Directory.Delete(tempDir, recursive: true);
285+
}
286+
}
287+
}
288+
289+
private static ManifestGenerator CreateSut()
290+
{
291+
return new ManifestGenerator(NullLogger<ManifestGenerator>.Instance);
292+
}
293+
294+
private static async Task<Manifest> ReadManifest(string path)
295+
{
296+
var json = await File.ReadAllTextAsync(path);
297+
var manifest = JsonSerializer.Deserialize<Manifest>(json);
298+
return Assert.IsType<Manifest>(manifest);
299+
}
300+
301+
private static string ComputeSha256(string path)
302+
{
303+
using var sha256 = SHA256.Create();
304+
using var stream = File.OpenRead(path);
305+
var hash = sha256.ComputeHash(stream);
306+
return Convert.ToHexString(hash).ToLowerInvariant();
307+
}
308+
309+
private static string CreateTempDirectory()
310+
{
311+
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
312+
Directory.CreateDirectory(directory);
313+
return directory;
314+
}
315+
}

Translator/Models/LanguageInfo.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.Linq;
24

35
namespace BTCPayTranslator.Models;
46

@@ -119,4 +121,14 @@ public static IEnumerable<LanguageInfo> GetAllLanguages()
119121
{
120122
return Languages.Values;
121123
}
124+
125+
public static (string Code, LanguageInfo)? GetLanguageInfoByName(string name)
126+
{
127+
var match = Languages.FirstOrDefault(kvp =>
128+
kvp.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
129+
130+
if (match.Key == null) return null;
131+
132+
return (match.Key, match.Value);
133+
}
122134
}

0 commit comments

Comments
 (0)