Skip to content

Commit 1d11ecb

Browse files
rubysclaude
andcommitted
csharp: emit the test project, so dotnet test has something to run
csharp was the last target whose gates asserted nothing about behavior. `toolchain-csharp` compiled the emit and stopped there; the archive's README § Test said `dotnet test`, and that ran in 1.1 seconds over a project with no test files, exited 0, and was green for months. The emit simply had no test half — every other target has emitted the transpiled real-blog suite for a while (#34 §0). It does now: `tests/` — ArticleTest 4, CommentTest 5, ArticlesControllerTest 9, CommentsControllerTest 3 = **21 executed**, xUnit, against a per-test database. A SEPARATE PROJECT (tests/App.Tests.csproj), not test files inside the app: `Microsoft.NET.Test.Sdk` generates its own entry point, which collides with the Web SDK app's top-level Program.cs. Two consequences worth knowing — * the app csproj gains `<Compile Remove="tests/**" />`. The SDK globs `**/*.cs`, so without it the app itself tries to compile the xUnit code it references. * README § Test becomes `dotnet test tests`. § Build stays a bare `dotnet build`: project discovery looks in the current directory only, and the root still holds exactly one csproj. XUNIT HAS NO @beforeeach — it constructs a fresh instance per test, so the constructor IS the per-test hook. `RoundhouseTestCase`'s ctor resets the schema and reloads fixtures; a test class that ingested a `setup` gets a generated ctor calling `Setup()`. C# runs the base ctor first, so the ordering matches what JUnit gets from superclass lifecycle methods and XCTest from setUp chaining, without the emitter modelling any of it. `Db.SetupTestDb` IS A FILE, NOT `:memory:`, and that is the one real design call here. This Db pools connections, and every connection to `:memory:` is its OWN database — the write would land somewhere the read pool cannot see, and the count-delta assertions would fail in a way that reads like a lowering bug. One path per process, deleted and recreated per test, keeps every connection on the same bytes. Existing connections point at the previous file, so they're dropped: pooled readers disposed (the Gate counts concurrent rentals, not pool contents, so draining does not desync it), the thread's write connection closed, and `SqliteConnection.ClearAllPools()` for Microsoft.Data.Sqlite's own pool. Being one process-wide static, the suite also can't run concurrently — hence `[assembly: CollectionBehavior(DisableTestParallelization = true)]`. The rest is the kotlin/swift recipe with no lowerer changes: fixtures → `<Plural>Fixtures`, `lower_test_modules_with_inner` → `emit_test_class` (`[Fact]` on `test_*`, inner classes hoisted, body ivars as properties), and a generated TestSetup.cs carrying the schema DDL, fixture loaders, and the routes/controllers tables the synchronous dispatch needs. TestSupport.cs — RoundhouseTestCase plus the Dom substring stub — is the port of runtime/kotlin/test_support.kt. TWO GATES, both new teeth rather than new coverage: * `real_blog_csharp_tests_pass` parses the `Passed: N` summary and demands >= 21. `dotnet test` exits 0 when it discovers nothing, which is how the old gate passed; a count is the only thing that catches emit ceasing to produce tests, or the runner package dropping out. * scripts/smoke's csharp KNOWN-GAP floor of 0 is DELETED. csharp takes the default 21 like everyone else, and an archive that runs nothing now fails instead of warning. A subtlety worth recording: the emitted ivar property for `@article` is `public Article? Article`, which shadows the model type, so `Article. Count()` in the same class binds the static through C#'s "Color Color" rule (§7.6.4.1 — E.I where E names both a type and a property of that type). It is exactly what that rule is for, and it is load-bearing here. Verified against a REAL .NET 10.0.400 toolchain (installed locally for this): `cargo test --test csharp_toolchain -- --ignored` green on both legs, "csharp real-blog suite: 21 tests passed", 4.76s. Also 21/21 through the archive path (`--target csharp`, README and all), stable across three consecutive runs, and `dotnet build` at the root still clean with tests/ present. cargo test --all-targets green, no regressions. The smoke recognizer reads dotnet's real summary as 21, and an archive whose § Test runs nothing now exits 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bc29fa7 commit 1d11ecb

