|
| 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 | +} |
0 commit comments