summaryrefslogtreecommitdiff
path: root/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
blob: 77c0da5f6c9b4bb879a5a480de36631e828dcc74 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
using System;
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using NUnit.Framework;
using StardewModdingAPI;
using StardewModdingAPI.Framework;

namespace SMAPI.Tests.Utilities
{
    /// <summary>Unit tests for <see cref="SemanticVersion"/>.</summary>
    [TestFixture]
    internal class SemanticVersionTests
    {
        /*********
        ** Unit tests
        *********/
        /****
        ** Constructor
        ****/
        /// <summary>Assert the parsed version when constructed from a standard string.</summary>
        /// <param name="input">The version string to parse.</param>
        [TestCase("1.0", ExpectedResult = "1.0.0")]
        [TestCase("1.0.0", ExpectedResult = "1.0.0")]
        [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")]
        [TestCase("1.2-some-tag.4", ExpectedResult = "1.2.0-some-tag.4")]
        [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
        [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")]
        [TestCase("1.2.3-some-tag.4      ", ExpectedResult = "1.2.3-some-tag.4")]
        [TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
        [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
        public string Constructor_FromString(string input)
        {
            // act
            ISemanticVersion version = new SemanticVersion(input);

            // assert
            return version.ToString();
        }


        /// <summary>Assert that the constructor rejects invalid values when constructed from a string.</summary>
        /// <param name="input">The version string to parse.</param>
        [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")]
        [TestCase(null)]
        [TestCase("")]
        [TestCase("   ")]
        [TestCase("1")]
        [TestCase("01.0")]
        [TestCase("1.05")]
        [TestCase("1.5.06")] // leading zeros specifically prohibited by spec
        [TestCase("1.2.3.4")]
        [TestCase("1.apple")]
        [TestCase("1.2.apple")]
        [TestCase("1.2.3.apple")]
        [TestCase("1..2..3")]
        [TestCase("1.2.3-")]
        [TestCase("1.2.3--some-tag")]
        [TestCase("1.2.3-some-tag...")]
        [TestCase("1.2.3-some-tag...4")]
        [TestCase("1.2.3-some-tag.4+build...4")]
        [TestCase("apple")]
        [TestCase("-apple")]
        [TestCase("-5")]
        public void Constructor_FromString_WithInvalidValues(string? input)
        {
            if (input == null)
                this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input!));
            else
                this.AssertAndLogException<FormatException>(() => new SemanticVersion(input));
        }

        /// <summary>Assert the parsed version when constructed from a non-standard string.</summary>
        /// <param name="input">The version string to parse.</param>
        [TestCase("1.2.3", ExpectedResult = "1.2.3")]
        [TestCase("1.0.0.0", ExpectedResult = "1.0.0")]
        [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")]
        [TestCase("1.2.3.4-some-tag.4      ", ExpectedResult = "1.2.3.4-some-tag.4")]
        public string Constructor_FromString_NonStandard(string input)
        {
            // act
            ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true);

            // assert
            return version.ToString();
        }

        /// <summary>Assert that the constructor rejects a non-standard string when the non-standard flag isn't set.</summary>
        /// <param name="input">The version string to parse.</param>
        [TestCase("1.0.0.0")]
        [TestCase("1.0.0.5")]
        [TestCase("1.2.3.4-some-tag.4      ")]
        public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input)
        {
            Assert.Throws<FormatException>(() => _ = new SemanticVersion(input));
        }

        /// <summary>Assert the parsed version when constructed from standard parts.</summary>
        /// <param name="major">The major number.</param>
        /// <param name="minor">The minor number.</param>
        /// <param name="patch">The patch number.</param>
        /// <param name="prerelease">The prerelease tag.</param>
        /// <param name="build">The build metadata.</param>
        [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")]
        [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")]
        [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")]
        [TestCase(1, 2, 3, "    ", null, ExpectedResult = "1.2.3")]
        [TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")]
        [TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")]
        [TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")]
        [TestCase(1, 2, 3, "some-tag.4   ", null, ExpectedResult = "1.2.3-some-tag.4")]
        [TestCase(1, 2, 3, "some-tag.4   ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
        [TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
        public string Constructor_FromParts(int major, int minor, int patch, string? prerelease, string? build)
        {
            // act
            ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);

            // assert
            this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false);
            return version.ToString();
        }

        /// <summary>Assert the parsed version when constructed from parts including non-standard fields.</summary>
        /// <param name="major">The major number.</param>
        /// <param name="minor">The minor number.</param>
        /// <param name="patch">The patch number.</param>
        /// <param name="platformRelease">The non-standard platform release number.</param>
        /// <param name="prerelease">The prerelease tag.</param>
        /// <param name="build">The build metadata.</param>
        [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")]
        [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")]
        [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")]
        [TestCase(1, 2, 3, 4, "    ", null, ExpectedResult = "1.2.3.4")]
        [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")]
        [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")]
        [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")]
        [TestCase(1, 2, 3, 4, "some-tag.4   ", null, ExpectedResult = "1.2.3.4-some-tag.4")]
        [TestCase(1, 2, 3, 4, "some-tag.4   ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")]
        [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")]
        public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build)
        {
            // act
            ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build);

            // assert
            this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0);
            return version.ToString();
        }

        /// <summary>Assert that the constructor rejects invalid values when constructed from the individual numbers.</summary>
        /// <param name="major">The major number.</param>
        /// <param name="minor">The minor number.</param>
        /// <param name="patch">The patch number.</param>
        /// <param name="prerelease">The prerelease tag.</param>
        /// <param name="build">The build metadata.</param>
        [TestCase(0, 0, 0, null, null)]
        [TestCase(-1, 0, 0, null, null)]
        [TestCase(0, -1, 0, null, null)]
        [TestCase(0, 0, -1, null, null)]
        [TestCase(1, 0, 0, "-tag", null)]
        [TestCase(1, 0, 0, "tag spaces", null)]
        [TestCase(1, 0, 0, "tag~", null)]
        [TestCase(1, 0, 0, null, "build~")]
        public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build)
        {
            this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build));
        }

        /// <summary>Assert the parsed version when constructed from an assembly version.</summary>
        /// <param name="major">The major number.</param>
        /// <param name="minor">The minor number.</param>
        /// <param name="patch">The patch number.</param>
        [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")]
        [TestCase(1, 0, 0, ExpectedResult = "1.0.0")]
        [TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
        [TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")]
        public string Constructor_FromAssemblyVersion(int major, int minor, int patch)
        {
            // act
            ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch));

            // assert
            this.AssertParts(version, major, minor, patch, null, null, nonStandard: false);
            return version.ToString();
        }

        /****
        ** CompareTo
        ****/
        /// <summary>Assert that <see cref="ISemanticVersion.CompareTo"/> returns the expected value.</summary>
        /// <param name="versionStrA">The left version.</param>
        /// <param name="versionStrB">The right version.</param>
        // equal
        [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)]
        [TestCase("1.0", "1.0", ExpectedResult = 0)]
        [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)]
        [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)]
        [TestCase("1.0-beta", "1.0-beta   ", ExpectedResult = 0)]
        [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = 0)]
        [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = 0)] // build metadata must not affect precedence

        // less than
        [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)]
        [TestCase("1.0", "1.1", ExpectedResult = -1)]
        [TestCase("1.0-beta", "1.0", ExpectedResult = -1)]
        [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = -1)]
        [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = -1)]
        [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = -1)]
        [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = -1)]
        [TestCase("1.0-unofficial.1", "1.0-beta.1", ExpectedResult = -1)] // special case: 'unofficial' has lower priority than official releases

        // more than
        [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)]
        [TestCase("1.1", "1.0", ExpectedResult = 1)]
        [TestCase("1.0", "1.0-beta", ExpectedResult = 1)]
        [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = 1)]
        [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)]
        [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)]
        [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)]

        // null
        [TestCase("1.0.0", null, ExpectedResult = 1)] // null is always less than any value per CompareTo remarks
        public int CompareTo(string versionStrA, string? versionStrB)
        {
            // arrange
            ISemanticVersion versionA = new SemanticVersion(versionStrA);
            ISemanticVersion? versionB = versionStrB != null
                ? new SemanticVersion(versionStrB)
                : null;

            // assert
            return versionA.CompareTo(versionB);
        }

        /****
        ** IsOlderThan
        ****/
        /// <summary>Assert that <see cref="ISemanticVersion.IsOlderThan(string)"/> and <see cref="ISemanticVersion.IsOlderThan(ISemanticVersion)"/> return the expected value.</summary>
        /// <param name="versionStrA">The left version.</param>
        /// <param name="versionStrB">The right version.</param>
        // keep test cases in sync with CompareTo for simplicity.
        // equal
        [TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
        [TestCase("1.0", "1.0", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)]
        [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0-beta   ", ExpectedResult = false)]
        [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence
        [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence

        // less than
        [TestCase("0.5.7", "0.5.8", ExpectedResult = true)]
        [TestCase("1.0", "1.1", ExpectedResult = true)]
        [TestCase("1.0-beta", "1.0", ExpectedResult = true)]
        [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = true)]
        [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = true)]
        [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = true)]
        [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = true)]

        // more than
        [TestCase("0.5.8", "0.5.7", ExpectedResult = false)]
        [TestCase("1.1", "1.0", ExpectedResult = false)]
        [TestCase("1.0", "1.0-beta", ExpectedResult = false)]
        [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = false)]
        [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)]
        [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)]
        [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)]

        // null
        [TestCase("1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks
        public bool IsOlderThan(string versionStrA, string? versionStrB)
        {
            // arrange
            ISemanticVersion versionA = new SemanticVersion(versionStrA);
            ISemanticVersion? versionB = versionStrB != null
                ? new SemanticVersion(versionStrB)
                : null;

            // assert
            Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB?.ToString()), "The two signatures returned different results.");
            return versionA.IsOlderThan(versionB);
        }

        /****
        ** IsNewerThan
        ****/
        /// <summary>Assert that <see cref="ISemanticVersion.IsNewerThan(string)"/> and <see cref="ISemanticVersion.IsNewerThan(ISemanticVersion)"/> return the expected value.</summary>
        /// <param name="versionStrA">The left version.</param>
        /// <param name="versionStrB">The right version.</param>
        // keep test cases in sync with CompareTo for simplicity.
        // equal
        [TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
        [TestCase("1.0", "1.0", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)]
        [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0-beta   ", ExpectedResult = false)]
        [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence
        [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence

        // less than
        [TestCase("0.5.7", "0.5.8", ExpectedResult = false)]
        [TestCase("1.0", "1.1", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0", ExpectedResult = false)]
        [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = false)]
        [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = false)]
        [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = false)]
        [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = false)]

        // more than
        [TestCase("0.5.8", "0.5.7", ExpectedResult = true)]
        [TestCase("1.1", "1.0", ExpectedResult = true)]
        [TestCase("1.0", "1.0-beta", ExpectedResult = true)]
        [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = true)]
        [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)]
        [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)]
        [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)]

        // null
        [TestCase("1.0.0", null, ExpectedResult = true)] // null is always less than any value per CompareTo remarks
        public bool IsNewerThan(string versionStrA, string? versionStrB)
        {
            // arrange
            ISemanticVersion versionA = new SemanticVersion(versionStrA);
            ISemanticVersion? versionB = versionStrB != null
                ? new SemanticVersion(versionStrB)
                : null;

            // assert
            Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB?.ToString()), "The two signatures returned different results.");
            return versionA.IsNewerThan(versionB);
        }

        /****
        ** IsBetween
        ****/
        /// <summary>Assert that <see cref="ISemanticVersion.IsBetween(string, string)"/> and <see cref="ISemanticVersion.IsBetween(ISemanticVersion, ISemanticVersion)"/> return the expected value.</summary>
        /// <param name="versionStr">The main version.</param>
        /// <param name="lowerStr">The lower version number.</param>
        /// <param name="upperStr">The upper version number.</param>
        [Test(Description = "Assert that version.IsBetween returns the expected value.")]
        // is between
        [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)]
        [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)]
        [TestCase("1.0", "1.0-beta", "1.1", ExpectedResult = true)]
        [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)]
        [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)]
        [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)]
        [TestCase("1.0.0", null, "1.0.0", ExpectedResult = true)] // null is always less than any value per CompareTo remarks

        // is not between
        [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)]
        [TestCase("1.0", "1.1", "1.0", ExpectedResult = false)]
        [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)]
        [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)]
        [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)]
        [TestCase("1.0.0", "1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks
        public bool IsBetween(string versionStr, string? lowerStr, string? upperStr)
        {
            // arrange
            ISemanticVersion? lower = lowerStr != null
                ? new SemanticVersion(lowerStr)
                : null;
            ISemanticVersion? upper = upperStr != null
                ? new SemanticVersion(upperStr)
                : null;
            ISemanticVersion version = new SemanticVersion(versionStr);

            // assert
            Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower?.ToString(), upper?.ToString()), "The two signatures returned different results.");
            return version.IsBetween(lower, upper);
        }

        /****
        ** Serializable
        ****/
        /// <summary>Assert that the version can be round-tripped through JSON with no special configuration.</summary>
        /// <param name="versionStr">The semantic version.</param>
        [TestCase("1.0.0")]
        [TestCase("1.0.0-beta.400")]
        [TestCase("1.0.0-beta.400+build")]
        public void Serializable(string versionStr)
        {
            // act
            string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr));
            SemanticVersion? after = JsonConvert.DeserializeObject<SemanticVersion>(json);

            // assert
            Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null.");
            Assert.AreEqual(versionStr, after!.ToString(), "The semantic version after deserialization doesn't match the input version.");
        }


        /****
        ** GameVersion
        ****/
        /// <summary>Assert that the GameVersion subclass correctly parses non-standard game versions.</summary>
        /// <param name="versionStr">The raw version.</param>
        [TestCase("1.0")]
        [TestCase("1.01")]
        [TestCase("1.02")]
        [TestCase("1.03")]
        [TestCase("1.04")]
        [TestCase("1.05")]
        [TestCase("1.051")]
        [TestCase("1.051b")]
        [TestCase("1.06")]
        [TestCase("1.07")]
        [TestCase("1.07a")]
        [TestCase("1.08")]
        [TestCase("1.1")]
        [TestCase("1.11")]
        [TestCase("1.2")]
        [TestCase("1.2.15")]
        [TestCase("1.4.0.1")]
        [TestCase("1.4.0.6")]
        public void GameVersion(string versionStr)
        {
            // act
            GameVersion version = new(versionStr);

            // assert
            Assert.AreEqual(versionStr, version.ToString(), "The game version did not round-trip to the same value.");
        }


        /*********
        ** Private methods
        *********/
        /// <summary>Assert that the version matches the expected parts.</summary>
        /// <param name="version">The version number.</param>
        /// <param name="major">The major number.</param>
        /// <param name="minor">The minor number.</param>
        /// <param name="patch">The patch number.</param>
        /// <param name="prerelease">The prerelease tag.</param>
        /// <param name="build">The build metadata.</param>
        /// <param name="nonStandard">Whether the version should be marked as non-standard.</param>
        private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string? prerelease, string? build, bool nonStandard)
        {
            Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match.");
            Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match.");
            Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match.");
            Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match.");
            Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match.");
            Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}.");
        }

        /// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary>
        /// <typeparam name="T">The expected exception type.</typeparam>
        /// <param name="action">The action which may throw the exception.</param>
        [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")]
        private void AssertAndLogException<T>(Func<object> action)
            where T : Exception
        {
            this.AssertAndLogException<T>(() =>
            {
                object result = action();
                TestContext.WriteLine($"Func result: {result}");
            });
        }

        /// <summary>Assert that the expected exception type is thrown, and log the thrown exception.</summary>
        /// <typeparam name="T">The expected exception type.</typeparam>
        /// <param name="action">The action which may throw the exception.</param>
        /// <param name="message">The message to log if the expected exception isn't thrown.</param>
        [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")]
        private void AssertAndLogException<T>(Action action, string? message = null)
            where T : Exception
        {
            try
            {
                action();
            }
            catch (T ex)
            {
                TestContext.WriteLine($"Exception thrown:\n{ex}");
                return;
            }
            catch (Exception ex) when (ex is not AssertionException)
            {
                TestContext.WriteLine($"Exception thrown:\n{ex}");
                Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}.");
            }

            // no exception thrown
            Assert.Fail(message ?? "Didn't throw an exception.");
        }
    }
}