Skip to content

Commit 2d3be56

Browse files
Make the sample index gates fail when they should
Five tests asserted things that could not come out any other way, so the gate they belonged to was reporting a pass it had not earned. Three of them asked the in-memory object a question about the published file: the schema version and source restated their own field initializers, the language tag restated the line in IndexGenerator that sets it, and the keyword check restated the TrimEntries already applied by SplitKeywords. They now read the committed JSON, which is what a consumer fetches and the only place the serialized field names can be observed. The keyword gate also covers the generated keywords, which come from the category frontmatter by a different path and were not checked at all, and the trimming it relies on is now pinned by unit tests over messy frontmatter. The fourth asserted that generating twice in one process produced the same bytes, which cannot see the divergence it was written to prevent: the index is generated on Windows and byte-compared by a Linux CI job, and NTFS compares names case-insensitively where ext4 does not. A fixture whose ordinal and case-insensitive orderings disagree catches both halves of that — dropping the sort, and swapping the comparer — on either filesystem. The existing EntriesAreOrderedByDocumentPath cannot, because every component in this repository happens to start with a capital letter. Program.cs had no coverage at all, while CI depends on its exit codes: 2 for a mistyped command, 1 for a stale or missing index, 0 otherwise. A mistyped command reported as drift would send someone looking for a change that was never made. The CRLF normalization is covered too, since without it every Windows working copy reads as stale. Main becomes internal so the tests can call it, and the fixture repository they run against lives in Fixture.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0a4e8e1 commit 2d3be56

7 files changed

Lines changed: 390 additions & 20 deletions

File tree

‎tools/SampleIndexExporter.Tests/ContractConformanceTests.cs‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Text.Json;
56
using System.Text.RegularExpressions;
67
using Microsoft.VisualStudio.TestTools.UnitTesting;
78

@@ -21,8 +22,14 @@ public class ContractConformanceTests
2122
[TestMethod]
2223
public void IndexDeclaresItsSourceAndVersion()
2324
{
24-
Assert.AreEqual(1, RepositoryIndex.Index.SchemaVersion);
25-
Assert.AreEqual("toolkit", RepositoryIndex.Index.Source);
25+
// Read back from the committed file rather than the object that produced it. A consumer
26+
// branches on these two fields before it reads anything else, so what matters is that
27+
// they survive serialization under the field names the contract publishes — asserting
28+
// against the in-memory defaults would only restate their initializers.
29+
using var document = JsonDocument.Parse(RepositoryIndex.CommittedJson);
30+
31+
Assert.AreEqual(1, document.RootElement.GetProperty("schemaVersion").GetInt32());
32+
Assert.AreEqual("toolkit", document.RootElement.GetProperty("source").GetString());
2633
}
2734

2835
[TestMethod]
@@ -134,19 +141,54 @@ public void EveryNuGetPackageNameLooksLikeAPackage()
134141
}
135142