9 files changed

Lines changed: 720 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -779,9 +779,12 @@ jobs:
779779
- uses: actions/setup-dotnet@v4
780780
with:
781781
dotnet-version: '10.0.x'
782-
# `dotnet build` of the emitted ASP.NET Core project — the C# analog of
783-
# crystal's `--no-codegen` / go's `vet`: compiles the model layer, the
784-
# transpiled framework runtime, and the primitives together.
782+
# Two legs: `dotnet build` of the emitted ASP.NET Core project (the C#
783+
# analog of crystal's `--no-codegen` / go's `vet` — model layer,
784+
# transpiled framework runtime and primitives compiling together), and
785+
# `dotnet test tests`, which RUNS the transpiled real-blog suite (21) as
786+
# an xUnit project with an executed-count floor. csharp was the last
787+
# target whose gates asserted nothing about behavior (#34 §0).
785788
- name: cargo test --test csharp_toolchain -- --ignored
786789
run: cargo test --test csharp_toolchain -- --ignored --nocapture
787790

runtime/csharp/Db.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Concurrent;
33
using System.Collections.Generic;
4+
using System.IO;
45
using System.Threading;
56
using Microsoft.Data.Sqlite;
67

@@ -23,8 +24,12 @@ namespace Roundhouse;
2324
// committed write visible to the read pool immediately.
2425
public static class Db
2526
{
27+
// Set only by `SetupTestDb`; production reads the environment.
28+
private static string? _testPath;
29+
2630
private static string DbPath =>
27-
Environment.GetEnvironmentVariable("BLOG_DB")
31+
_testPath
32+
?? Environment.GetEnvironmentVariable("BLOG_DB")
2833
?? Environment.GetEnvironmentVariable("DATABASE_PATH")
2934
?? "storage/development.sqlite3";
3035

@@ -135,6 +140,50 @@ public static void Finalize(long stmt)
135140

136141
private static SqliteConnection WriteConn() => _writeConn ??= Open();
137142

143+
// Per-test database: point every future connection at a fresh file and
144+
// replay the schema DDL. The C# analog of kotlin's `Db.setupTestDb` /
145+
// swift's per-test `:memory:`.
146+
//
147+
// A FILE, not `:memory:`, because this Db pools connections and each
148+
// connection to `:memory:` is its OWN database — the write would land
149+
// somewhere the read pool can't see. One path per process, deleted and
150+
// recreated per test, keeps every connection looking at the same bytes.
151+
//
152+
// Existing connections point at the previous file, so they're dropped:
153+
// pooled readers are disposed (the next `Rent` opens a fresh one — the
154+
// Gate counts concurrent rentals, not pool contents, so draining it does
155+
// not desync), and this thread's write connection is closed. Callers run
156+
// on the test thread with parallelization disabled (see the test
157+
// project's AssemblyInfo), so per-thread state is per-test state.
158+
public static void SetupTestDb(string schema)
159+
{
160+
foreach (var handle in OpenReaders.Keys)
161+
{
162+
Finalize(handle);
163+
}
164+
while (Pool.TryTake(out var pooled))
165+
{
166+
pooled.Dispose();
167+
}
168+
_writeConn?.Dispose();
169+
_writeConn = null;
170+
SqliteConnection.ClearAllPools();
171+
172+
_testPath ??= Path.Combine(
173+
Path.GetTempPath(), $"roundhouse-test-{Environment.ProcessId}.sqlite3");
174+
foreach (var suffix in new[] { "", "-wal", "-shm" })
175+
{
176+
var f = _testPath + suffix;
177+
if (File.Exists(f)) File.Delete(f);
178+
}
179+
180+
if (schema.Length == 0) return;
181+
foreach (var stmt in schema.Split(";\n"))
182+
{
183+
if (stmt.Trim().Length > 0) Exec(stmt);
184+
}
185+
}
186+
138187
public static void Exec(string sql)
139188
{
140189
using var cmd = WriteConn().CreateCommand();

runtime/csharp/TestSupport.cs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Xunit;
5+
6+
namespace Roundhouse;
7+
8+
// The hand-written half of the C# test harness; the app-specific half is
9+
// the generated `TestSetup.cs` (RoundhouseTestSetup — schema DDL, fixture
10+
// loaders, routes, controller factories). Port of
11+
// `runtime/kotlin/test_support.kt` / `runtime/swift/test_support.swift`.
12+
//
13+
// Lifecycle: xUnit constructs a FRESH INSTANCE per test, so the
14+
// constructor is the per-test setup — the analog of JUnit's @BeforeEach.
15+
// A derived test class that ingested a `setup` method gets a generated
16+
// constructor calling it, and C# runs the base constructor first, so the
17+
// ordering (schema reset + fixtures, then the test's own setup) matches
18+
// the other targets.
19+
//
20+
// The controller-test surface (Get/Post/Patch/Delete + AssertResponse /
21+
// AssertRedirectedTo / AssertSelect) dispatches SYNCHRONOUSLY through the
22+
// transpiled Router — the same path Server.Dispatch takes, minus HTTP and
23+
// minus Kestrel. Assertion failures inside test bodies are plain
24+
// `throw new Exception(...)` (what the `inline_assertions` lowerer emits),
25+
// which xUnit records as a per-test failure; the harness's own checks use
26+
// `Assert.Fail` for the same effect with a better message.
27+
public class RoundhouseTestCase
28+
{
29+
protected long __status = 200L;
30+
protected string __body = "";
31+
protected string __location = "";
32+
protected Flash __flash = new Flash();
33+
protected Session __session = new Session();
34+
35+
public RoundhouseTestCase()
36+
{
37+
if (RoundhouseTestSetup.SchemaSql.Length > 0)
38+
{
39+
Db.SetupTestDb(RoundhouseTestSetup.SchemaSql);
40+
foreach (var loader in RoundhouseTestSetup.FixtureLoaders)
41+
{
42+
loader();
43+
}
44+
}
45+
ViewHelpers.ResetSlotsBang();
46+
__flash = new Flash();
47+
__session = new Session();
48+
}
49+
50+
// ── controller-test dispatch ─────────────────────────────────
51+
52+
public void Get(string path) =>
53+
PerformRequest("GET", path, new Dictionary<string, object?>());
54+
55+
public void Post(string path, Dictionary<string, object?>? opts = null) =>
56+
PerformRequest("POST", path, RequestParams(opts));
57+
58+
public void Patch(string path, Dictionary<string, object?>? opts = null) =>
59+
PerformRequest("PATCH", path, RequestParams(opts));
60+
61+
public void Delete(string path, Dictionary<string, object?>? opts = null) =>
62+
PerformRequest("DELETE", path, RequestParams(opts));
63+
64+
// `post path, params: { article: { … } }` lowers to a single options
65+
// hash; the request body is its "params" entry.
66+
private static Dictionary<string, object?> RequestParams(Dictionary<string, object?>? opts)
67+
{
68+
if (opts != null && opts.TryGetValue("params", out var p) &&
69+
p is Dictionary<string, object?> nested)
70+
{
71+
return nested;
72+
}
73+
return new Dictionary<string, object?>();
74+
}
75+
76+
private void PerformRequest(string method, string path, Dictionary<string, object?> prms)
77+
{
78+
ViewHelpers.ResetSlotsBang();
79+
var match = Router.Match(method, path, RoundhouseTestSetup.Routes);
80+
if (match == null)
81+
{
82+
Assert.Fail($"no route for {method} {path}");
83+
return;
84+
}
85+
if (!RoundhouseTestSetup.Controllers.TryGetValue(match.Controller, out var factory))
86+
{
87+
Assert.Fail($"no controller registered for {match.Controller}");
88+
return;
89+
}
90+
91+
var merged = new Dictionary<string, object?>(prms);
92+
foreach (var kv in match.PathParams)
93+
{
94+
merged[kv.Key] = kv.Value;
95+
}
96+
97+
var controller = factory();
98+
controller.Params = merged;
99+
controller.RequestFormat = "html";
100+
controller.RequestMethod = method;
101+
controller.RequestPath = path;
102+
controller.Flash = __flash;
103+
controller.Session = __session;
104+
try
105+
{
106+
controller.ProcessAction(match.Action);
107+
}
108+
catch (RecordNotFound)
109+
{
110+
// Rails' rescue_from: a missing record is a 404, not a crash.
111+
__status = 404L;
112+
__body = "";
113+
__location = "";
114+
return;
115+
}
116+
__status = controller.Status;
117+
__body = controller.Body;
118+
__location = controller.Location ?? "";
119+
__flash = controller.Flash;
120+
}
121+
122+
// ── HTTP response assertions ─────────────────────────────────
123+
124+
private static readonly Dictionary<string, (long Lo, long Hi)> StatusRanges = new()
125+
{
126+
["success"] = (200L, 299L),
127+
["redirect"] = (300L, 399L),
128+
["missing"] = (404L, 404L),
129+
["not_found"] = (404L, 404L),
130+
["error"] = (500L, 599L),
131+
["ok"] = (200L, 200L),
132+
["created"] = (201L, 201L),
133+
["no_content"] = (204L, 204L),
134+
["moved_permanently"] = (301L, 301L),
135+
["found"] = (302L, 302L),
136+
["see_other"] = (303L, 303L),
137+
["bad_request"] = (400L, 400L),
138+
["unauthorized"] = (401L, 401L),
139+
["forbidden"] = (403L, 403L),
140+
["unprocessable_entity"] = (422L, 422L),
141+
["unprocessable_content"] = (422L, 422L),
142+
["internal_server_error"] = (500L, 500L),
143+
};
144+
145+
public void AssertResponse(string expected)
146+
{
147+
if (!StatusRanges.TryGetValue(expected, out var range))
148+
{
149+
Assert.Fail($"unknown response expectation {expected}");
150+
return;
151+
}
152+
if (__status < range.Lo || __status > range.Hi)
153+
{
154+
var preview = __body.Length > 200 ? __body.Substring(0, 200) : __body;
155+
Assert.Fail($"expected response {expected}, got status={__status} body={preview}");
156+
}
157+
}
158+
159+
public void AssertRedirectedTo(string expectedPath)
160+
{
161+
if (__status < 300L || __status >= 400L)
162+
{
163+
Assert.Fail($"expected a redirect, got status={__status} location={__location}");
164+
return;
165+
}
166+
if (!__location.Contains(expectedPath))
167+
{
168+
Assert.Fail($"expected Location to contain {expectedPath}, got {__location}");
169+
}
170+
}
171+
172+
// `AssertSelect` over the Dom primitive surface (below). Presence
173+
// check: the selector matches at least one node. The stub Dom is a
174+
// substring matcher, so this stays rough-but-effective for the
175+
// scaffold-blog HTML shapes; cardinality kwargs are best-effort
176+
// no-ops. A real engine tightens it without changing these sites.
177+
public void AssertSelect(string selector)
178+
{
179+
if (Dom.Select(Dom.Parse(__body), selector).Count == 0)
180+
{
181+
Assert.Fail($"expected body to match selector {selector}");
182+
}
183+
}
184+
185+
// `content` is nullable: a nullable column read (`assert_select "h1",
186+
// article.title`) is `string?`, and Rails compares the element's text
187+
// against it — nil reads as the empty string, matching `nil.to_s`.
188+
public void AssertSelect(string selector, string? content)
189+
{
190+
var nodes = Dom.Select(Dom.Parse(__body), selector);
191+
if (nodes.Count == 0)
192+
{
193+
Assert.Fail($"expected body to match selector {selector}");
194+
return;
195+
}
196+
if (!nodes.Any(n => Dom.Text(n).Contains(content ?? "")))
197+
{
198+
Assert.Fail($"expected text {content} under selector {selector}");
199+
}
200+
}
201+
202+
// `assert_select "h2", minimum: 1` — the cardinality kwargs arrive as
203+
// an options hash; presence is what the stub can honour.
204+
public void AssertSelect(string selector, Dictionary<string, object?> opts) =>
205+
AssertSelect(selector);
206+
207+
// `assert_select "#articles" do … end` — the nested assertions run
208+
// against the same body (the stub has no scoping).
209+
public void AssertSelect(string selector, Action body)
210+
{
211+
AssertSelect(selector);
212+
body();
213+
}
214+
}
215+
216+
// ── Dom primitive surface (the AssertSelect substrate) ─────────────
217+
//
218+
// The HTML-query contract AssertSelect lowers to, shared in shape with
219+
// the Ruby/Kotlin/Swift/TS/Python/Rust/Elixir twins (cross-target
220+
// contract in runtime/spinel/test/test_helper.rbs). Stub: the substring
221+
// matcher dressed as a Dom — Select fabricates one synthetic node (the
222+
// whole document) per fragment occurrence and Text returns it verbatim,
223+
// so presence / minimum / content checks degrade to exactly the
224+
// pre-contract behavior. The upgrade path is to swap these three methods
225+
// for a real HTML parser (AngleSharp) — real nodes, real CSS selectors —
226+
// touching only this class; the RoundhouseTestCase call sites stay put.
227+
public static class Dom
228+
{
229+
// Parse an HTML document. Stub: the document *is* its html string.
230+
public static string Parse(string html) => html;
231+
232+
// Nodes matching `selector` within `root` (a document or node). Stub:
233+
// one synthetic node (the root's html) per substring-fragment
234+
// occurrence.
235+
public static List<string> Select(string root, string selector)
236+
{
237+
var fragment = FragmentFor(selector);
238+
var nodes = new List<string>();
239+
if (fragment.Length == 0) return nodes;
240+
var from = 0;
241+
while (true)
242+
{
243+
var i = root.IndexOf(fragment, from, StringComparison.Ordinal);
244+
if (i < 0) break;
245+
nodes.Add(root);
246+
from = i + fragment.Length;
247+
}
248+
return nodes;
249+
}
250+
251+
// Concatenated descendant text of a node. Stub: the node verbatim.
252+
public static string Text(string node) => node;
253+
254+
// Loose selector → substring fragment (the stub's rule, replaced by a
255+
// real CSS engine on upgrade): "#id" → id="id", ".cls" → cls", "tag"
256+
// → <tag. Compound selectors take the first chunk.
257+
private static string FragmentFor(string selector)
258+
{
259+
var first = selector.Split(' ').FirstOrDefault() ?? selector;
260+
if (first.StartsWith("#", StringComparison.Ordinal)) return "id=\"" + first.Substring(1) + "\"";
261+
if (first.StartsWith(".", StringComparison.Ordinal)) return first.Substring(1) + "\"";
262+
return "<" + first;
263+
}
264+
}

scripts/smoke

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,11 +237,8 @@ case "$TARGET" in
237237
# case this floor exists to catch. Upgrade to a number if the
238238
# README's command ever grows a counting flag.
239239
MIN_TESTS=ran ;;
240-
csharp)
241-
# KNOWN GAP (#34): csharp emits no test project, so `dotnet test`
242-
# executes zero tests. Declared, warned about on every run, and
243-
# NOT silent. Set to 21 the moment csharp test emit lands.
244-
MIN_TESTS=0 ;;
240+
# (csharp's KNOWN-GAP floor of 0 is retired: it now emits `tests/` —
241+
# an xUnit project running the same 21 — and takes the default.)
245242
esac
246243
if [[ -n "${SMOKE_MIN_TESTS:-}" ]]; then
247244
MIN_TESTS="$SMOKE_MIN_TESTS"

0 commit comments

Comments
 (0)