136143
[TestMethod]
137-
public void CuratedKeywordsAreTrimmedAndNonEmpty()
144+
public void KeywordsAreTrimmedAndNonEmpty()
138145
{
139146
// Consumers weight curated keywords above generated ones, so a stray empty string or
140-
// untrimmed entry becomes a search term that matches nothing.
141-
var malformed = RepositoryIndex.Index.Controls
142-
.Where(c => c.CuratedKeywords is { } keywords
143-
&& keywords.Any(k => string.IsNullOrWhiteSpace(k) || k != k.Trim()))
144-
.Select(c => c.Id)
145-
.ToList();
147+
// untrimmed entry becomes a search term that matches nothing. Read back from the
148+
// committed file, and covering both lists: the generated keywords come from the
149+
// category frontmatter by a different path than the curated ones, and were not checked
150+
// at all.
151+
using var document = JsonDocument.Parse(RepositoryIndex.CommittedJson);
152+
153+
var malformed = new List<string>();
154+
155+
foreach (var control in document.RootElement.GetProperty("controls").EnumerateArray())
156+
{
157+
foreach (var field in new[] { "curatedKeywords", "keywords" })
158+
{
159+
if (!control.TryGetProperty(field, out var keywords))
160+
{
161+
continue;
162+
}
163+
164+
malformed.AddRange(keywords
165+
.EnumerateArray()
166+
.Select(k => k.GetString())
167+
.Where(k => string.IsNullOrWhiteSpace(k) || k != k.Trim())
168+
.Select(k => $"{control.GetProperty("id").GetString()}.{field}: '{k}'"));
169+
}
170+
}
146171

147172
Assert.AreEqual(
148173
0,
149174
malformed.Count,
150-
"Entries with blank or untrimmed curated keywords:\n " + string.Join("\n ", malformed));
175+
"Blank or untrimmed keywords:\n " + string.Join("\n ", malformed));
176+
}
177+
178+
[TestMethod]
179+
public void KeywordSplittingDiscardsPaddingAndEmptyTerms()
180+
{
181+
// Pins the behaviour the gate above relies on. Frontmatter is hand-written, so a
182+
// trailing comma or a space after one is a matter of time rather than a hypothetical.
183+
var keywords = MarkdownDocument.SplitKeywords("WrapPanel, Layout ,, Panel,");
184+
185+
CollectionAssert.AreEqual(new[] { "WrapPanel", "Layout", "Panel" }, keywords);
186+
}
187+
188+
[TestMethod]
189+
public void KeywordSplittingTreatsAnAbsentFieldAsNoKeywords()
190+
{
191+
Assert.AreEqual(0, MarkdownDocument.SplitKeywords(null).Count);
192+
Assert.AreEqual(0, MarkdownDocument.SplitKeywords(" ").Count);
151193
}
152194
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.SampleIndex.Tests;
6+
7+
/// <summary>
8+
/// Builds a miniature repository on disk for the tests that are about the exporter rather than
9+
/// about this repository's samples.
10+
/// </summary>
11+
/// <remarks>
12+
/// Small on purpose. It has to satisfy <see cref="IndexGenerator.FindRepoRoot"/> and carry one
13+
/// component with one documented sample; anything more would make the tests that use it harder
14+
/// to read without making them test more.
15+
/// </remarks>
16+
internal static class Fixture
17+
{
18+
public const string SampleXaml =
19+
"""
20+
<Page x:Class="Widgets.WidgetSample"
21+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
22+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
23+
<TextBlock Text="Hello" />
24+
</Page>
25+
""";
26+
27+
/// <summary>Create a fixture repository and return its root.</summary>
28+
/// <param name="components">
29+
/// Component folder names, created in the order given. The generated index must not depend
30+
/// on that order.
31+
/// </param>
32+
public static string Create(params string[] components)
33+
{
34+
if (components.Length == 0)
35+
{
36+
components = ["Widgets"];
37+
}
38+
39+
var root = Directory.CreateTempSubdirectory("sample-index-").FullName;
40+
41+
// What FindRepoRoot looks for.
42+
File.WriteAllText(Path.Combine(root, "Directory.Build.props"), "<Project />");
43+
Directory.CreateDirectory(Path.Combine(root, "components"));
44+
45+
foreach (var component in components)
46+
{
47+
var samples = Directory.CreateDirectory(
48+
Path.Combine(root, "components", component, "samples")).FullName;
49+
50+
File.WriteAllText(Path.Combine(samples, $"{component}.md"), Document(component));
51+
File.WriteAllText(Path.Combine(samples, "WidgetSample.xaml.cs"), Declaration("WidgetSample", "Widget"));
52+
File.WriteAllText(Path.Combine(samples, "WidgetSample.xaml"), SampleXaml);
53+
}
54+
55+
return root;
56+
}
57+
58+
public static string Declaration(string id, string displayName) =>
59+
$$"""
60+
using CommunityToolkit.Tooling.SampleGen.Attributes;
61+
62+
namespace Widgets;
63+
64+
[ToolkitSample(id: nameof({{id}}), "{{displayName}}", description: "Shows a widget.")]
65+
public sealed partial class {{id}} : Page;
66+
""";
67+
68+
private static string Document(string component) =>
69+
$"""
70+
---
71+
title: {component}
72+
author: nobody
73+
description: A component used by the exporter's own tests.
74+
keywords: {component}
75+
category: Controls
76+
subcategory: Layout
77+
---
78+
79+
Prose introducing the sample.
80+
81+
> [!SAMPLE WidgetSample]
82+
""";
83+
}

‎tools/SampleIndexExporter.Tests/IndexUpToDateTests.cs‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,53 @@ public void CommittedIndexMatchesTheSamples()
4646
[TestMethod]
4747
public void GeneratingTwiceProducesTheSameBytes()
4848
{
49-
// Stable ordering, stated as a test rather than assumed. If generation were order-
50-
// dependent — on file enumeration order, say, or on a dictionary's iteration order —
51-
// the staleness check above would fail at random and teach everyone to ignore it.
49+
// Catches in-process non-determinism only: a dictionary's iteration order, a hash set
50+
// reaching the output, a stray DateTime. It cannot see the filesystem-dependent kind,
51+
// which is what the test below is for.
5252
var first = IndexGenerator.Serialize(IndexGenerator.Generate(RepositoryIndex.Root).Index);
5353
var second = IndexGenerator.Serialize(IndexGenerator.Generate(RepositoryIndex.Root).Index);
5454

5555
Assert.AreEqual(first, second, "Generating the index twice produced different output.");
5656
}
5757

58+
[TestMethod]
59+
public void EntriesAreOrderedOrdinallyRatherThanByTheHostsIdeaOfOrder()
60+
{
61+
// The index is generated by contributors on Windows and byte-compared by a Linux CI
62+
// job, so every ordering has to mean the same thing on both. NTFS compares names
63+
// case-insensitively and ext4 does not; a culture-aware or case-insensitive comparer
64+
// would therefore agree with itself on one machine and disagree across the two,
65+
// producing a CI failure nobody could reproduce locally.
66+
//
67+
// These three names are chosen so ordinal order (uppercase first) and case-insensitive
68+
// order disagree: ordinal gives Beta, Gamma, alpha; case-insensitive gives alpha, Beta,
69+
// Gamma. Comparing two fixtures built in different orders would not catch this, because
70+
// NTFS hands directory entries back in name order whatever order they were created in.
71+
var root = Fixture.Create("Beta", "alpha", "Gamma");
72+
73+
try
74+
{
75+
var entries = IndexGenerator.Generate(root).Index.Controls
76+
.Select(c => c.Toolkit!.DocumentPath)
77+
.ToList();
78+
79+
CollectionAssert.AreEqual(
80+
new[]
81+
{
82+
"components/Beta/samples/Beta.md",
83+
"components/Gamma/samples/Gamma.md",
84+
"components/alpha/samples/alpha.md",
85+
},
86+
entries,
87+
"Entries must be ordered ordinally, which is the only order both CI and a "
88+
+ "contributor's machine agree on.");
89+
}
90+
finally
91+
{
92+
Directory.Delete(root, recursive: true);
93+
}
94+
}
95+
5896
[TestMethod]
5997
public void EntriesAreOrderedByDocumentPath()
6098
{

0 commit comments

Comments
 (0)