diff --git a/docs/js/tag.md b/docs/js/tag.md index 71b62f7..765f58a 100644 --- a/docs/js/tag.md +++ b/docs/js/tag.md @@ -86,4 +86,4 @@ tagElement.addEventListener(webexpress.webapp.Event.TAG_ADDED_EVENT, (e) => { ## Read-only Mode -Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlRestTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits. +Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlDataTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits. diff --git a/docs/js/theme-selector.md b/docs/js/theme-selector.md index 0862c24..85cf01e 100644 --- a/docs/js/theme-selector.md +++ b/docs/js/theme-selector.md @@ -16,7 +16,7 @@ The `webexpress.webapp.DropdownTheme` is a REST-backed theme picker that extends ## Declarative Configuration -The control is rendered server-side by `ControlRestSelectionTheme`. Manual HTML usage is also supported: +The control is rendered server-side by `ControlDataSelectionTheme`. Manual HTML usage is also supported: |Attribute |Description |-------------------------|----------------------------------------------------------------- @@ -74,7 +74,7 @@ public sealed class ThemeApi : RestApiTheme } // 2. drop the selector onto a page - it is a standalone dropdown: -new ControlRestSelectionTheme("themeSelector") +new ControlDataSelectionTheme("themeSelector") { RestUri = _ => sitemapManager.GetUri(applicationContext) }; diff --git a/src/WebExpress.WebApp.Test/Config/webexpress.config.xml b/src/WebExpress.WebApp.Test/Config/webexpress.config.xml index d345069..d6ab679 100644 --- a/src/WebExpress.WebApp.Test/Config/webexpress.config.xml +++ b/src/WebExpress.WebApp.Test/Config/webexpress.config.xml @@ -6,10 +6,10 @@ - - 300 - 3000000000 - + + 300 + 3000000000 + de-DE diff --git a/src/WebExpress.WebApp.Test/JsTest/README.md b/src/WebExpress.WebApp.Test/JsTest/README.md new file mode 100644 index 0000000..ad46855 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/README.md @@ -0,0 +1,71 @@ +# Headless tests for the View, State and Service engine + +This folder contains the headless unit tests for the phase zero engine of the +View, State and Service architecture. The engine modules live in +`src/WebExpress.WebUI/Assets/js` (store, service, renderer, intent and +component) and are described in +`WebExpress.WebApp/docs/architecture/view-state-service.md`. + +The tests load the real, shipped engine modules through a Node `vm` context with +a minimal DOM stub, so they exercise the same files that the framework embeds, +not a copy. + +## Requirements + +Node 18 or newer. No npm packages are installed; the harness uses only the Node +standard library (`node:test`, `node:assert`, `node:vm`, `node:fs`). The Node +that ships with the Visual Studio "Node.js development" component works as well, +even though it is not added to the system PATH. + +## Running + +From this folder, when node is on the PATH: + +``` +node --test +``` + +Node discovers and runs every `*.test.mjs` file. The expected output ends with a +pass summary and an exit code of zero. + +When node is not on the PATH, for example when it ships only with Visual Studio, +use the helper script, which locates node automatically: + +``` +./run.ps1 +``` + +## Layout + +| File | Purpose +|-----------------------|----------------------------------------------------------------- +| `harness.mjs` | Loads the engine modules (and optional application modules) into an isolated context with host stubs. +| `dom-stub.mjs` | A minimal DOM used by the renderer and the component tests. +| `engine.test.mjs` | Unit tests for the store, the service, the renderer, the intents and the component. +| `list.model.test.mjs` | Unit tests for the REST list model helpers (phase one), including an end to end path through a service. +| `table.model.test.mjs` | Unit tests for the REST table model helpers (phase two), including the query and the put update through a service. +| `restform.model.test.mjs` | Unit tests for the REST form model helpers (phase two): request shaping, response classification and error normalisation. +| `restwizard.model.test.mjs` | Unit tests for the REST wizard model helpers (phase two): step request shaping, cache decision and last step detection. +| `tab.model.test.mjs` | Unit tests for the REST tab model helpers (phase two): the list, create, reorder and close operations through a service. +| `comment.model.test.mjs` | Unit tests for the REST comment model helpers (phase two): endpoint url and path building and category normalisation. +| `kanban.model.test.mjs` | Unit tests for the REST kanban model helpers (phase two): board normalisation and the load and persist operations through a service. +| `watcher.model.test.mjs` | Unit tests for the watcher model helpers: list normalisation, user search url, candidate filtering, removal helpers and the load, add and remove operations through a service. +| `scrum.backlog.model.test.mjs` | Unit tests for the scrum backlog model helpers: board and sprint normalisation, sprint and item paths, rank bodies, the group filter and sort, the rank rewrite, the active sprint crossing and the persist operations through a service. +| `tile.model.test.mjs` | Unit tests for the REST tile model helpers: the page slice, the total reduction, the item to tile mapping and the load and persist operations through a service. +| `dashboard.model.test.mjs` | Unit tests for the REST dashboard model helpers: the column and widget normalisation and the load and persist operations through a service. +| `workflow.editor.model.test.mjs` | Unit tests for the workflow editor model helpers: the meta and catalog normalisation, the wire format read with its aliases and the wire payload build, plus the load and persist operations through a service. +| `comment.composer.model.test.mjs` | Unit tests for the comment composer model helpers: the categories url, the categories normalisation and the label parsing, plus the categories load and the comment post through a service. +| `input.unique.model.test.mjs` | Unit tests for the unique input model helpers: the header parsing, the request body shaping and the availability extraction with its field and status and code heuristics, plus a uniqueness check through the shared request. +| `selection.model.test.mjs` | Unit tests for the REST selection model helpers: the request url and init shaping and the response item mapping, plus a search through the shared request. +| `input.selection.model.test.mjs` | Unit tests for the REST input selection model helpers: the request url and init shaping and the item mapping with its data and aria tuples, plus a search through the shared request. +| `dropdown.theme.model.test.mjs` | Unit tests for the theme dropdown model helpers: the theme item mapping and the theme list normalisation, plus a themes load through the shared request. + +## Relationship to the .NET test suite + +The .NET test `WebExpress.WebUI.Test/WebInclude/UnitTestEngineAssets` verifies +that the engine modules are embedded as resources and registered in the correct +load order through the `IncludeJavaScript` Asset attributes. That test runs in +the normal xUnit suite and guards the build pipeline. The headless tests in this +folder guard the runtime behaviour of the engine and are intended to run wherever +Node is available, for example on a developer machine or in continuous +integration. diff --git a/src/WebExpress.WebApp.Test/JsTest/UnitTestJavaScript.cs b/src/WebExpress.WebApp.Test/JsTest/UnitTestJavaScript.cs new file mode 100644 index 0000000..63de1f9 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/UnitTestJavaScript.cs @@ -0,0 +1,251 @@ +using System.Diagnostics; + +namespace WebExpress.WebApp.Test.JsTest +{ + /// + /// Runs the headless JavaScript tests under JsTest as part of the regular + /// xUnit run. The JavaScript tests exercise the shipped client sources and + /// therefore need a JavaScript engine; they are executed through the + /// Node.js test runner. Node.js is an optional, external prerequisite, so + /// a missing or outdated installation skips the test with a warning + /// instead of failing the build. The lookup is platform independent: an + /// explicit WEBEXPRESS_NODE override, the PATH, and well-known install + /// locations (including the Node.js bundled with Visual Studio) are + /// probed in that order. + /// + public class UnitTestJavaScript + { + /// + /// The minimum Node.js major version required by the test harness. + /// + private const int MinimumNodeMajorVersion = 18; + + /// + /// The maximum time the Node.js test runner may take before the run + /// is treated as failed. + /// + private static readonly TimeSpan _timeout = TimeSpan.FromMinutes(2); + + private readonly ITestOutputHelper _output; + + /// + /// Initializes a new instance of the class. + /// + /// The xUnit output sink for the runner log. + public UnitTestJavaScript(ITestOutputHelper output) + { + _output = output; + } + + /// + /// Executes every JsTest/*.test.mjs file through the Node.js test + /// runner and fails with the captured runner output when a JavaScript + /// test fails. Skips with a warning when Node.js is unavailable. + /// + [Fact] + public void RunJavaScriptTests() + { + var node = FindNodeExecutable(); + if (node == null) + { + Assert.Skip("Warning: Node.js was not found (checked WEBEXPRESS_NODE, the PATH and " + + "well-known install locations). The JavaScript tests under JsTest were skipped."); + } + + var major = GetNodeMajorVersion(node); + if (major < MinimumNodeMajorVersion) + { + Assert.Skip($"Warning: Node.js at '{node}' is unusable or too old " + + $"(major version {major}, required {MinimumNodeMajorVersion} or newer). " + + "The JavaScript tests under JsTest were skipped."); + } + + var directory = GetJsTestDirectory(); + var files = Directory.GetFiles(directory, "*.test.mjs").OrderBy(f => f).ToArray(); + Assert.True(files.Length > 0, $"No *.test.mjs files were found in '{directory}'."); + + var (exitCode, log) = RunNode(node, directory, ["--test", .. files]); + + _output.WriteLine($"node: {node}"); + _output.WriteLine(log); + Assert.True(exitCode == 0, + $"The JavaScript tests failed (node exit code {exitCode}).{Environment.NewLine}{log}"); + } + + /// + /// Locates the Node.js executable in a platform independent way. The + /// WEBEXPRESS_NODE environment variable wins so CI systems can pin a + /// specific runtime; otherwise the PATH and the conventional install + /// locations of the current platform are probed. + /// + /// The full path of the executable, or null when not found. + private static string FindNodeExecutable() + { + var overridePath = Environment.GetEnvironmentVariable("WEBEXPRESS_NODE"); + if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } + + var fileName = OperatingSystem.IsWindows() ? "node.exe" : "node"; + var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var entry in pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(entry.Trim(), fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return EnumerateWellKnownLocations().FirstOrDefault(File.Exists); + } + + /// + /// Yields the conventional Node.js install locations of the current + /// platform. On Windows this includes the private Node.js that Visual + /// Studio ships for its build tooling, which is sufficient for the + /// test runner even when no standalone Node.js is installed. + /// + /// Candidate paths of the executable. + private static IEnumerable EnumerateWellKnownLocations() + { + if (!OperatingSystem.IsWindows()) + { + yield return "/usr/local/bin/node"; + yield return "/usr/bin/node"; + yield return "/opt/homebrew/bin/node"; + yield break; + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + yield return Path.Combine(programFiles, "nodejs", "node.exe"); + yield return Path.Combine(programFilesX86, "nodejs", "node.exe"); + yield return Path.Combine(localAppData, "Programs", "nodejs", "node.exe"); + + var visualStudioRoot = Path.Combine(programFiles, "Microsoft Visual Studio"); + if (!Directory.Exists(visualStudioRoot)) + { + yield break; + } + foreach (var versionDirectory in Directory.EnumerateDirectories(visualStudioRoot)) + { + foreach (var editionDirectory in Directory.EnumerateDirectories(versionDirectory)) + { + yield return Path.Combine(editionDirectory, + "MSBuild", "Microsoft", "VisualStudio", "NodeJs", "node.exe"); + } + } + } + + /// + /// Determines the major version of a Node.js executable by invoking + /// "node --version". + /// + /// The path of the executable. + /// The major version, or -1 when the probe fails. + private static int GetNodeMajorVersion(string node) + { + try + { + var info = new ProcessStartInfo + { + FileName = node, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + info.ArgumentList.Add("--version"); + + using var process = Process.Start(info); + var version = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(10000); + + // the output has the form "v24.12.0" + var major = version.TrimStart('v').Split('.')[0]; + return int.TryParse(major, out var value) ? value : -1; + } + catch + { + return -1; + } + } + + /// + /// Locates the JsTest source folder by walking up from the test + /// assembly location to the project directory. The tests must run + /// from the source tree because the harness resolves the shipped + /// JavaScript assets relative to its own location. + /// + /// The full path of the JsTest folder. + private static string GetJsTestDirectory() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, "JsTest"); + if (Directory.Exists(candidate)) + { + return candidate; + } + directory = directory.Parent; + } + throw new DirectoryNotFoundException( + $"The JsTest folder was not found above '{AppContext.BaseDirectory}'."); + } + + /// + /// Runs the Node.js executable with the given arguments and captures + /// the combined output. + /// + /// The path of the executable. + /// The working directory of the run. + /// The command line arguments. + /// The exit code and the combined stdout/stderr log. + private static (int ExitCode, string Log) RunNode(string node, string workingDirectory, IEnumerable arguments) + { + var info = new ProcessStartInfo + { + FileName = node, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in arguments) + { + info.ArgumentList.Add(argument); + } + + using var process = Process.Start(info); + // drain both streams concurrently so neither pipe can fill up and + // deadlock the runner + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)_timeout.TotalMilliseconds)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // the process ended between the timeout and the kill + } + return (-1, $"The Node.js test runner timed out after {_timeout.TotalSeconds:0} seconds."); + } + process.WaitForExit(); + + var log = string.Join(Environment.NewLine, + new[] { stdout.GetAwaiter().GetResult(), stderr.GetAwaiter().GetResult() } + .Where(part => !string.IsNullOrWhiteSpace(part))); + return (process.ExitCode, log); + } + } +} diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs new file mode 100644 index 0000000..59e1943 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs @@ -0,0 +1,77 @@ +/** + * Headless unit tests for the comment composer model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.comment.composer.js: + * the legacy descriptor, the categories url, the categories normalisation and + * the label parsing, plus an end to end path that loads the categories through + * the shared request and posts a new comment with the service create. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.comment.composer.model.js")] }, + options + )); +} + +test("categories url appends the segment with a single slash", () => { + const { wxapp } = load(); + assert.equal(wxapp.commentComposerModel.categoriesUrl("/api/c"), "/api/c/categories"); + assert.equal(wxapp.commentComposerModel.categoriesUrl("/api/c/"), "/api/c/categories"); + assert.equal(wxapp.commentComposerModel.categoriesUrl(""), "/categories"); +}); + +test("normalize categories accepts an array or an object keyed by id", () => { + const { wxapp } = load(); + assert.deepEqual( + wxapp.commentComposerModel.normalizeCategories([{ id: "q", label: "Q" }, { label: "noid" }]), + { q: { id: "q", label: "Q" } } + ); + + const obj = { a: { id: "a" } }; + assert.equal(wxapp.commentComposerModel.normalizeCategories(obj), obj); + assert.deepEqual(wxapp.commentComposerModel.normalizeCategories(null), {}); +}); + +test("parse labels splits, trims and drops empty entries", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.commentComposerModel.parseLabels("a, b ,,c"), ["a", "b", "c"]); + assert.deepEqual(wxapp.commentComposerModel.parseLabels(""), []); + assert.deepEqual(wxapp.commentComposerModel.parseLabels(null), []); +}); + +test("model loads categories and posts a comment through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [{ id: "q", label: "Q" }] }; + } + return { ok: true, status: 200, json: async () => ({ id: "c1" }) }; + }); + + // categories are loaded through the shared request from the categories url + const catRes = await wxapp.ServiceRegistry.request( + wxapp.commentComposerModel.categoriesUrl("/api/comments"), + { headers: { "Accept": "application/json" } } + ); + assert.equal(calls[0].url.endsWith("/categories"), true); + assert.deepEqual(wxapp.commentComposerModel.normalizeCategories(catRes.data), { q: { id: "q", label: "Q" } }); + + // the new comment is posted through the service create + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/comments", method: "GET", updateMethod: "PUT" }); + const created = await service.create({ body: "hi", category: "q", labels: wxapp.commentComposerModel.parseLabels("x, y") }); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { body: "hi", category: "q", labels: ["x", "y"] }); + assert.equal(created.data.id, "c1"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs new file mode 100644 index 0000000..905cde7 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs @@ -0,0 +1,57 @@ +/** + * Headless tests for the comment composer control after it was lifted onto the + * Data base (View, State and Service). The composer keeps its imperative form + * flow; the lift gives it the service map - a configured island service is + * preferred over the legacy descriptor - and the Data lifecycle teardown that + * aborts the service. The categories are preset so the constructor performs no + * load. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.comment.composer.model.js"), + webappAsset("webexpress.webapp.comment.composer.js") + ] + }, + options + )); +} + +const PRESET_CATEGORIES = JSON.stringify([{ id: "general", i18n: "", color: "#000", bg: "#fff" }]); + +test("comment composer extends the data base and resolves its service", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/comments/INC-1", method: "GET", updateMethod: "PUT" }); + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentComposerCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); + assert.ok(ctrl.useService("data")); +}); + +test("comment composer destroy tears down without throwing", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/comments/INC-1", method: "GET", updateMethod: "PUT" }); + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentComposerCtrl(element); + + assert.doesNotThrow(() => ctrl.destroy()); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs new file mode 100644 index 0000000..7a2fa63 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs @@ -0,0 +1,97 @@ +/** + * Headless unit tests for the REST comment model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.comment.js, + * namely the endpoint url and path building and the category normalisation, + * plus an end to end path that drives the list, edit, like and delete + * operations through a service to confirm the urls survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.comment.model.js")] }, + options + )); +} + +test("normalize categories accepts arrays and objects", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.commentModel.normalizeCategories([{ id: "a", color: "#1" }, { id: "b" }]), + { a: { id: "a", color: "#1" }, b: { id: "b" } } + ); + assert.deepEqual(wxapp.commentModel.normalizeCategories({ a: { id: "a" } }), { a: { id: "a" } }); + assert.deepEqual(wxapp.commentModel.normalizeCategories(null), {}); +}); + +test("categories url joins with a single slash", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.categoriesUrl("/api/c"), "/api/c/categories"); + assert.equal(wxapp.commentModel.categoriesUrl("/api/c/"), "/api/c/categories"); +}); + +test("build users url appends encoded comma separated ids", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users", ["a", "b"]), "/api/users?ids=a,b"); + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users?x=1", ["a"]), "/api/users?x=1&ids=a"); + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users", ["a b"]), "/api/users?ids=a%20b"); +}); + +test("comment path and sub path encode the id", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.commentPath("42"), "/42"); + assert.equal(wxapp.commentModel.commentPath("x/y"), "/x%2Fy"); + assert.equal(wxapp.commentModel.commentSubPath("42", "likes"), "/42/likes"); +}); + +test("model drives the comment operations through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ([{ id: "1" }]) }; + } + if (method === "PUT") { + return { ok: true, status: 200, json: async () => ({ id: "1", body: "edited" }) }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ likes: ["u1"] }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/c", method: "GET", updateMethod: "PUT" }); + + const list = await service.request("/api/c", { method: "GET", headers: { "Accept": "application/json" } }); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(list.data, [{ id: "1" }]); + + const edit = await service.update({ body: "edited" }, { path: wxapp.commentModel.commentPath("1") }); + assert.equal(calls[1].method, "PUT"); + assert.match(calls[1].url, /\/api\/c\/1$/); + assert.deepEqual(JSON.parse(calls[1].body), { body: "edited" }); + assert.equal(edit.data.body, "edited"); + + const like = await service.create({ userId: "u1" }, { path: wxapp.commentModel.commentSubPath("1", "likes") }); + assert.equal(calls[2].method, "POST"); + assert.match(calls[2].url, /\/api\/c\/1\/likes$/); + assert.deepEqual(like.data.likes, ["u1"]); + + const removed = await service.remove({ path: wxapp.commentModel.commentPath("1") }); + assert.equal(calls[3].method, "DELETE"); + assert.match(calls[3].url, /\/api\/c\/1$/); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs new file mode 100644 index 0000000..35d6069 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs @@ -0,0 +1,92 @@ +/** + * Headless tests for the threaded comment control after it was lifted onto the + * Component base (View, State and Service). The control keeps its own imperative + * render flow; the lift gives it the component store (UI state plus the seedable + * comments), the service map and lifecycle. The tests assert that it extends + * Component, seeds its comments from the wx-state island and skips the + * comment load in that case, and otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.comment.model.js"), + webappAsset("webexpress.webapp.comment.js") + ] + }, + options + )); +} + +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const PRESET_CATEGORIES = JSON.stringify([{ id: "general", i18n: "", color: "#000", bg: "#fff" }]); + +const SEED_COMMENT = { + id: "c1", + author: { id: "u1", name: "Ann", initials: "AN", color: "#abc" }, + category: "general", + labels: [], + body: "

hi

", + created: "2026-01-01T00:00:00Z", + likes: [], + reactions: {}, + replies: [], + pinned: false +}; + +test("comment extends the component base", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] })); + + const element = createElement("div"); + const ctrl = new wxapp.CommentCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("comment seeds its comments from the wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/comments", method: "GET", updateMethod: "PUT" }); + element.dataset.categories = PRESET_CATEGORIES; + appendStateIsland(document, element, { comments: [SEED_COMMENT] }); + + const ctrl = new wxapp.CommentCtrl(element); + + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "c1"); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("comment loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/comments", method: "GET", updateMethod: "PUT" }); + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentCtrl(element); + + await settle(); + assert.equal(fetchCount, 1); + assert.equal(ctrl.value.length, 0); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs new file mode 100644 index 0000000..10a6bcf --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs @@ -0,0 +1,79 @@ +/** + * Headless unit tests for the REST dashboard model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.dashboard.js: the + * legacy descriptor and the column and widget normalisation, plus an end to end + * path that loads the dashboard with a query and persists the layout state with + * an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.dashboard.model.js")] }, + options + )); +} + +test("normalize columns maps columns and widgets with defaults", () => { + const { wxapp } = load(); + const cols = wxapp.dashboardModel.normalizeColumns({ + columns: [ + { id: "c1", widgets: [{ id: "w1" }, { id: "w2", removable: false, movable: false, html: "", params: { a: 1 } }] }, + { id: "c2", label: "L", size: "2fr" } + ] + }); + + assert.equal(cols.length, 2); + assert.equal(cols[0].size, "1fr"); + assert.equal(cols[1].size, "2fr"); + assert.equal(cols[1].label, "L"); + + assert.equal(cols[0].widgets.length, 2); + assert.equal(cols[0].widgets[0].removable, true); + assert.equal(cols[0].widgets[0].movable, true); + assert.equal(cols[0].widgets[1].removable, false); + assert.equal(cols[0].widgets[1].movable, false); + assert.equal(cols[0].widgets[1].html, ""); + assert.deepEqual(cols[0].widgets[1].params, { a: 1 }); + assert.equal(cols[0].widgets[0].instanceId.startsWith("wx_inst_c1_0_"), true); +}); + +test("normalize columns returns null when the response carries no columns", () => { + const { wxapp } = load(); + assert.equal(wxapp.dashboardModel.normalizeColumns({}), null); + assert.equal(wxapp.dashboardModel.normalizeColumns(null), null); +}); + +test("model loads the dashboard and persists the state through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ columns: [{ id: "c1", widgets: [{ id: "w1" }] }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/dash", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const cols = wxapp.dashboardModel.normalizeColumns(loaded.data); + assert.equal(cols[0].id, "c1"); + assert.equal(cols[0].widgets[0].id, "w1"); + + const saved = await service.update({ action: "move" }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { action: "move" }); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs new file mode 100644 index 0000000..60c1ae5 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs @@ -0,0 +1,244 @@ +/** + * Minimal DOM stub for the headless engine tests. + * + * It implements only the surface that the View, State and Service engine uses, + * which is element creation, child node manipulation, attributes, class list, + * dataset, a simple style object, text content and event listeners. It is not + * a browser and it is not jsdom. It exists so that the renderer and the + * component can be exercised in Node without a browser. + */ + +class TextNode { + constructor(text) { + this.nodeType = 3; + this.parentNode = null; + this._text = String(text); + } + get nodeName() { return "#text"; } + get textContent() { return this._text; } + set textContent(value) { this._text = String(value); } + get nodeValue() { return this._text; } + set nodeValue(value) { this._text = String(value); } + get data() { return this._text; } + set data(value) { this._text = String(value); } +} + +class ClassList { + constructor(owner) { this._owner = owner; } + add(name) { this._owner._classes.add(name); } + remove(name) { this._owner._classes.delete(name); } + contains(name) { return this._owner._classes.has(name); } + toggle(name, force) { + const has = this._owner._classes.has(name); + const shouldHave = force === undefined ? !has : !!force; + if (shouldHave) { this._owner._classes.add(name); } else { this._owner._classes.delete(name); } + return shouldHave; + } +} + +class Element { + constructor(tag) { + this.nodeType = 1; + this.tagName = String(tag).toUpperCase(); + this.childNodes = []; + this.parentNode = null; + this._attrs = new Map(); + this._classes = new Set(); + this._listeners = {}; + this._id = null; + this._innerHTML = undefined; + this.dataset = {}; + this.style = makeStyle(); + this.value = ""; + this.checked = false; + } + + get firstChild() { return this.childNodes[0] || null; } + + get nodeName() { return this.tagName; } + + get innerText() { return this.textContent; } + set innerText(value) { this.textContent = value; } + + get id() { return this._id; } + set id(value) { this._id = value == null ? null : String(value); } + + get className() { return Array.from(this._classes).join(" "); } + set className(value) { this._classes = new Set(String(value || "").split(/\s+/).filter(Boolean)); } + + get classList() { return new ClassList(this); } + + appendChild(node) { + if (node.parentNode) { node.parentNode.removeChild(node); } + this.childNodes.push(node); + node.parentNode = this; + return node; + } + + insertBefore(node, reference) { + if (node.parentNode) { node.parentNode.removeChild(node); } + if (reference == null) { + this.childNodes.push(node); + node.parentNode = this; + return node; + } + const index = this.childNodes.indexOf(reference); + if (index === -1) { this.childNodes.push(node); } else { this.childNodes.splice(index, 0, node); } + node.parentNode = this; + return node; + } + + prepend(node) { + this.insertBefore(node, this.childNodes[0] || null); + } + + removeChild(node) { + const index = this.childNodes.indexOf(node); + if (index !== -1) { this.childNodes.splice(index, 1); } + node.parentNode = null; + return node; + } + + replaceChildren(...nodes) { + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + for (const node of nodes) { this.appendChild(node); } + } + + replaceChild(newNode, oldNode) { + if (newNode.parentNode) { newNode.parentNode.removeChild(newNode); } + const index = this.childNodes.indexOf(oldNode); + if (index !== -1) { + this.childNodes.splice(index, 1, newNode); + newNode.parentNode = this; + oldNode.parentNode = null; + } + return oldNode; + } + + setAttribute(name, value) { + if (name === "id") { this._id = String(value); return; } + this._attrs.set(name, String(value)); + } + getAttribute(name) { + if (name === "id") { return this._id; } + if (name === "class") { return this.className; } + return this._attrs.has(name) ? this._attrs.get(name) : null; + } + hasAttribute(name) { + if (name === "id") { return this._id != null; } + return this._attrs.has(name); + } + removeAttribute(name) { + if (name === "id") { this._id = null; return; } + this._attrs.delete(name); + } + + querySelector() { return null; } + querySelectorAll() { return []; } + closest() { return null; } + + get textContent() { + return this.childNodes.map((n) => (n.nodeType === 3 ? n._text : n.textContent)).join(""); + } + set textContent(value) { + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + if (value != null && value !== "") { this.appendChild(new TextNode(String(value))); } + } + + get innerHTML() { return this._innerHTML !== undefined ? this._innerHTML : ""; } + set innerHTML(value) { + this._innerHTML = String(value); + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + } + + addEventListener(type, handler) { + (this._listeners[type] || (this._listeners[type] = new Set())).add(handler); + } + removeEventListener(type, handler) { + if (this._listeners[type]) { this._listeners[type].delete(handler); } + } + dispatchEvent(event) { + const set = this._listeners[event.type]; + if (set) { Array.from(set).forEach((fn) => fn(event)); } + return true; + } + + focus() { } +} + +/** + * Builds a simple style object that accepts both cssText and individual + * property assignment. + * @returns {object} The style object. + */ +function makeStyle() { + const style = {}; + let cssText = ""; + Object.defineProperty(style, "cssText", { + get() { return cssText; }, + set(value) { cssText = String(value); }, + enumerable: false + }); + return style; +} + +/** + * Finds an element with the given id in a subtree. + * @param {Element} node - The subtree root. + * @param {string} id - The id to find. + * @returns {Element|null} The element or null. + */ +function findById(node, id) { + if (node.nodeType === 1 && node.id === id) { + return node; + } + for (const child of node.childNodes || []) { + const found = findById(child, id); + if (found) { + return found; + } + } + return null; +} + +/** + * Creates a fresh document stub. The document carries a body, an id lookup + * over the body subtree and working event listeners, so the engine's global + * channels (for example the service error event and the component mount + * event) can be exercised. + * @returns {object} The document stub. + */ +export function createDocument() { + const body = new Element("body"); + const listeners = {}; + + return { + baseURI: "http://localhost/", + readyState: "complete", + cookie: "", + body, + createElement(tag) { return new Element(tag); }, + createElementNS(namespace, tag) { return new Element(tag); }, + createDocumentFragment() { return new Element("#document-fragment"); }, + createTextNode(text) { return new TextNode(text); }, + getElementById(id) { return findById(body, String(id)); }, + querySelector() { return null; }, + querySelectorAll() { return []; }, + addEventListener(type, handler) { + (listeners[type] || (listeners[type] = new Set())).add(handler); + }, + removeEventListener(type, handler) { + if (listeners[type]) { listeners[type].delete(handler); } + }, + dispatchEvent(event) { + const set = listeners[event.type]; + if (set) { Array.from(set).forEach((fn) => fn(event)); } + return true; + } + }; +} + +export { Element, TextNode }; diff --git a/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs new file mode 100644 index 0000000..8a9c5d3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs @@ -0,0 +1,74 @@ +/** + * Headless unit tests for the theme dropdown model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.dropdown.theme.js: + * the theme item mapping and the theme list normalisation, plus an end to end + * path that loads the themes through the shared request and normalises them. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.dropdown.theme.model.js")] }, + options + )); +} + +test("map item projects the menu item with a void uri and text aliases", () => { + const { wxapp } = load(); + const item = wxapp.dropdownThemeModel.mapItem({ id: 7, name: "Dark", icon: "fa" }); + assert.equal(item.id, "7"); + assert.equal(item.text, "Dark"); + assert.equal(item.uri, "javascript:void(0);"); + assert.equal(item.icon, "fa"); + assert.deepEqual(item.data, []); + + const fallback = wxapp.dropdownThemeModel.mapItem({ id: "x" }); + assert.equal(fallback.text, "x"); + + const empty = wxapp.dropdownThemeModel.mapItem(null); + assert.equal(empty.id, null); + assert.equal(empty.text, ""); +}); + +test("normalize themes maps the items and reads the selected id", () => { + const { wxapp } = load(); + const themes = wxapp.dropdownThemeModel.normalizeThemes({ + items: [{ id: "a", name: "A" }, { id: "b", name: "B" }], + selected: "b" + }); + assert.deepEqual(themes.items.map(i => i.id), ["a", "b"]); + assert.equal(themes.items[0].text, "A"); + assert.equal(themes.selected, "b"); + + const none = wxapp.dropdownThemeModel.normalizeThemes({}); + assert.deepEqual(none.items, []); + assert.equal(none.selected, null); + + const blank = wxapp.dropdownThemeModel.normalizeThemes({ items: [{ id: "a" }], selected: "" }); + assert.equal(blank.selected, null); +}); + +test("model loads and normalises the themes through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "light", name: "Light" }], selected: "light" }) }; + }); + + const res = await wxapp.ServiceRegistry.request("/api/themes", { method: "GET" }); + assert.equal(calls[0].method, "GET"); + + const themes = wxapp.dropdownThemeModel.normalizeThemes(res.data); + assert.equal(themes.items[0].id, "light"); + assert.equal(themes.items[0].text, "Light"); + assert.equal(themes.selected, "light"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs new file mode 100644 index 0000000..c7f42bf --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs @@ -0,0 +1,759 @@ +/** + * Headless unit tests for the View, State and Service engine (phase zero). + * + * Run with Node 18 or newer from the jstest folder: + * node --test + * + * The tests load the real engine modules from Assets/js through a vm context + * with a minimal DOM stub, so they exercise the shipped code rather than a copy. + */ + +import { test } from "node:test"; +// loose assert: objects produced inside the engine's vm context have a +// different Object.prototype than this test realm, so deepStrictEqual would +// reject structurally equal objects. Loose deepEqual compares by structure. +import assert from "node:assert"; +import { loadEngine, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +// Store + +test("store applies a shallow patch and notifies once after flush", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1, b: 2 }); + + let calls = 0; + let last = null; + store.subscribe((state) => { calls += 1; last = state; }); + + store.setState({ a: 10 }); + store.setState({ b: 20 }); + store.flush(); + + assert.equal(calls, 1); + assert.deepEqual(last, { a: 10, b: 20 }); + assert.equal(store.getState().a, 10); +}); + +test("store does not notify when nothing changes", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1 }); + + let calls = 0; + store.subscribe(() => { calls += 1; }); + + store.setState({ a: 1 }); + store.flush(); + + assert.equal(calls, 0); +}); + +test("store watch fires only when the selected slice changes", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1, b: 1 }); + + let aCalls = 0; + store.watch((state) => state.a, () => { aCalls += 1; }); + + store.setState({ b: 2 }); + store.flush(); + assert.equal(aCalls, 0); + + store.setState({ a: 5 }); + store.flush(); + assert.equal(aCalls, 1); +}); + +test("store registry reference counts shared stores", () => { + const { wxapp } = loadEngine(); + + const first = wxapp.StoreRegistry.acquire("x", { n: 0 }); + const second = wxapp.StoreRegistry.acquire("x"); + assert.equal(first, second); + + wxapp.StoreRegistry.release("x"); + assert.equal(wxapp.StoreRegistry.get("x"), first); + + wxapp.StoreRegistry.release("x"); + assert.equal(wxapp.StoreRegistry.get("x"), null); +}); + +// Service + +test("rest service maps parameters and normalises a success", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [1, 2, 3], total: 3 }) }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + method: "GET", + query: { search: "q", page: "p" }, + response: { items: "items", total: "total" } + }); + + const result = await service.query({ search: "abc", page: 2 }); + + assert.equal(result.ok, true); + assert.deepEqual(result.data, { items: [1, 2, 3], total: 3 }); + assert.match(capturedUrl, /\/api\/orders\?/); + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /p=2/); + assert.deepEqual(service.project(result.data), { items: [1, 2, 3], total: 3 }); +}); + +test("rest service normalises an http error", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ ok: false, status: 404 })); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.query({}); + + assert.equal(result.ok, false); + assert.equal(result.error.kind, "http"); + assert.equal(result.error.status, 404); +}); + +test("rest service returns an empty body for a 204 delete", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ ok: true, status: 204 })); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.remove({ path: "/42" }); + + assert.equal(result.ok, true); + assert.equal(result.data, null); + assert.equal(result.status, 204); +}); + +test("rest service normalises a network error", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => { throw new TypeError("boom"); }); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.query({}); + + assert.equal(result.ok, false); + assert.equal(result.error.kind, "network"); +}); + +test("service registry builds services from a wx-service island element", () => { + const { wxapp, createElement, document } = loadEngine(); + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/x" }); + + const services = wxapp.ServiceRegistry.fromElement(element); + + assert.ok(services.data); + assert.equal(typeof services.data.query, "function"); + assert.equal(services.data.baseUri, "/api/x"); + // the island is consumed on the read + assert.equal(element.childNodes.length, 0); +}); + +test("service registry parses the island mappings and policies", () => { + const { wxapp, createElement, document } = loadEngine(); + const element = createElement("div"); + appendServiceIsland(document, element, { + name: "data", + kind: "rest", + baseUri: "/api/orders", + method: "GET", + updateMethod: "PUT", + query: { search: "q", page: "p" }, + response: { items: "items" }, + headers: { "X-Api-Version": "1" }, + errors: { "404": "webexpress.webapp:error.notfound" }, + retry: { count: 2, delayMs: 300 } + }); + + const services = wxapp.ServiceRegistry.fromElement(element); + const descriptor = services.data._descriptor; + + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); + assert.deepEqual(descriptor.query, { search: "q", page: "p" }); + assert.deepEqual(descriptor.response, { items: "items" }); + assert.deepEqual(descriptor.headers, { "X-Api-Version": "1" }); + assert.deepEqual(descriptor.errors, { "404": "webexpress.webapp:error.notfound" }); + assert.deepEqual(descriptor.retry, { count: 2, delayMs: 300 }); +}); + +test("rest service request parses json by content type and passes init through", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedInit = null; + setFetch(async (url, init) => { + capturedInit = init; + return { + ok: true, + status: 200, + headers: { get: (h) => (h === "content-type" ? "application/json" : null) }, + json: async () => ({ a: 1 }) + }; + }); + + const service = new wxapp.RestService({ baseUri: "/x" }); + const result = await service.request("/api/form?id=1", { method: "GET", credentials: "same-origin" }); + + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.data.a, 1); + assert.equal(capturedInit.method, "GET"); + assert.equal(capturedInit.credentials, "same-origin"); +}); + +test("rest service request returns the body on a 400 for inspection", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ + ok: false, + status: 400, + headers: { get: () => "application/json" }, + json: async () => ({ errors: { name: "required" } }) + })); + + const service = new wxapp.RestService({ baseUri: "/x" }); + const result = await service.request("/api/form", { method: "POST" }); + + assert.equal(result.ok, false); + assert.equal(result.status, 400); + assert.equal(result.data.errors.name, "required"); +}); + +test("service registry request routes one off calls through a shared service", async () => { + const { wxapp, setFetch } = loadEngine(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { + ok: true, + status: 200, + headers: { get: (h) => (h === "content-type" ? "application/json" : null) }, + json: async () => ({ ok: true }) + }; + }); + + const first = await wxapp.ServiceRegistry.request("/api/themes", { method: "GET" }); + const second = await wxapp.ServiceRegistry.request("/api/themes", { method: "PUT", body: "{}" }); + + assert.equal(first.ok, true); + assert.equal(first.data.ok, true); + assert.equal(calls[0].url, "/api/themes"); + assert.equal(calls[1].method, "PUT"); + assert.equal(wxapp.ServiceRegistry._shared, wxapp.ServiceRegistry._shared); + assert.ok(wxapp.ServiceRegistry._shared, "the shared service is created lazily and reused"); +}); + +// Renderer + +test("renderer creates elements and text", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("span", { class: "a" }, "hello"), h("b", null, "x")]); + + assert.equal(root.childNodes.length, 2); + assert.equal(root.childNodes[0].tagName, "SPAN"); + assert.equal(root.childNodes[0].className, "a"); + assert.equal(root.childNodes[0].textContent, "hello"); + assert.equal(root.childNodes[1].tagName, "B"); +}); + +test("renderer updates props and text in place", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("span", { class: "a" }, "hi")]); + const span = root.childNodes[0]; + + wxapp.Renderer.patch(root, [h("span", { class: "b", "data-x": "1" }, "ho")]); + + assert.equal(root.childNodes[0], span); + assert.equal(span.className, "b"); + assert.equal(span.getAttribute("data-x"), "1"); + assert.equal(span.textContent, "ho"); +}); + +test("renderer reorders keyed nodes and preserves identity", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")]); + const a = root.childNodes[0]; + const b = root.childNodes[1]; + const c = root.childNodes[2]; + + wxapp.Renderer.patch(root, [h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")]); + + assert.equal(root.childNodes.length, 3); + assert.equal(root.childNodes[0], c); + assert.equal(root.childNodes[1], a); + assert.equal(root.childNodes[2], b); +}); + +test("renderer keep flag preserves a nested subtree", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("div", { class: "host", keep: true })]); + const host = root.childNodes[0]; + + const nested = createElement("span"); + host.appendChild(nested); + assert.equal(host.childNodes.length, 1); + + wxapp.Renderer.patch(root, [h("div", { class: "host2", keep: true })]); + + assert.equal(root.childNodes[0], host); + assert.equal(host.className, "host2"); + assert.equal(host.childNodes.length, 1); + assert.equal(host.childNodes[0], nested); +}); + +test("renderer removes stale nodes", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("li", null, "1"), h("li", null, "2"), h("li", null, "3")]); + assert.equal(root.childNodes.length, 3); + + wxapp.Renderer.patch(root, [h("li", null, "1")]); + assert.equal(root.childNodes.length, 1); + assert.equal(root.childNodes[0].textContent, "1"); +}); + +// Intents + +test("intent reducer applies a patch to the store", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ count: 0 }); + wxapp.Intents.register("inc", { reduce: (state, payload) => ({ count: state.count + (payload || 1) }) }); + + wxapp.Intents.dispatch("inc", { store, payload: 5 }); + + assert.equal(store.getState().count, 5); +}); + +test("intent effect runs and can dispatch a follow up", () => { + const { wxapp } = loadEngine(); + let effectRan = false; + + wxapp.Intents.register("load", { + reduce: () => ({ loading: true }), + effect: (context) => { effectRan = true; context.dispatch("done", { ok: true }); } + }); + wxapp.Intents.register("done", { reduce: (state, payload) => ({ loading: false, ok: payload.ok }) }); + + const store = new wxapp.Store({ loading: false }); + wxapp.Intents.dispatch("load", { store }); + + assert.equal(effectRan, true); + assert.equal(store.getState().loading, false); + assert.equal(store.getState().ok, true); +}); + +test("intent dispatch of an unknown intent does not throw", () => { + const { wxapp } = loadEngine(); + assert.doesNotThrow(() => wxapp.Intents.dispatch("nope", { store: new wxapp.Store({}) })); +}); + +// Component + +test("component seeds state, renders and re-renders", () => { + const { wxapp, createElement, document } = loadEngine(); + + class Counter extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + render(state) { + return wxapp.h("p", { class: "v" }, String(state.count)); + } + } + + const element = createElement("div"); + appendStateIsland(document, element, { count: 7 }); + const component = new Counter(element); + + assert.equal(element.childNodes.length, 1); + assert.equal(element.childNodes[0].tagName, "P"); + assert.equal(element.childNodes[0].textContent, "7"); + + component.setState({ count: 8 }); + component.store.flush(); + + assert.equal(element.childNodes[0].textContent, "8"); +}); + +test("component readState parses the state island and tolerates its absence", () => { + const { wxapp, createElement, document } = loadEngine(); + + const withState = createElement("div"); + appendStateIsland(document, withState, { a: 1 }); + assert.deepEqual(wxapp.Data.readState(withState), { a: 1 }); + + const withoutState = createElement("div"); + assert.deepEqual(wxapp.Data.readState(withoutState), {}); +}); + +test("component readState coerces the typed wx-prop values", () => { + const { wxapp, createElement, document } = loadEngine(); + + const element = createElement("div"); + appendStateIsland(document, element, { + page: 0, + loading: false, + search: "treasure", + items: [{ id: "a" }] + }); + + const state = wxapp.Data.readState(element); + + assert.strictEqual(state.page, 0); + assert.strictEqual(state.loading, false); + assert.strictEqual(state.search, "treasure"); + assert.deepEqual(state.items, [{ id: "a" }]); + // the island is consumed on the first read, later reads serve the cache + assert.equal(element.childNodes.length, 0); + assert.deepEqual(wxapp.Data.readState(element).items, [{ id: "a" }]); +}); + +test("component seeds its store from the c# authored wx-state island", () => { + const { wxapp, createElement, document } = loadEngine(); + + // the exact island shape that a c# DataState (page 0, pageSize 50) emits + const probe = createElement("div"); + appendStateIsland(document, probe, { page: 0, pageSize: 50 }); + assert.deepEqual(wxapp.Data.readState(probe), { page: 0, pageSize: 50 }); + + class ListComponent extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + render(state) { + return wxapp.h("span", { class: "p" }, String(state.page) + "/" + String(state.pageSize)); + } + } + + const element = createElement("div"); + appendStateIsland(document, element, { page: 0, pageSize: 50 }); + const component = new ListComponent(element); + + assert.equal(element.childNodes[0].textContent, "0/50"); +}); + +test("component destroy stops further renders", () => { + const { wxapp, createElement } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element) { + super(element); + this.renders = 0; + this.mount(); + } + render(state) { + this.renders += 1; + return wxapp.h("span", null, String(state.n || 0)); + } + } + + const element = createElement("div"); + const component = new Probe(element); + const rendersAfterMount = component.renders; + + component.destroy(); + component.setState({ n: 5 }); + component.store.flush(); + + assert.equal(component.renders, rendersAfterMount); +}); + +// Multiple services per component + +test("service registry parses several islands into named services", () => { + const { wxapp, createElement, document } = loadEngine(); + const element = createElement("div"); + appendServiceIsland(document, element, { name: "load", kind: "rest", baseUri: "/api/form" }); + appendServiceIsland(document, element, { name: "submit", kind: "rest", baseUri: "/api/form" }); + + const services = wxapp.ServiceRegistry.fromElement(element); + + assert.ok(services.load); + assert.ok(services.submit); + assert.equal(typeof services.load.query, "function"); + assert.equal(typeof services.submit.create, "function"); +}); + +// Retry policy and error channel + +test("rest service retries a retriable failure per the descriptor policy", async () => { + const { wxapp, setFetch } = loadEngine(); + let calls = 0; + setFetch(async () => { + calls += 1; + if (calls === 1) { + return { ok: false, status: 503 }; + } + return { ok: true, status: 200, json: async () => ({ items: [] }) }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + retry: { count: 1, delayMs: 0 } + }); + + const result = await service.query({}); + + assert.equal(calls, 2); + assert.equal(result.ok, true); +}); + +test("rest service does not retry a non retriable failure", async () => { + const { wxapp, setFetch } = loadEngine(); + let calls = 0; + setFetch(async () => { + calls += 1; + return { ok: false, status: 404 }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + retry: { count: 3, delayMs: 0 } + }); + + const result = await service.query({}); + + assert.equal(calls, 1); + assert.equal(result.ok, false); +}); + +test("error channel reports a failure with the mapped message key", async () => { + const { wxapp, setFetch, document } = loadEngine(); + setFetch(async () => ({ ok: false, status: 404 })); + + const reported = []; + document.addEventListener("webexpress.webapp.service.error", (event) => reported.push(event.detail)); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + errors: { "404": "webexpress.webapp:error.notfound" } + }); + + const result = await service.query({}); + + assert.equal(result.error.message, "webexpress.webapp:error.notfound"); + assert.equal(reported.length, 1); + assert.equal(reported[0].service, "data"); + assert.equal(reported[0].kind, "http"); + assert.equal(reported[0].status, 404); + assert.equal(reported[0].message, "webexpress.webapp:error.notfound"); +}); + +test("error channel stays silent on success and on abort", async () => { + const { wxapp, setFetch, document } = loadEngine(); + + const reported = []; + document.addEventListener("webexpress.webapp.service.error", (event) => reported.push(event.detail)); + + setFetch(async () => ({ ok: true, status: 200, json: async () => ({}) })); + const service = new wxapp.RestService({ name: "data", baseUri: "/api/orders" }); + await service.query({}); + + setFetch(async () => { const error = new Error("aborted"); error.name = "AbortError"; throw error; }); + await service.query({}); + + assert.equal(reported.length, 0); +}); + +// Templates + +test("component without a render uses the referenced registered template", () => { + const { wxapp, createElement, document } = loadEngine(); + + wxapp.Templates.register("orders-view", (state) => wxapp.h("p", { class: "t" }, String(state.count))); + + class Probe extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + } + + const element = createElement("div"); + element.setAttribute("data-wx-template", "orders-view"); + appendStateIsland(document, element, { count: 3 }); + const component = new Probe(element); + + assert.equal(element.childNodes.length, 1); + assert.equal(element.childNodes[0].tagName, "P"); + assert.equal(element.childNodes[0].textContent, "3"); + + component.setState({ count: 4 }); + component.store.flush(); + + assert.equal(element.childNodes[0].textContent, "4"); +}); + +// State and model binds + +test("state bind reflects a store path as text", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "orders"; + document.body.appendChild(host); + const component = new Probe(host, { state: { total: 7 } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + const label = createElement("span"); + label.setAttribute("data-wx-bind", "state"); + label.setAttribute("data-wx-bind-store", "orders"); + label.setAttribute("data-wx-bind-path", "total"); + document.body.appendChild(label); + + wx.Binds.get("state").bind(label); + assert.equal(label.textContent, "7"); + + component.setState({ total: 9 }); + component.store.flush(); + assert.equal(label.textContent, "9"); +}); + +test("model bind patches the store on input and reflects store changes", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "form"; + document.body.appendChild(host); + const component = new Probe(host, { state: { model: { name: "Guybrush" } } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + const input = createElement("input"); + input.setAttribute("data-wx-bind", "model"); + input.setAttribute("data-wx-bind-store", "form"); + input.setAttribute("data-wx-model", "model.name"); + document.body.appendChild(input); + + wx.Binds.get("model").bind(input); + assert.equal(input.value, "Guybrush"); + + input.value = "LeChuck"; + input.dispatchEvent({ type: "input" }); + component.store.flush(); + assert.equal(component.state.model.name, "LeChuck"); + + component.setState({ model: { name: "Elaine" } }); + component.store.flush(); + assert.equal(input.value, "Elaine"); +}); + +test("binds resolve a component that mounts after the bind", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + const label = createElement("span"); + label.setAttribute("data-wx-bind", "state"); + label.setAttribute("data-wx-bind-store", "late"); + label.setAttribute("data-wx-bind-path", "total"); + document.body.appendChild(label); + + wx.Binds.get("state").bind(label); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "late"; + document.body.appendChild(host); + const component = new Probe(host, { state: { total: 42 } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + // the component announces its mount through a bubbling document event; + // the stub document does not bubble, so the event is replayed on it + document.dispatchEvent({ type: "webexpress.webapp.data.mount", detail: { component } }); + + assert.equal(label.textContent, "42"); +}); + +// Query intents of the data query families + +test("query intents reduce state and trigger the load for list, table and tile", () => { + const { wxapp } = loadEngine(); + + for (const domain of ["list", "table", "tile"]) { + const store = new wxapp.Store({ search: "", wql: "", filter: "", page: 3 }); + let loads = 0; + const component = { load() { loads += 1; } }; + + wxapp.Intents.dispatch(domain + "/search", { store, payload: { pattern: "guybrush", searchType: "basic" }, component }); + assert.equal(store.getState().search, "guybrush", domain); + assert.equal(store.getState().wql, null, domain); + assert.equal(store.getState().page, 0, domain); + assert.equal(loads, 1, domain); + + wxapp.Intents.dispatch(domain + "/search", { store, payload: { pattern: "monkey", searchType: "wql" }, component }); + assert.equal(store.getState().search, null, domain); + assert.equal(store.getState().wql, "monkey", domain); + assert.equal(loads, 2, domain); + + wxapp.Intents.dispatch(domain + "/page", { store, payload: { page: 2 }, component }); + assert.equal(store.getState().page, 2, domain); + assert.equal(loads, 3, domain); + + wxapp.Intents.dispatch(domain + "/filter", { store, payload: { pattern: "insult" }, component }); + assert.equal(store.getState().filter, "insult", domain); + assert.equal(store.getState().page, 0, domain); + assert.equal(loads, 4, domain); + } +}); + +test("rest service maps the closed vocabulary to default wire names without a query mapping", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [] }) }; + }); + + // the common GET/PUT descriptor shape carries no query mapping; the + // logical names still travel as the historical wire names + const service = new wxapp.RestService({ name: "data", baseUri: "/api/tiles", method: "GET", updateMethod: "PUT" }); + await service.query({ search: "abc", filter: "", page: 1, pageSize: 25, orderBy: "label", orderDir: "asc" }); + + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /f=/); + assert.match(capturedUrl, /p=1/); + assert.match(capturedUrl, /l=25/); + assert.match(capturedUrl, /o=label/); + assert.match(capturedUrl, /d=asc/); + assert.doesNotMatch(capturedUrl, /search=/); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/harness.mjs b/src/WebExpress.WebApp.Test/JsTest/harness.mjs new file mode 100644 index 0000000..36a0e34 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/harness.mjs @@ -0,0 +1,244 @@ +/** + * Headless test harness for the View, State and Service engine. + * + * It loads the engine modules into an isolated vm context that carries the + * host globals the engine needs, which are console, queueMicrotask, URL, + * AbortController, fetch and a minimal document. A small Ctrl base is defined + * in the context so that the Component module can extend it without the full + * browser runtime. Each call to loadEngine returns a fresh, isolated engine, so + * that tests do not share state. + */ + +import vm from "node:vm"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDocument } from "./dom-stub.mjs"; + +// the harness lives in WebExpress.WebApp/src/WebExpress.WebApp.Test/JsTest and +// loads the shipped engine sources from the sibling WebExpress.WebApp project +const here = path.dirname(fileURLToPath(import.meta.url)); +const webappAssetsJs = path.resolve(here, "..", "..", "WebExpress.WebApp", "Assets", "js"); + +/** + * Resolves the absolute path of a WebExpress.WebApp asset by file name, so that + * application modules can be loaded into the engine for testing. + * @param {string} name - The asset file name, for example "webexpress.webapp.list.model.js". + * @returns {string} The absolute path. + */ +export function webappAsset(name) { + return path.join(webappAssetsJs, name); +} + +// load order mirrors the Asset attribute order in IncludeJavaScript +// the engine lives in WebExpress.WebApp (WebUI carries only static controls) +const ENGINE_FILES = [ + "webexpress.webapp.store.js", + "webexpress.webapp.service.js", + "webexpress.webapp.renderer.js", + "webexpress.webapp.template.js", + "webexpress.webapp.intent.js", + "webexpress.webapp.data.js", + "service/default.js", + "intent/default.js", + "bind/default.js" +]; + +// a minimal Ctrl base, defined inside the context, that mirrors the parts of +// webexpress.webui.Ctrl the Component relies on, without the DOM heavy runtime +const BOOTSTRAP = ` + var webexpress = { webui: {}, webapp: {} }; + // a minimal CustomEvent so the engine's mount and error events construct + // without a browser runtime + class CustomEvent { + constructor(type, init) { + init = init || {}; + this.type = type; + this.detail = init.detail; + this.bubbles = !!init.bubbles; + } + } + webexpress.webui.Ctrl = class { + constructor(element) { this._element = element; } + render() { } + update() { this.render(); } + destroy() { } + _dispatch(type, detail) { + if (this._element && typeof this._element.dispatchEvent === "function") { + this._element.dispatchEvent({ type: type, detail: detail }); + } + } + _i18n(key, fallback) { return fallback; } + _isVisible() { return true; } + _iconTheme() { return "dark"; } + _iconClass(faClass, lightClass) { return faClass || lightClass || ""; } + }; + // a minimal Controller registry so that application control files, which + // register their class at the end, can be loaded into the harness alongside + // the models. The engine itself does not depend on it. + webexpress.webui.Controller = { + registerClass() { }, + getInstance() { return null; }, + getInstanceByElement() { return null; }, + getClosestInstance() { return null; } + }; + // a minimal Binds registry so the webapp bind defaults, which register the + // state and model binds, can be loaded and exercised in the harness + webexpress.webui.Binds = { + _binds: new Map(), + register(name, definition) { this._binds.set(name, definition); return this; }, + get(name) { return this._binds.get(name) || null; }, + unregister(name) { this._binds.delete(name); } + }; + // event name constants live in the full webexpress.webui.js, which the engine + // harness does not load; an empty map lets controls dispatch without throwing + // (the dispatched type is simply undefined, which the stub element ignores). + webexpress.webui.Event = {}; + webexpress.webapp.Event = {}; +`; + +/** + * Loads a fresh, isolated engine. + * @param {object} [options] - Optional overrides such as a fetch mock. + * @returns {object} An object with the engine namespace, the document and helpers. + */ +export function loadEngine(options = {}) { + const document = createDocument(); + + const sandbox = { + console, + queueMicrotask, + setTimeout, + clearTimeout, + URL, + URLSearchParams, + AbortController, + document, + fetch: options.fetch || (async () => { throw new Error("fetch is not stubbed for this test"); }) + }; + + vm.createContext(sandbox); + vm.runInContext(BOOTSTRAP, sandbox, { filename: "bootstrap" }); + + for (const file of ENGINE_FILES) { + const full = path.join(webappAssetsJs, file); + const code = fs.readFileSync(full, "utf8"); + vm.runInContext(code, sandbox, { filename: full }); + } + + // optional test specific bootstrap, for example a base class stub that an + // application control file extends, run before the extra files load + if (options.bootstrap) { + vm.runInContext(options.bootstrap, sandbox, { filename: "test-bootstrap" }); + } + + // optional additional modules (for example application level helpers), + // loaded after the engine so they can build on it + for (const full of options.extraFiles || []) { + const code = fs.readFileSync(full, "utf8"); + vm.runInContext(code, sandbox, { filename: full }); + } + + return { + wx: sandbox.webexpress.webui, + wxapp: sandbox.webexpress.webapp, + document, + sandbox, + setFetch(fn) { sandbox.fetch = fn; }, + createElement(tag) { return document.createElement(tag); } + }; +} + +/** + * Awaits a turn of the microtask queue, so that batched store notifications run. + * @returns {Promise} A promise that resolves after the microtask queue drains. + */ +export async function tick() { + await Promise.resolve(); + await Promise.resolve(); +} + +/** + * Appends a wx-service island element to a host, mirroring the C# emission in + * DataServiceDescriptor.ToIslandElement, so the control tests configure their + * hosts through the same channel the server renders. + * @param {object} document - The document stub of the loaded engine. + * @param {object} element - The host element. + * @param {object} descriptor - The service descriptor shape. + * @returns {object} The island element. + */ +export function appendServiceIsland(document, element, descriptor) { + descriptor = descriptor || {}; + + const island = document.createElement("wx-service"); + island.setAttribute("hidden", ""); + island.setAttribute("name", descriptor.name || "data"); + island.setAttribute("kind", descriptor.kind || "rest"); + island.setAttribute("base-uri", descriptor.baseUri || ""); + + if (descriptor.method) { + island.setAttribute("method", descriptor.method); + } + if (descriptor.updateMethod) { + island.setAttribute("update-method", descriptor.updateMethod); + } + if (descriptor.retry) { + island.setAttribute("retry-count", String(descriptor.retry.count)); + island.setAttribute("retry-delay", String(descriptor.retry.delayMs || 0)); + } + + const mappings = [ + ["wx-query", "name", "wire", descriptor.query], + ["wx-response", "name", "wire", descriptor.response], + ["wx-header", "name", "value", descriptor.headers], + ["wx-error", "status", "message", descriptor.errors] + ]; + + for (const [tag, keyAttribute, valueAttribute, mapping] of mappings) { + for (const [key, value] of Object.entries(mapping || {})) { + const child = document.createElement(tag); + child.setAttribute(keyAttribute, key); + child.setAttribute(valueAttribute, value); + island.appendChild(child); + } + } + + element.appendChild(island); + return island; +} + +/** + * Appends a wx-state island element to a host, mirroring the C# emission in + * DataState.ToIslandElement, with the same type markers the engine coerces. + * @param {object} document - The document stub of the loaded engine. + * @param {object} element - The host element. + * @param {object} state - The initial state values. + * @returns {object} The island element. + */ +export function appendStateIsland(document, element, state) { + const island = document.createElement("wx-state"); + island.setAttribute("hidden", ""); + + for (const [name, value] of Object.entries(state || {})) { + const prop = document.createElement("wx-prop"); + prop.setAttribute("name", name); + + if (typeof value === "number") { + prop.setAttribute("type", "number"); + prop.textContent = String(value); + } else if (typeof value === "boolean") { + prop.setAttribute("type", "boolean"); + prop.textContent = String(value); + } else if (typeof value === "string") { + prop.textContent = value; + } else { + prop.setAttribute("type", "json"); + prop.textContent = JSON.stringify(value); + } + + island.appendChild(prop); + } + + element.appendChild(island); + return island; +} diff --git a/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs new file mode 100644 index 0000000..2b406d3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs @@ -0,0 +1,82 @@ +/** + * Headless unit tests for the REST input selection model helpers (View, State + * and Service). + * + * These cover the pure logic extracted from webexpress.webapp.input.selection.js: + * the request url and init shaping and the response item mapping with its data + * and aria attribute tuples, plus an end to end path that searches through the + * shared request and maps the result. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.input.selection.model.js")] }, + options + )); +} + +test("build url appends the query and page for get and is unchanged otherwise", () => { + const { wxapp } = load(); + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + assert.equal(wxapp.inputSelectionModel.buildUrl(cfg, "ab"), "/api/s?q=ab&p=0"); + assert.equal(wxapp.inputSelectionModel.buildUrl({ apiEndpoint: "/api/s", httpMethod: "POST" }, "x"), "/api/s"); +}); + +test("build request init carries a json body for post and a signal for get", () => { + const { wxapp } = load(); + const post = wxapp.inputSelectionModel.buildRequestInit({ httpMethod: "POST", queryParam: "q", pageParam: "p", page: 1 }, "term", "SIG"); + assert.equal(post.method, "POST"); + assert.deepEqual(JSON.parse(post.body), { q: "term", p: 1 }); + assert.equal(post.signal, "SIG"); + + const get = wxapp.inputSelectionModel.buildRequestInit({ httpMethod: "GET" }, "term", "SIG"); + assert.equal(get.method, "GET"); + assert.equal("body" in get, false); +}); + +test("map api item projects aliases and builds data and aria tuples", () => { + const { wxapp } = load(); + const item = wxapp.inputSelectionModel.mapApiItem({ + id: "1", + name: "N", + data: { foo: "bar", "data-baz": "1" }, + aria: { label: "L" } + }); + + assert.equal(item.id, "1"); + assert.equal(item.value, "1"); + assert.equal(item.label, "N"); + assert.equal(item.content, "N"); + assert.equal(item.uri, "javascript:void(0);"); + assert.deepEqual(item.data, [["data-foo", "bar"], ["data-baz", "1"]]); + assert.deepEqual(item.aria, [["aria-label", "L"]]); + + const withUri = wxapp.inputSelectionModel.mapApiItem({ id: "2", url: "/u" }); + assert.equal(withUri.uri, "/u"); +}); + +test("model searches and maps the result through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "1", content: "One" }] }) }; + }); + + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + const url = wxapp.inputSelectionModel.buildUrl(cfg, "on"); + const init = wxapp.inputSelectionModel.buildRequestInit(cfg, "on", null); + const res = await wxapp.ServiceRegistry.request(url, init); + + assert.equal(calls[0].url, "/api/s?q=on&p=0"); + const mapped = (res.data.items || []).map((x) => wxapp.inputSelectionModel.mapApiItem(x)); + assert.equal(mapped[0].label, "One"); + assert.equal(mapped[0].value, "1"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs new file mode 100644 index 0000000..679fc0f --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs @@ -0,0 +1,83 @@ +/** + * Headless unit tests for the unique input model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.input.unique.js: + * the header parsing, the request body shaping and the availability extraction + * with its configured field and the status and code heuristics, plus an end to + * end path that checks uniqueness through the shared request and interprets the + * result through the model. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.input.unique.model.js")] }, + options + )); +} + +test("parse headers reads string pairs and tolerates invalid input", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('{"X-A":"1","X-B":"2"}'), { "X-A": "1", "X-B": "2" }); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders(""), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders("not json"), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('["a"]'), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('{"X-A":1,"X-B":"ok"}'), { "X-B": "ok" }); +}); + +test("request body carries the configured parameter", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.inputUniqueModel.requestBody("v", "name"), { v: "name" }); + assert.deepEqual(wxapp.inputUniqueModel.requestBody("login", "ann"), { login: "ann" }); +}); + +test("extract availability reads the configured field as boolean, string or number", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ available: true }, "available"), true); + assert.equal(m.extractAvailability({ available: false }, "available"), false); + assert.equal(m.extractAvailability({ free: "true" }, "free"), true); + assert.equal(m.extractAvailability({ free: "FALSE" }, "free"), false); + assert.equal(m.extractAvailability({ ok: 1 }, "ok"), true); + assert.equal(m.extractAvailability({ ok: 0 }, "ok"), false); +}); + +test("extract availability falls back to the status and code heuristics", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ status: "free" }, "available"), true); + assert.equal(m.extractAvailability({ status: "available" }, "available"), true); + assert.equal(m.extractAvailability({ status: "taken" }, "available"), false); + assert.equal(m.extractAvailability({ status: "in_use" }, "available"), false); + assert.equal(m.extractAvailability({ code: "available" }, "available"), true); + assert.equal(m.extractAvailability({ code: "unavailable" }, "available"), false); +}); + +test("extract availability returns null when undecidable", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ foo: "bar" }, "available"), null); + assert.equal(m.extractAvailability({ available: "maybe" }, "available"), null); + assert.equal(m.extractAvailability(null, "available"), null); +}); + +test("model checks uniqueness through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ available: false }) }; + }); + + const res = await wxapp.ServiceRegistry.request("/api/unique?v=taken", { method: "GET" }); + assert.equal(calls[0].method, "GET"); + assert.equal(wxapp.inputUniqueModel.extractAvailability(res.data, "available"), false); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs new file mode 100644 index 0000000..a8a3c56 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs @@ -0,0 +1,78 @@ +/** + * Headless unit tests for the REST kanban model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.kanban.js, namely + * the board normalisation, plus an end to end path that loads the board with a + * query and persists a card move with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.kanban.model.js")] }, + options + )); +} + +test("normalize board maps columns, swimlanes and cards with defaults", () => { + const { wxapp } = load(); + const board = wxapp.kanbanModel.normalizeBoard({ + columns: [{ id: "c1", label: "To Do" }, { id: "c2", label: "Done", size: "2fr" }], + swimlanes: [{ id: "s1", label: "Lane", expanded: false }], + items: [{ id: "i1", columnId: "c1", swimlaneId: "s1", label: "Card" }] + }); + + assert.equal(board.columns.length, 2); + assert.equal(board.columns[0].size, "1fr"); + assert.equal(board.columns[1].size, "2fr"); + assert.equal(board.swimlanes[0].expanded, false); + assert.equal(board.cards[0].id, "i1"); + assert.equal(board.cards[0].label, "Card"); + assert.deepEqual(board.cards[0].primaryAction, {}); +}); + +test("normalize board returns only the present parts and tolerates empties", () => { + const { wxapp } = load(); + + const partial = wxapp.kanbanModel.normalizeBoard({ items: [{ id: "x" }] }); + assert.equal("columns" in partial, false); + assert.equal("swimlanes" in partial, false); + assert.equal(partial.cards.length, 1); + + const lanes = wxapp.kanbanModel.normalizeBoard({ swimlanes: [{ id: "s", label: "L" }] }); + assert.equal(lanes.swimlanes[0].expanded, true); + + assert.deepEqual(wxapp.kanbanModel.normalizeBoard(null), {}); +}); + +test("model loads the board and persists a move through a service", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ columns: [{ id: "c1", label: "To Do" }], items: [{ id: "i1", columnId: "c1" }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/board", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const board = wxapp.kanbanModel.normalizeBoard(loaded.data); + assert.equal(board.columns[0].id, "c1"); + assert.equal(board.cards[0].id, "i1"); + + const moved = await service.update({ cardId: "i1", columnId: "c2", swimlaneId: null }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { cardId: "i1", columnId: "c2", swimlaneId: null }); + assert.equal(moved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs new file mode 100644 index 0000000..15b4090 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs @@ -0,0 +1,119 @@ +/** + * Headless unit tests for the REST list model helpers (phase one). + * + * These cover the pure logic extracted from webexpress.webapp.list.js, and an + * end to end path that feeds the model output through a RestService to confirm + * the legacy query parameter names survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.list.model.js")] }, + options + )); +} + +test("query params always include the base fields and omit order when unset", () => { + const { wxapp } = load(); + const params = wxapp.listModel.queryParams({ search: "abc", page: 3, pageSize: 25 }); + + assert.equal(params.search, "abc"); + assert.equal(params.wql, ""); + assert.equal(params.filter, ""); + assert.equal(params.page, 3); + assert.equal(params.pageSize, 25); + assert.equal("orderBy" in params, false); + assert.equal("orderDir" in params, false); +}); + +test("query params include order when an order field is set", () => { + const { wxapp } = load(); + const params = wxapp.listModel.queryParams({ orderBy: "name", orderDir: "asc" }); + + assert.equal(params.orderBy, "name"); + assert.equal(params.orderDir, "asc"); +}); + +test("reduce response extracts paging and clears loading and error", () => { + const { wxapp } = load(); + const patch = wxapp.listModel.reduceResponse({ page: 0, pageSize: 50 }, { total: 42, page: 1, pageSize: 25 }); + + assert.equal(patch.total, 42); + assert.equal(patch.page, 1); + assert.equal(patch.pageSize, 25); + assert.equal(patch.loading, false); + assert.equal(patch.error, null); +}); + +test("reduce response falls back to the current state and tolerates alternates", () => { + const { wxapp } = load(); + const patch = wxapp.listModel.reduceResponse({ page: 2, pageSize: 50 }, { totalCount: 7 }); + + assert.equal(patch.total, 7); + assert.equal(patch.page, 2); + assert.equal(patch.pageSize, 50); +}); + +test("map items projects strings and objects and tolerates a missing array", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.listModel.mapItems(null), []); + assert.deepEqual(wxapp.listModel.mapItems({}), []); + + const items = wxapp.listModel.mapItems({ + items: ["plain", { id: 7, text: "labelled", editable: true, options: [{ a: 1 }] }] + }); + + assert.equal(items.length, 2); + assert.equal(items[0].id, null); + assert.deepEqual(items[0].content, { content: "plain" }); + assert.equal(items[1].id, 7); + assert.equal(items[1].content, "labelled"); + assert.equal(items[1].editable, true); + assert.deepEqual(items[1].options, [{ a: 1 }]); +}); + +test("model feeds a rest service with the legacy parameter names end to end", async () => { + const { wxapp, setFetch } = load(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [{ id: 1, text: "a" }], total: 1 }) }; + }); + + const service = wxapp.ServiceRegistry.create({ + name: "data", + kind: "rest", + baseUri: "/api/orders", + method: "GET", + query: { search: "q", wql: "wql", filter: "f", page: "p", pageSize: "l", orderBy: "o", orderDir: "d" }, + response: { items: "items", total: "total" } + }); + const state = { search: "abc", wql: "", filter: "x", page: 2, pageSize: 25, orderBy: "name", orderDir: "asc" }; + + const result = await service.query(wxapp.listModel.queryParams(state)); + + assert.equal(result.ok, true); + assert.match(capturedUrl, /\/api\/orders\?/); + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /f=x/); + assert.match(capturedUrl, /p=2/); + assert.match(capturedUrl, /l=25/); + assert.match(capturedUrl, /o=name/); + assert.match(capturedUrl, /d=asc/); + + const patch = wxapp.listModel.reduceResponse(state, result.data); + assert.equal(patch.total, 1); + assert.equal(patch.loading, false); + + const items = wxapp.listModel.mapItems(result.data); + assert.equal(items.length, 1); + assert.equal(items[0].content, "a"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs new file mode 100644 index 0000000..2fd7498 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs @@ -0,0 +1,158 @@ +/** + * Headless unit tests for the REST form model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.restform.js, + * namely the request shaping, the response classification and the server error + * normalisation, plus an end to end path through a service request. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.restform.model.js")] }, + options + )); +} + +test("build load url carries the id and the mode", () => { + const { wxapp } = load(); + const url = wxapp.restFormModel.buildLoadUrl("/api/form", 42, "edit", "http://localhost"); + + assert.match(url, /\/api\/form\?/); + assert.match(url, /id=42/); + assert.match(url, /mode=edit/); +}); + +test("build request shapes a json body and appends the id for post", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", + { method: "POST", json: true, headers: { "Content-Type": "application/json; charset=utf-8" }, id: 5 }, + { name: "a" }, + "http://localhost" + ); + + assert.equal(built.init.method, "POST"); + assert.equal(JSON.parse(built.init.body).name, "a"); + assert.equal(built.init.headers["Content-Type"], "application/json; charset=utf-8"); + assert.match(built.url, /id=5/); +}); + +test("build request adds the json content type when missing", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: true, headers: {} }, { x: 1 }, "http://localhost"); + + assert.equal(built.init.headers["Content-Type"], "application/json; charset=utf-8"); +}); + +test("build request shapes a form encoded body when json is off", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: false, headers: {} }, { a: "1", b: "2" }, "http://localhost"); + + assert.equal(built.init.headers["Content-Type"], "application/x-www-form-urlencoded; charset=utf-8"); + assert.match(built.init.body, /a=1/); + assert.match(built.init.body, /b=2/); +}); + +test("build request for delete carries only the id and drops the content type", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "DELETE", id: 9, headers: { "Content-Type": "application/json" } }, {}, "http://localhost"); + + assert.equal(built.init.method, "DELETE"); + assert.equal(built.init.body, undefined); + assert.match(built.url, /id=9/); + assert.equal("Content-Type" in built.init.headers, false); +}); + +test("build request for get appends the payload as query parameters", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "GET", headers: {} }, { q: "abc", f: "x" }, "http://localhost"); + + assert.match(built.url, /q=abc/); + assert.match(built.url, /f=x/); + assert.equal(built.init.body, undefined); +}); + +test("classify response handles success, close, confirm, validation and error", () => { + const { wxapp } = load(); + + let c = wxapp.restFormModel.classifyResponse(true, 200, { ok: true }); + assert.equal(c.kind, "success"); + assert.equal(c.closeModal, true); + + c = wxapp.restFormModel.classifyResponse(true, 200, { message: "saved" }); + assert.equal(c.kind, "success"); + assert.equal(c.closeModal, false); + assert.equal(c.message, "saved"); + + c = wxapp.restFormModel.classifyResponse(true, 200, { message: "m", data: { confirmHtml: "ok" } }); + assert.equal(c.confirmHtml, "ok"); + + c = wxapp.restFormModel.classifyResponse(false, 400, [{ field: "name", message: "required" }]); + assert.equal(c.kind, "validation"); + assert.equal(c.errors[0].field, "name"); + assert.equal(c.errors[0].message, "required"); + + c = wxapp.restFormModel.classifyResponse(false, 400, { errors: { email: "invalid" } }); + assert.equal(c.kind, "validation"); + assert.equal(c.errors[0].field, "email"); + assert.equal(c.errors[0].message, "invalid"); + + c = wxapp.restFormModel.classifyResponse(false, 400, { message: "bad" }); + assert.equal(c.kind, "validation"); + assert.deepEqual(c.errors, []); + assert.equal(c.message, "bad"); + + c = wxapp.restFormModel.classifyResponse(false, 500, {}); + assert.equal(c.kind, "error"); + assert.equal(c.status, 500); +}); + +test("normalize errors reads the several casings the server may use", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.restFormModel.normalizeFieldErrors({ a: "x", b: "y" }), + [{ field: "a", message: "x" }, { field: "b", message: "y" }] + ); + assert.deepEqual(wxapp.restFormModel.normalizeFieldErrors(null), []); + + assert.deepEqual( + wxapp.restFormModel.normalizeArrayErrors([{ field: "f", message: "m" }, { Message: "M2" }]), + [{ field: "f", message: "m" }, { field: null, message: "M2" }] + ); +}); + +test("model feeds a service request and classifies the result end to end", async () => { + const { wxapp, setFetch } = load(); + let captured = null; + setFetch(async (url, init) => { + captured = { url: url, init: init }; + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ message: "saved" }) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/form" }); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: true, headers: {}, id: 7 }, { name: "a" }, "http://localhost"); + + const result = await service.request(built.url, built.init); + + assert.equal(result.ok, true); + assert.equal(result.data.message, "saved"); + assert.match(captured.url, /id=7/); + assert.equal(JSON.parse(captured.init.body).name, "a"); + + const classification = wxapp.restFormModel.classifyResponse(result.ok, result.status, result.data); + assert.equal(classification.kind, "success"); + assert.equal(classification.message, "saved"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs new file mode 100644 index 0000000..2629a67 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs @@ -0,0 +1,73 @@ +/** + * Headless tests for the REST form control after it was lifted onto the Data + * base (View, State and Service). The form already owned a store, store-backed + * ui-state accessors and an island-or-legacy service map; the lift delegates the + * store and the service map to the base (super(element, { state, services })) and + * gains the base teardown that aborts the service. The form keeps its imperative + * render and submit flow. + * + * The load test omits the form mode triggers so the constructor performs no + * load, which keeps the lift assertions free of async I/O. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.restform.model.js"), + webappAsset("webexpress.webapp.restform.js") + ] + }, + options + )); +} + +test("restform extends the data base and resolves its service", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/form" }); + + // the configured endpoint triggers the initial load, which needs the full + // browser form runtime; the lift assertions only cover the wiring + wxapp.RestFormCtrl.prototype.load = async function () { }; + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); + assert.ok(ctrl.useService("data")); + assert.equal(ctrl.options.api, "/api/form"); + assert.equal(ctrl.mode, "new"); +}); + +test("restform seeds its ui state from the wx-state island", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + appendStateIsland(document, element, { submitting: true }); + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.equal(ctrl.state.submitting, true); +}); + +test("restform destroy tears down without throwing", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.doesNotThrow(() => ctrl.destroy()); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs new file mode 100644 index 0000000..b56b1c0 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs @@ -0,0 +1,85 @@ +/** + * Headless unit tests for the REST wizard model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.restwizard.js, + * namely the step request shaping, the cache decision and the last step + * detection, plus an end to end path through a service request that returns an + * html step and one that returns a 204 skip. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.restwizard.model.js")] }, + options + )); +} + +test("build step request init posts the payload and accepts html", () => { + const { wxapp } = load(); + const init = wxapp.restWizardModel.buildStepRequestInit('{"a":1}'); + + assert.equal(init.method, "POST"); + assert.equal(init.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(init.headers["Accept"], "text/html"); + assert.equal(init.body, '{"a":1}'); +}); + +test("should use cache only when loaded, unchanged and without error", () => { + const { wxapp } = load(); + const page = { isLoaded: true, payloadHash: "abc", hasError: false }; + + assert.equal(wxapp.restWizardModel.shouldUseCache(page, "abc"), true); + assert.equal(wxapp.restWizardModel.shouldUseCache(page, "xyz"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache({ isLoaded: false, payloadHash: "abc" }, "abc"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache({ isLoaded: true, payloadHash: "abc", hasError: true }, "abc"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache(null, "abc"), false); +}); + +test("is last page ignores skipped pages and an empty list", () => { + const { wxapp } = load(); + const pages = [{ skipped: false }, { skipped: false }, { skipped: false }]; + + assert.equal(wxapp.restWizardModel.isLastPage(pages, 2), true); + assert.equal(wxapp.restWizardModel.isLastPage(pages, 1), false); + + const trailing = [{ skipped: false }, { skipped: false }, { skipped: true }]; + assert.equal(wxapp.restWizardModel.isLastPage(trailing, 1), true); + + assert.equal(wxapp.restWizardModel.isLastPage([], 0), true); +}); + +test("model feeds a service request that returns an html step", async () => { + const { wxapp, setFetch } = load(); + let captured = null; + setFetch(async (url, init) => { + captured = { url: url, init: init }; + return { ok: true, status: 200, headers: { get: () => "text/html" }, text: async () => "

step

" }; + }); + + const service = wxapp.ServiceRegistry.create({ kind: "rest", baseUri: "" }); + const result = await service.request("/step/2", wxapp.restWizardModel.buildStepRequestInit('{"x":1}')); + + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.data.text, "

step

"); + assert.equal(captured.init.method, "POST"); + assert.equal(captured.init.body, '{"x":1}'); +}); + +test("model feeds a service request that returns a 204 skip", async () => { + const { wxapp, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 204, headers: { get: () => null } })); + + const service = wxapp.ServiceRegistry.create({ kind: "rest", baseUri: "" }); + const result = await service.request("/step/3", wxapp.restWizardModel.buildStepRequestInit("{}")); + + assert.equal(result.status, 204); + assert.equal(result.data, null); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/run.ps1 b/src/WebExpress.WebApp.Test/JsTest/run.ps1 new file mode 100644 index 0000000..803afb5 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/run.ps1 @@ -0,0 +1,37 @@ +# Runs the headless engine tests with Node. +# +# It uses node from the PATH when available, and otherwise falls back to the +# Node that ships with a Visual Studio installation (the "Node.js development" +# component), which is not added to the system PATH. +# +# Usage from this folder: +# ./run.ps1 + +$ErrorActionPreference = "Stop" + +function Resolve-Node { + $onPath = Get-Command node -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $roots = @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio") + foreach ($root in $roots) { + if (Test-Path $root) { + $candidate = Get-ChildItem -Path $root -Filter "node.exe" -Recurse -ErrorAction SilentlyContinue -File | + Select-Object -First 1 + if ($candidate) { return $candidate.FullName } + } + } + + return $null +} + +$node = Resolve-Node +if (-not $node) { + Write-Error "node was not found on the PATH or in a Visual Studio installation. Install Node 18 or newer." + exit 1 +} + +Write-Host "using node: $node" +Set-Location $PSScriptRoot +& $node --test +exit $LASTEXITCODE diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs new file mode 100644 index 0000000..ccba672 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs @@ -0,0 +1,149 @@ +/** + * Headless unit tests for the scrum backlog model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.scrum.backlog.js: + * the legacy descriptor, the board and sprint normalisation, the sprint and item + * paths, the request bodies, the sprint group filter and sort, the rank rewrite + * and the active sprint crossing classification, plus an end to end path that + * loads the board with a query, persists a sprint and an item rank with an + * update on a path and deletes a sprint with a remove through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.scrum.backlog.model.js")] }, + options + )); +} + +test("normalize data returns sprint and item arrays and tolerates absence", () => { + const { wxapp } = load(); + const norm = wxapp.scrumBacklogModel.normalizeData({ sprints: [{ id: "s1" }], items: [{ id: "i1" }] }); + assert.deepEqual(norm.sprints.map(s => s.id), ["s1"]); + assert.deepEqual(norm.items.map(i => i.id), ["i1"]); + + const empty = wxapp.scrumBacklogModel.normalizeData(null); + assert.deepEqual(empty.sprints, []); + assert.deepEqual(empty.items, []); +}); + +test("normalize sprint applies defaults and keeps caller supplied fields", () => { + const { wxapp } = load(); + const s = wxapp.scrumBacklogModel.normalizeSprint({ id: "s1", name: "Sprint 1", capacity: 5, extra: "x" }); + assert.equal(s.id, "s1"); + assert.equal(s.name, "Sprint 1"); + assert.equal(s.status, "planned"); + assert.equal(s.goal, ""); + assert.equal(s.start, null); + assert.equal(s.capacity, 5); + assert.equal(s.extra, "x"); + + const d = wxapp.scrumBacklogModel.normalizeSprint({ id: "s2" }); + assert.equal(d.status, "planned"); + assert.equal(d.capacity, 0); + + const active = wxapp.scrumBacklogModel.normalizeSprint({ id: "s3", status: "active" }); + assert.equal(active.status, "active"); +}); + +test("paths build the sprint, item rank and batch endpoints", () => { + const { wxapp } = load(); + assert.equal(wxapp.scrumBacklogModel.sprintPath("s 1"), "/sprints/s%201"); + assert.equal(wxapp.scrumBacklogModel.itemRankPath("i 1"), "/items/i%201/rank"); + assert.equal(wxapp.scrumBacklogModel.rankBatchPath(), "/items/rank-batch"); +}); + +test("request bodies carry the rank payloads", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.scrumBacklogModel.itemRankBody({ id: "i1", sprintId: "s1", rank: 3 }), { sprintId: "s1", rank: 3 }); + assert.deepEqual(wxapp.scrumBacklogModel.itemRankBody({ id: "i2", rank: 1 }), { sprintId: null, rank: 1 }); + + const batch = wxapp.scrumBacklogModel.rankBatchBody([{ id: "i1", sprintId: "s1", rank: 1 }, { id: "i2", rank: 2 }]); + assert.deepEqual(batch, { ranks: [{ id: "i1", sprintId: "s1", rank: 1 }, { id: "i2", sprintId: null, rank: 2 }] }); + assert.deepEqual(wxapp.scrumBacklogModel.rankBatchBody(null), { ranks: [] }); +}); + +test("items for sprint sorted filters by group and sorts by rank then key", () => { + const { wxapp } = load(); + const items = [ + { id: "a", sprintId: "s1", rank: 2, key: "A" }, + { id: "b", sprintId: "s1", rank: 1, key: "B" }, + { id: "c", sprintId: null, status: "backlog", key: "C" }, + { id: "d", status: "backlog", key: "D" } + ]; + + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(items, "s1").map(i => i.id), ["b", "a"]); + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(items, null).map(i => i.id), ["c", "d"]); + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(null, "s1"), []); +}); + +test("rewrite ranks assigns a one based rank and the sprint id", () => { + const { wxapp } = load(); + const items = [{ id: "x" }, { id: "y" }, { id: "z" }]; + wxapp.scrumBacklogModel.rewriteRanks("s1", items); + assert.deepEqual(items.map(i => i.rank), [1, 2, 3]); + assert.deepEqual(items.map(i => i.sprintId), ["s1", "s1", "s1"]); + + const backlog = [{ id: "x", sprintId: "s1" }]; + wxapp.scrumBacklogModel.rewriteRanks(null, backlog); + assert.equal(backlog[0].sprintId, null); + assert.equal(backlog[0].rank, 1); +}); + +test("crosses active sprint detects entering or leaving the active sprint", () => { + const { wxapp } = load(); + const m = wxapp.scrumBacklogModel; + + assert.equal(m.crossesActiveSprint([{ sprintId: "s1" }], "s2", null), false); + assert.equal(m.crossesActiveSprint([{ sprintId: null }], "act", "act"), true); + assert.equal(m.crossesActiveSprint([{ sprintId: "act" }], null, "act"), true); + assert.equal(m.crossesActiveSprint([{ sprintId: "act" }], "act", "act"), false); + assert.equal(m.crossesActiveSprint([{ sprintId: "s1" }], "s2", "act"), false); +}); + +test("model loads, persists and deletes through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ sprints: [{ id: "s1" }], items: [{ id: "i1", sprintId: "s1" }] }) }; + } + if (method === "DELETE") { + return { ok: true, status: 204 }; + } + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + const norm = wxapp.scrumBacklogModel.normalizeData(loaded.data); + assert.equal(norm.sprints[0].id, "s1"); + assert.equal(norm.items[0].id, "i1"); + + await service.update({ id: "s1", status: "active" }, { path: wxapp.scrumBacklogModel.sprintPath("s1") }); + assert.equal(calls[1].method, "PUT"); + assert.equal(calls[1].url.endsWith("/sprints/s1"), true); + + await service.update( + wxapp.scrumBacklogModel.itemRankBody({ id: "i1", sprintId: "s1", rank: 3 }), + { path: wxapp.scrumBacklogModel.itemRankPath("i1") } + ); + assert.equal(calls[2].url.endsWith("/items/i1/rank"), true); + assert.deepEqual(JSON.parse(calls[2].body), { sprintId: "s1", rank: 3 }); + + const removed = await service.remove({ path: wxapp.scrumBacklogModel.sprintPath("s1") }); + assert.equal(calls[3].method, "DELETE"); + assert.equal(calls[3].url.endsWith("/sprints/s1"), true); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs new file mode 100644 index 0000000..38cb57a --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs @@ -0,0 +1,91 @@ +/** + * Headless tests for the scrum backlog control after it was lifted onto the + * Component base (View, State and Service). The backlog is large and mutates its + * items in place throughout, so it is a light lift: it extends Component for the + * store, the service map, the seed and the lifecycle, but keeps its own manual + * render flow. The tests assert that it extends Component, seeds its sprints and + * items from the wx-state island and skips the load in that case, and + * otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.scrum.backlog.model.js"), + webappAsset("webexpress.webapp.scrum.backlog.js") + ] + }, + options + )); +} + +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("scrum backlog extends the component base", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => ({ sprints: [], items: [] }) })); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" }); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("scrum backlog seeds from the wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => ({ sprints: [], items: [] }) }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" }); + appendStateIsland(document, element, { + sprints: [{ id: "s1", name: "Sprint 1", status: "active" }], + items: [{ id: "i1", sprintId: "s1", title: "Task", key: "T-1", points: 3 }] + }); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + + assert.equal(ctrl.sprints.length, 1); + assert.equal(ctrl.sprints[0].id, "s1"); + assert.equal(ctrl.items.length, 1); + assert.equal(ctrl.items[0].id, "i1"); + // the backlog is rendered from the seeded state, not deferred to a load + assert.ok(element.childNodes.length > 0); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("scrum backlog loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { + fetchCount++; + return { ok: true, status: 200, json: async () => ({ sprints: [{ id: "s9" }], items: [] }) }; + }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" }); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + assert.equal(ctrl.sprints.length, 0); + + await settle(); + + assert.equal(fetchCount, 1); + assert.equal(ctrl.sprints.length, 1); + assert.equal(ctrl.sprints[0].id, "s9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs new file mode 100644 index 0000000..0421356 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs @@ -0,0 +1,86 @@ +/** + * Headless tests for the scrum sprint control after it was lifted onto the + * Component base (View, State and Service). The control keeps its own imperative + * render method, which Component._apply calls on every state change. The tests + * assert that it extends Component, seeds its sprint from the wx-state + * island and skips the network load in that case, and otherwise loads from the + * shared request. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.scrum.sprint.js")] }, + options + )); +} + +/** + * Awaits the asynchronous load and the batched store notification. + * @returns {Promise} Resolves after the macrotask and microtask queues drain. + */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("scrum sprint extends the component base", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => ({}) })); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/sprint", method: "GET" }); + + const ctrl = new wxapp.ScrumSprintCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("scrum sprint seeds from the wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => ({}) }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/sprint", method: "GET" }); + appendStateIsland(document, element, { + sprint: { name: "Sprint 24", goal: "MVP", committedPoints: 47, completedPoints: 18, capacity: 60, daysTotal: 14, daysElapsed: 7 } + }); + + const ctrl = new wxapp.ScrumSprintCtrl(element); + + assert.ok(ctrl.sprint); + assert.equal(ctrl.sprint.name, "Sprint 24"); + // the sprint card is rendered from the seeded state on mount, not the empty placeholder + assert.ok(element.childNodes.length > 0); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("scrum sprint loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { + fetchCount++; + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ name: "Sprint 9" }) }; + }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/sprint", method: "GET" }); + + const ctrl = new wxapp.ScrumSprintCtrl(element); + assert.equal(ctrl.sprint, null); + + await settle(); + + assert.equal(fetchCount, 1); + assert.ok(ctrl.sprint); + assert.equal(ctrl.sprint.name, "Sprint 9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs new file mode 100644 index 0000000..ec8a922 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs @@ -0,0 +1,79 @@ +/** + * Headless unit tests for the REST selection model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.selection.js: the + * request url and init shaping and the response item mapping, plus an end to end + * path that searches through the shared request and maps the result. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.selection.model.js")] }, + options + )); +} + +test("build url appends the query and page for get and is unchanged otherwise", () => { + const { wxapp } = load(); + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + assert.equal(wxapp.selectionModel.buildUrl(cfg, "ab"), "/api/s?q=ab&p=0"); + + const cfgQ = { apiEndpoint: "/api/s?x=1", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 2 }; + assert.equal(wxapp.selectionModel.buildUrl(cfgQ, "a b"), "/api/s?x=1&q=a%20b&p=2"); + + assert.equal(wxapp.selectionModel.buildUrl({ apiEndpoint: "/api/s", httpMethod: "POST" }, "x"), "/api/s"); +}); + +test("build request init carries a json body for post and a signal for get", () => { + const { wxapp } = load(); + const post = wxapp.selectionModel.buildRequestInit({ httpMethod: "POST", queryParam: "q", pageParam: "p", page: 1 }, "term", "SIG"); + assert.equal(post.method, "POST"); + assert.equal(post.headers["Content-Type"], "application/json"); + assert.deepEqual(JSON.parse(post.body), { q: "term", p: 1 }); + assert.equal(post.signal, "SIG"); + + const get = wxapp.selectionModel.buildRequestInit({ httpMethod: "GET" }, "term", "SIG"); + assert.equal(get.method, "GET"); + assert.equal(get.signal, "SIG"); + assert.equal("body" in get, false); +}); + +test("map api item chooses field aliases defensively", () => { + const { wxapp } = load(); + const item = wxapp.selectionModel.mapApiItem({ id: "1", content: "C", url: "/u", disabled: true }); + assert.equal(item.id, "1"); + assert.equal(item.label, "C"); + assert.equal(item.primaryUri, "/u"); + assert.equal(item.disabled, true); + + const empty = wxapp.selectionModel.mapApiItem({}); + assert.equal(empty.id, null); + assert.equal(empty.label, ""); + assert.equal(empty.disabled, false); +}); + +test("model searches and maps the result through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "1", label: "One" }] }) }; + }); + + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + const url = wxapp.selectionModel.buildUrl(cfg, "on"); + const init = wxapp.selectionModel.buildRequestInit(cfg, "on", null); + const res = await wxapp.ServiceRegistry.request(url, init); + + assert.equal(calls[0].url, "/api/s?q=on&p=0"); + const mapped = (res.data.items || []).map((x) => wxapp.selectionModel.mapApiItem(x)); + assert.equal(mapped[0].label, "One"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs new file mode 100644 index 0000000..59a4373 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs @@ -0,0 +1,114 @@ +/** + * Headless unit tests for the REST tab model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.tab.js, and an + * end to end path that drives the four operations (list, create, reorder, + * close) through a service to confirm the methods and bodies survive the + * migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.tab.model.js")] }, + options + )); +} + +test("map tabs reads the items array and tolerates a missing one", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.mapTabs({ items: [{ id: 1 }, { id: 2 }] }), [{ id: 1 }, { id: 2 }]); + assert.deepEqual(wxapp.tabModel.mapTabs({}), []); + assert.deepEqual(wxapp.tabModel.mapTabs(null), []); +}); + +test("create and reorder bodies carry the action and payload", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.createBody("t"), { action: "create", templateId: "t" }); + assert.deepEqual(wxapp.tabModel.reorderBody(["a", "b"]), { action: "reorder", order: ["a", "b"] }); +}); + +test("extract new tab applies the requested template id and tolerates absence", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.extractNewTab({ newTab: { id: 1 } }, "t"), { id: 1, templateId: "t" }); + assert.deepEqual(wxapp.tabModel.extractNewTab({ newTab: { id: 1, templateId: "x" } }, "t"), { id: 1, templateId: "x" }); + assert.equal(wxapp.tabModel.extractNewTab({}, "t"), null); + assert.equal(wxapp.tabModel.extractNewTab(null, "t"), null); +}); + +test("parse multiplicity yields a non negative integer or null", () => { + const { wxapp } = load(); + + assert.equal(wxapp.tabModel.parseMultiplicity("3"), 3); + assert.equal(wxapp.tabModel.parseMultiplicity("0"), 0); + assert.equal(wxapp.tabModel.parseMultiplicity(""), null); + assert.equal(wxapp.tabModel.parseMultiplicity(undefined), null); + assert.equal(wxapp.tabModel.parseMultiplicity("-1"), null); + assert.equal(wxapp.tabModel.parseMultiplicity("abc"), null); +}); + +test("is template available respects the multiplicity limit", () => { + const { wxapp } = load(); + + assert.equal(wxapp.tabModel.isTemplateAvailable(null, 5), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: null }, 5), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: 3 }, 2), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: 3 }, 3), false); +}); + +test("model drives the four operations through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ items: [{ id: 1 }] }) }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ newTab: { id: 9 } }) }; + } + if (method === "PUT") { + return { ok: true, status: 200, json: async () => ({}) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create({ + name: "data", + kind: "rest", + baseUri: "/api/tabs", + method: "GET", + updateMethod: "PUT", + query: { id: "id" }, + response: { items: "items" } + }); + + const list = await service.query({}); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(wxapp.tabModel.mapTabs(list.data), [{ id: 1 }]); + + const created = await service.create(wxapp.tabModel.createBody("t")); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { action: "create", templateId: "t" }); + assert.equal(wxapp.tabModel.extractNewTab(created.data, "t").id, 9); + + const reordered = await service.update(wxapp.tabModel.reorderBody(["a", "b"])); + assert.equal(calls[2].method, "PUT"); + assert.deepEqual(JSON.parse(calls[2].body), { action: "reorder", order: ["a", "b"] }); + assert.equal(reordered.ok, true); + + const removed = await service.remove({ params: { id: "x" } }); + assert.equal(calls[3].method, "DELETE"); + assert.match(calls[3].url, /id=x/); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs new file mode 100644 index 0000000..96f03f6 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs @@ -0,0 +1,136 @@ +/** + * Headless unit tests for the REST table model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.table.js, and an + * end to end path that feeds the model output through a RestService to confirm + * the legacy query parameter names and the PUT update survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.table.model.js")] }, + options + )); +} + +test("query params include order only when an order field is set", () => { + const { wxapp } = load(); + + const without = wxapp.tableModel.queryParams({ search: "x", page: 1, pageSize: 20 }); + assert.equal(without.search, "x"); + assert.equal(without.page, 1); + assert.equal(without.pageSize, 20); + assert.equal("orderBy" in without, false); + + const withOrder = wxapp.tableModel.queryParams({ orderBy: "name", orderDir: "desc" }); + assert.equal(withOrder.orderBy, "name"); + assert.equal(withOrder.orderDir, "desc"); +}); + +test("reduce response uses the response total and clamps the page", () => { + const { wxapp } = load(); + const patch = wxapp.tableModel.reduceResponse({ page: 5, pageSize: 10 }, { total: 12, rows: [] }); + + assert.equal(patch.total, 12); + assert.equal(patch.page, 1); + assert.equal(patch.error, null); +}); + +test("reduce response infers the total from page, size and received rows", () => { + const { wxapp } = load(); + const patch = wxapp.tableModel.reduceResponse({ page: 2, pageSize: 10 }, { rows: [{}, {}, {}] }); + + assert.equal(patch.total, 23); + assert.equal(patch.page, 2); +}); + +test("slice rows caps to the page size and tolerates non arrays", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tableModel.sliceRows([1, 2, 3, 4], 2), [1, 2]); + assert.deepEqual(wxapp.tableModel.sliceRows([1, 2], 5), [1, 2]); + assert.deepEqual(wxapp.tableModel.sliceRows(null, 5), []); +}); + +test("normalize columns projects fields and applies the sort", () => { + const { wxapp } = load(); + const columns = wxapp.tableModel.normalizeColumns({ + columns: [ + { id: "a", label: "A" }, + { id: "b", template: { type: "date", options: { fmt: 1 }, editable: true } } + ] + }, "a", "desc"); + + assert.equal(columns.length, 2); + assert.equal(columns[0].id, "a"); + assert.equal(columns[0].label, "A"); + assert.equal(columns[0].sort, "desc"); + assert.equal(columns[1].rendererType, "date"); + assert.equal(columns[1].rendererOptions.fmt, 1); + assert.equal(columns[1].rendererOptions.editable, true); + assert.equal(columns[1].sort, null); + + assert.deepEqual(wxapp.tableModel.normalizeColumns({}, null, null), []); +}); + +test("normalize rows recurses into children and slices to the page size", () => { + const { wxapp } = load(); + const rows = wxapp.tableModel.normalizeRows({ + rows: [ + { id: 1, cells: [{ content: "x" }], children: [{ id: 2 }] }, + { id: 3 }, + { id: 4 } + ] + }, 2); + + assert.equal(rows.length, 2); + assert.equal(rows[0].id, 1); + assert.equal(rows[0].expanded, true); + assert.equal(rows[0].children.length, 1); + assert.equal(rows[0].children[0].id, 2); + assert.equal(rows[0].children[0].parent, rows[0]); +}); + +test("model feeds a rest service for both the query and the put update", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ rows: [{ id: 1, cells: [] }], total: 1 }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create({ + name: "data", + kind: "rest", + baseUri: "/api/table", + method: "GET", + updateMethod: "PUT", + query: { search: "q", wql: "wql", filter: "f", page: "p", pageSize: "l", orderBy: "o", orderDir: "d" }, + response: { rows: "rows", total: "total" } + }); + const state = { search: "x", wql: "", filter: "", page: 0, pageSize: 50, orderBy: "name", orderDir: "asc" }; + + const queryResult = await service.query(wxapp.tableModel.queryParams(state)); + assert.equal(queryResult.ok, true); + assert.match(calls[0].url, /\/api\/table\?/); + assert.match(calls[0].url, /q=x/); + assert.match(calls[0].url, /o=name/); + assert.match(calls[0].url, /d=asc/); + + const updateResult = await service.update({ c: [{ id: "a", visible: true, width: 100 }] }); + assert.equal(updateResult.ok, true); + assert.equal(calls[1].method, "PUT"); + const sentBody = JSON.parse(calls[1].body); + assert.deepEqual(sentBody.c[0], { id: "a", visible: true, width: 100 }); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs new file mode 100644 index 0000000..3eba6f8 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs @@ -0,0 +1,105 @@ +/** + * Headless unit tests for the REST tile model helpers (View, State and Service). + * + * These cover the pure logic extracted from webexpress.webapp.tile.js: the + * legacy descriptor, the page slice, the total reduction and the item to tile + * mapping, plus an end to end path that loads tiles with a query and persists + * the state with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.tile.model.js")] }, + options + )); +} + +test("slice items caps to the page size and tolerates non arrays", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.tileModel.sliceItems([1, 2, 3], 2), [1, 2]); + assert.deepEqual(wxapp.tileModel.sliceItems([1, 2], 5), [1, 2]); + assert.deepEqual(wxapp.tileModel.sliceItems(null, 5), []); +}); + +test("query params always carry search, wql and filter and include order only when set", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.tileModel.queryParams({ search: "abc", page: 2, pageSize: 25 }), + { search: "abc", wql: "", filter: "", page: 2, pageSize: 25 }); + + assert.deepEqual( + wxapp.tileModel.queryParams({ orderBy: "label", orderDir: "desc" }), + { search: "", wql: "", filter: "", page: 0, pageSize: 50, orderBy: "label", orderDir: "desc" }); + + assert.deepEqual( + wxapp.tileModel.queryParams(null), + { search: "", wql: "", filter: "", page: 0, pageSize: 50 }); +}); + +test("reduce total uses the response total and otherwise infers it", () => { + const { wxapp } = load(); + assert.equal(wxapp.tileModel.reduceTotal({ total: 42 }, 10, 0, 50), 42); + assert.equal(wxapp.tileModel.reduceTotal({}, 10, 2, 50), 110); + assert.equal(wxapp.tileModel.reduceTotal({ total: "x" }, 3, 0, 50), 0); + assert.equal(wxapp.tileModel.reduceTotal(null, 4, 1, 50), 54); +}); + +test("map tiles projects field aliases and defaults the visibility", () => { + const { wxapp } = load(); + const tiles = wxapp.tileModel.mapTiles({ + items: [ + { id: "t1", title: "T", color: "red", visible: false, options: [1, 2] }, + { name: "N", content: "" } + ] + }); + + assert.equal(tiles.length, 2); + assert.equal(tiles[0].id, "t1"); + assert.equal(tiles[0].label, "T"); + assert.equal(tiles[0].colorCss, "red"); + assert.equal(tiles[0].visible, false); + assert.deepEqual(tiles[0].options, [1, 2]); + + assert.equal(tiles[1].id, null); + assert.equal(tiles[1].label, "N"); + assert.equal(tiles[1].html, ""); + assert.equal(tiles[1].visible, true); + assert.equal(tiles[1].options, null); + + assert.deepEqual(wxapp.tileModel.mapTiles(null), []); + assert.deepEqual(wxapp.tileModel.mapTiles({}), []); +}); + +test("model loads tiles and persists the state through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ items: [{ id: "t1", title: "Tile" }], total: 1 }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/tiles", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const tiles = wxapp.tileModel.mapTiles(loaded.data); + assert.equal(tiles[0].id, "t1"); + assert.equal(wxapp.tileModel.reduceTotal(loaded.data, tiles.length, 0, 50), 1); + + const saved = await service.update({ layout: "x" }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { layout: "x" }); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs new file mode 100644 index 0000000..2ce14db --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs @@ -0,0 +1,132 @@ +/** + * Headless tests for the REST tile control (View, State and Service). + * + * They instantiate the real webexpress.webapp.TileCtrl on the DOM stub with a + * stubbed WebUI tile base and assert that the control seeds its store from + * the wx-state island, queries through the configured data service with + * the default wire vocabulary, and routes search, filter and paging through + * the tile domain intents. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +// the webapp tile extends the static WebUI tile, which the engine harness does +// not load; the stub carries the members the webapp control calls +const TILE_BASE_STUB = ` + webexpress.webui.TileCtrl = class extends webexpress.webui.Ctrl { + render() { } + searchTiles() { return []; } + _markSearchDirty() { } + _dispatchSortEvent() { } + }; +`; + +function load(options) { + return loadEngine(Object.assign({ + bootstrap: TILE_BASE_STUB, + extraFiles: [ + webappAsset("webexpress.webapp.tile.model.js"), + webappAsset("webexpress.webapp.tile.js") + ] + }, options)); +} + +/** + * Builds a tile host element carrying the common GET/PUT service island and an + * optional state island. + * @param {object} engine - The loaded engine. + * @param {object} [state] - The optional initial state island. + * @returns {object} The host element. + */ +function createHost(engine, state) { + const element = engine.createElement("div"); + appendServiceIsland(engine.document, element, { + name: "data", kind: "rest", baseUri: "/api/tiles", method: "GET", updateMethod: "PUT" + }); + if (state) { + appendStateIsland(engine.document, element, state); + } + return element; +} + +/** + * Awaits the pending load turns of the control. + * @returns {Promise} A promise that resolves after the pending turns. + */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("tile seeds its store from the state island and queries with default wire names", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [{ id: "a", label: "A" }], total: 9 }) }; + }); + + const element = createHost(engine, { page: 1, pageSize: 2 }); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + assert.equal(urls.length, 1); + assert.match(urls[0], /\/api\/tiles\?/); + assert.match(urls[0], /p=1/); + assert.match(urls[0], /l=2/); + assert.match(urls[0], /q=/); + assert.match(urls[0], /f=/); + assert.equal(tile._page, 1); + assert.equal(tile._totalRecords, 9); +}); + +test("tile search dispatches the tile/search intent and reloads the first page", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [], total: 0 }) }; + }); + + const element = createHost(engine, { page: 4 }); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + tile.search("guybrush"); + await settle(); + + assert.equal(urls.length, 2); + assert.match(urls[1], /q=guybrush/); + assert.match(urls[1], /p=0/); + assert.equal(tile._search, "guybrush"); + assert.equal(tile._page, 0); +}); + +test("tile paging and filter dispatch their intents through the store", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [], total: 0 }) }; + }); + + const element = createHost(engine); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + tile.paging(3); + await settle(); + assert.match(urls[1], /p=3/); + assert.equal(tile._page, 3); + + tile.filter("insult"); + await settle(); + assert.match(urls[2], /f=insult/); + assert.match(urls[2], /p=0/); + assert.equal(tile._filter, "insult"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs new file mode 100644 index 0000000..69e01e7 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs @@ -0,0 +1,81 @@ +/** + * Headless unit tests for the watcher model helpers (View, State and Service). + * + * These cover the pure logic extracted from webexpress.webapp.watcher.js, namely + * the list normalisation, the candidate filtering and the removal helpers, plus + * an end to end path that loads watchers with a query, adds one with a create + * and deletes one with a remove through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.watcher.model.js")] }, + options + )); +} + +test("normalize list passes an array through and defaults to empty", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.watcherModel.normalizeList([{ id: "a" }]), [{ id: "a" }]); + assert.deepEqual(wxapp.watcherModel.normalizeList(null), []); + assert.deepEqual(wxapp.watcherModel.normalizeList({ id: "a" }), []); +}); + +test("candidates excludes existing watchers and tolerates non arrays", () => { + const { wxapp } = load(); + const watchers = [{ id: "u1" }, { id: "u2" }]; + const users = [{ id: "u1" }, { id: "u3" }, { id: "u2" }, { id: "u4" }]; + + assert.deepEqual(wxapp.watcherModel.candidates(watchers, users).map(u => u.id), ["u3", "u4"]); + assert.deepEqual(wxapp.watcherModel.candidates(null, users).map(u => u.id), ["u1", "u3", "u2", "u4"]); + assert.deepEqual(wxapp.watcherModel.candidates(watchers, null), []); +}); + +test("remove path and remove by id drop the matching watcher", () => { + const { wxapp } = load(); + assert.equal(wxapp.watcherModel.removePath("a b"), "/a%20b"); + + const list = [{ id: "u1" }, { id: "u2" }, { id: "u3" }]; + assert.deepEqual(wxapp.watcherModel.removeById(list, "u2").map(u => u.id), ["u1", "u3"]); + assert.deepEqual(wxapp.watcherModel.removeById(null, "u2"), []); +}); + +test("model loads, adds and removes a watcher through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => [{ id: "u1", name: "Ann" }] }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ id: "u2", name: "Bob" }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/watchers", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(wxapp.watcherModel.normalizeList(loaded.data).map(u => u.id), ["u1"]); + + const created = await service.create({ userId: "u2" }); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { userId: "u2" }); + assert.equal(created.data.id, "u2"); + + const removed = await service.remove({ path: wxapp.watcherModel.removePath("u1") }); + assert.equal(calls[2].method, "DELETE"); + assert.equal(calls[2].url.endsWith("/u1"), true); + assert.equal(removed.ok, true); + assert.equal(removed.data, null); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs b/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs new file mode 100644 index 0000000..79c6451 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs @@ -0,0 +1,88 @@ +/** + * Headless tests for the watcher control after it was lifted onto the Component + * base (View, State and Service). They instantiate the real control file in the + * harness (alongside its model) and assert that it extends Component, seeds its + * watchers from the wx-state island and skips the network load in that + * case, and otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.watcher.model.js"), + webappAsset("webexpress.webapp.watcher.js") + ] + }, + options + )); +} + +/** + * Awaits the asynchronous load and the batched store notification. + * @returns {Promise} Resolves after the macrotask and microtask queues drain. + */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("watcher extends the component base", () => { + const { wxapp, createElement, setFetch, document } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => [] })); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/watchers", method: "GET", updateMethod: "PUT" }); + + const ctrl = new wxapp.WatcherCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("watcher seeds its watchers from the wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => [] }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/watchers", method: "GET", updateMethod: "PUT" }); + appendStateIsland(document, element, { watchers: [{ id: "u1", name: "Ann", initials: "AN" }] }); + + const ctrl = new wxapp.WatcherCtrl(element); + + // the store is seeded synchronously, so the value is available at once + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "u1"); + + // the avatar row is rendered from the seeded state on mount + assert.equal(ctrl._row.childNodes.length, 1); + + // the seed avoids the round trip + await settle(); + assert.equal(fetchCount, 0); +}); + +test("watcher loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch, document } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => [{ id: "u9", name: "Bob" }] }; }); + + const element = createElement("div"); + appendServiceIsland(document, element, { name: "data", kind: "rest", baseUri: "/api/watchers", method: "GET", updateMethod: "PUT" }); + + const ctrl = new wxapp.WatcherCtrl(element); + assert.equal(ctrl.value.length, 0); + + await settle(); + + assert.equal(fetchCount, 1); + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "u9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs new file mode 100644 index 0000000..94c7901 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs @@ -0,0 +1,108 @@ +/** + * Headless unit tests for the workflow editor model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.workflow.editor.js: + * the legacy descriptor, the meta and catalog normalisation, the wire format + * read (nodes/states and edges/transitions aliases with source/target mapping) + * and the wire payload build, plus an end to end path that loads the workflow + * with a query and persists it with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.workflow.editor.model.js")] }, + options + )); +} + +test("normalize meta and catalog read fields and default them", () => { + const { wxapp } = load(); + const meta = wxapp.workflowEditorModel.normalizeMeta({ id: "w1", name: "W" }); + assert.equal(meta.id, "w1"); + assert.equal(meta.name, "W"); + assert.equal(meta.state, ""); + assert.equal(meta.version, ""); + + const cat = wxapp.workflowEditorModel.normalizeCatalog({ guards: [{ id: "g" }] }); + assert.deepEqual(cat.guards, [{ id: "g" }]); + assert.deepEqual(cat.validations, []); + assert.deepEqual(cat.postfunctions, []); + assert.deepEqual(wxapp.workflowEditorModel.normalizeCatalog(null).guards, []); +}); + +test("from wire format accepts aliases and maps source and target", () => { + const { wxapp } = load(); + const resp = { states: [{ id: "n1" }], transitions: [{ id: "e1", source: "n1", target: "n2" }] }; + const graph = wxapp.workflowEditorModel.fromWireFormat(resp); + + assert.equal(graph.nodes[0].id, "n1"); + assert.equal(graph.edges[0].from, "n1"); + assert.equal(graph.edges[0].to, "n2"); + assert.notEqual(graph.nodes[0], resp.states[0]); + + const resp2 = { nodes: [{ id: "x" }], states: [{ id: "y" }], edges: [{ id: "e", from: "a", to: "b" }] }; + const g2 = wxapp.workflowEditorModel.fromWireFormat(resp2); + assert.equal(g2.nodes[0].id, "x"); + assert.equal(g2.edges[0].from, "a"); + + assert.deepEqual(wxapp.workflowEditorModel.fromWireFormat({}), { nodes: [], edges: [] }); + assert.deepEqual(wxapp.workflowEditorModel.fromWireFormat(null), { nodes: [], edges: [] }); +}); + +test("to wire payload mirrors nodes and edges under states and transitions", () => { + const { wxapp } = load(); + const nodes = [{ id: "n1" }]; + const edges = [{ id: "e1" }]; + const p = wxapp.workflowEditorModel.toWirePayload( + { id: "w1", name: "W", state: "draft", version: "1", description: "d" }, + { nodes: nodes, edges: edges } + ); + + assert.equal(p.id, "w1"); + assert.equal(p.name, "W"); + assert.equal(p.description, "d"); + assert.equal(p.nodes, nodes); + assert.equal(p.states, nodes); + assert.equal(p.edges, edges); + assert.equal(p.transitions, edges); + + const p2 = wxapp.workflowEditorModel.toWirePayload(null, null); + assert.deepEqual(p2.nodes, []); + assert.deepEqual(p2.states, []); +}); + +test("model loads and persists the workflow through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ id: "w1", states: [{ id: "n1" }], transitions: [{ id: "e1", source: "n1", target: "n2" }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create({ name: "data", kind: "rest", baseUri: "/api/wf", method: "GET", updateMethod: "PUT" }); + + const loaded = await service.query({}); + const meta = wxapp.workflowEditorModel.normalizeMeta(loaded.data); + const graph = wxapp.workflowEditorModel.fromWireFormat(loaded.data); + assert.equal(meta.id, "w1"); + assert.equal(graph.edges[0].from, "n1"); + + const saved = await service.update(wxapp.workflowEditorModel.toWirePayload(meta, graph)); + assert.equal(calls[1].method, "PUT"); + const sentBody = JSON.parse(calls[1].body); + assert.equal(sentBody.states[0].id, "n1"); + assert.equal(sentBody.transitions[0].from, "n1"); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/workflow.editor.test.mjs b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.test.mjs new file mode 100644 index 0000000..b6461fa --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.test.mjs @@ -0,0 +1,222 @@ +/** + * Headless tests for the workflow editor control (View, State and Service). + * + * They instantiate the real webexpress.webapp.WorkflowEditorCtrl on the DOM + * stub with a stubbed WebUI graph editor base and assert the id contract of + * the REST integration: the workflow id authored in the wx-state island + * rides along as the wire query parameter on the load GET and the autosave + * PUT, and a load that resolves after destroy leaves the editor untouched. + * The pure wire-format mapping is covered by workflow.editor.model.test.mjs. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset, appendServiceIsland, appendStateIsland } from "./harness.mjs"; + +// the workflow editor extends the WebUI graph editor, which the engine harness +// does not load; the stub carries the members the workflow control calls. The +// model setter mirrors the real viewer chain (normalize, then materialize the +// visual nodes the autosave merges positions back from). The keyboard +// shortcuts attach to window, which the DOM stub does not provide. +const GRAPH_EDITOR_BASE_STUB = ` + var window = { + addEventListener() { }, + removeEventListener() { } + }; + webexpress.webui.GraphEditorCtrl = class extends webexpress.webui.Ctrl { + constructor(element) { + super(element); + // the real graph viewer base clears the host while building its + // svg canvas, so the islands must be consumed before super + element.innerHTML = ""; + this._toolbarContainer = null; + this._selectedNodeId = null; + this._selectedEdgeId = null; + this._model = { nodes: [], edges: [] }; + this._nodes = []; + } + get model() { return this._model; } + set model(val) { + this._model = this._normalizeModel(val); + this._nodes = (this._model.nodes || []).map((n) => ({ id: n.id, x: n.x, y: n.y })); + } + _normalizeModel(model) { + const m = model || {}; + return { + nodes: (m.nodes || []).map((n) => Object.assign({}, n)), + edges: (m.edges || []).map((e) => Object.assign({}, e)) + }; + } + _emitChangeSafe() { } + _updateToolbarState() { } + _deselectAll() { } + }; +`; + +function load(options) { + const engine = loadEngine(Object.assign({ + bootstrap: GRAPH_EDITOR_BASE_STUB, + extraFiles: [ + webappAsset("webexpress.webapp.workflow.editor.model.js"), + webappAsset("webexpress.webapp.workflow.editor.js") + ] + }, options)); + + // the properties panel renders through querySelector, which the DOM stub + // does not implement; the spy keeps the call count observable instead + const propsPanelCalls = { count: 0 }; + engine.wxapp.WorkflowEditorCtrl.prototype._renderPropsPanel = function () { + propsPanelCalls.count++; + }; + + return { engine, propsPanelCalls }; +} + +/** + * Builds a workflow host element carrying the common GET/PUT service island + * and an optional state island with the authored workflow id. + * @param {object} engine - The loaded engine. + * @param {object} [state] - The optional initial state island. + * @returns {object} The host element. + */ +function createHost(engine, state) { + const element = engine.createElement("div"); + appendServiceIsland(engine.document, element, { + name: "data", kind: "rest", baseUri: "/api/workflow", method: "GET", updateMethod: "PUT" + }); + if (state) { + appendStateIsland(engine.document, element, state); + } + return element; +} + +/** + * Builds the RestApiWorkflowResult shaped response the load consumes. + * @returns {object} The response payload. + */ +function workflowResponse() { + return { + id: "wf1", + name: "Monkey Island Quest", + version: "1", + description: "A pirate's journey.", + states: [ + { id: "todo", label: "Quest Board", x: 100, y: 120 }, + { id: "done", label: "Legendary Status", x: 850, y: 200 } + ], + transitions: [ + { id: "t1", from: "todo", to: "done", label: "Achieve Victory" } + ], + guards: [], + validations: [], + postfunctions: [] + }; +} + +/** + * Awaits the pending load turns of the control. + * @returns {Promise} A promise that resolves after the pending turns. + */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("workflow editor loads with the workflow id from the state island", async () => { + const { engine, propsPanelCalls } = load(); + const requests = []; + engine.setFetch(async (url, init) => { + requests.push({ url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, json: async () => workflowResponse() }; + }); + + const element = createHost(engine, { id: "monkeyisland" }); + const editor = new engine.wxapp.WorkflowEditorCtrl(element); + await settle(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].method, "GET"); + assert.equal(requests[0].url, "/api/workflow?id=monkeyisland"); + assert.equal(editor._workflowId, "monkeyisland"); + assert.equal(editor._meta.id, "wf1"); + assert.equal(editor._meta.name, "Monkey Island Quest"); + assert.equal(editor._model.nodes.length, 2); + assert.equal(editor._model.edges.length, 1); + assert.equal(editor._isLoading, false); + assert.equal(element.classList.contains("placeholder-glow"), false); + // once from the constructor, once after the load completed + assert.equal(propsPanelCalls.count, 2); +}); + +test("workflow editor loads without an id when no state island is authored", async () => { + const { engine } = load(); + const requests = []; + engine.setFetch(async (url, init) => { + requests.push({ url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, json: async () => workflowResponse() }; + }); + + const element = createHost(engine); + const editor = new engine.wxapp.WorkflowEditorCtrl(element); + await settle(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "/api/workflow"); + assert.equal(editor._workflowId, ""); +}); + +test("workflow editor autosave puts the wire payload keyed by the authored id", async () => { + const { engine } = load(); + const requests = []; + engine.setFetch(async (url, init) => { + requests.push({ url, method: (init && init.method) || "GET", body: init && init.body }); + return { ok: true, status: 200, json: async () => workflowResponse() }; + }); + + const element = createHost(engine, { id: "monkeyisland" }); + const editor = new engine.wxapp.WorkflowEditorCtrl(element); + await settle(); + + // a drag only moves the visual node; the save must merge the position back + editor._nodes[0].x = 999; + editor._flushSave(); + await settle(); + + assert.equal(requests.length, 2); + assert.equal(requests[1].method, "PUT"); + assert.equal(requests[1].url, "/api/workflow?id=monkeyisland"); + + const body = JSON.parse(requests[1].body); + // the body id is the server-issued meta id, the query id stays the authored one + assert.equal(body.id, "wf1"); + assert.equal(body.states[0].x, 999); + assert.equal(body.states.length, 2); + assert.equal(body.transitions.length, 1); + assert.equal(body.transitions[0].from, "todo"); +}); + +test("workflow editor ignores a load that resolves after destroy", async () => { + const { engine, propsPanelCalls } = load(); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + engine.setFetch(async () => { + await gate; + return { ok: true, status: 200, json: async () => workflowResponse() }; + }); + + const element = createHost(engine, { id: "monkeyisland" }); + const editor = new engine.wxapp.WorkflowEditorCtrl(element); + + editor.destroy(); + release(); + await settle(); + + assert.equal(editor._destroyed, true); + assert.equal(editor._model.nodes.length, 0); + assert.equal(editor._meta.id, ""); + // only the constructor render, the late response must not render again + assert.equal(propsPanelCalls.count, 1); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/wql.prompt.test.mjs b/src/WebExpress.WebApp.Test/JsTest/wql.prompt.test.mjs new file mode 100644 index 0000000..b46718d --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/wql.prompt.test.mjs @@ -0,0 +1,378 @@ +/** + * Headless unit tests for the WQL prompt control + * (webexpress.webapp.wql.prompt.js), focused on the behaviour that broke in + * the wild: the newline handling of the highlighted contenteditable (a + * trailing newline used to accumulate on every input cycle), the smart + * formatting per analyze expression type (the type names must match the + * lower-cased WqlExpressionType enum names of the analyze endpoint), the + * invalid-state wiring from the analyze response, history navigation and + * submission. + * + * Run with Node 18 or newer from the JsTest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import vm from "node:vm"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDocument } from "./dom-stub.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const promptJs = path.resolve(here, "..", "..", "WebExpress.WebApp", "Assets", "js", "webexpress.webapp.wql.prompt.js"); + +// a minimal Ctrl base defined inside the context, mirroring the parts of +// webexpress.webui.Ctrl the prompt relies on +const BOOTSTRAP = ` + var webexpress = { webui: {}, webapp: {} }; + webexpress.webui.Ctrl = class { + constructor(element) { + this._element = element; + this._dispatched = []; + } + _i18n(key) { return key; } + _dispatch(type, detail) { this._dispatched.push({ type: type, detail: detail }); } + }; + webexpress.webui.Controller = { registerClass: function () { } }; + webexpress.webui.Event = { CHANGE_FILTER_EVENT: "wx-change-filter" }; + // no highlighter registered: the prompt falls back to plain text + webexpress.webui.Syntax = { get: function () { return null; } }; +`; + +/** + * Loads the prompt control into a fresh vm context backed by the DOM stub. + * Timers are capture-only so the tests drive every code path directly. + * @param {object} [options] - Optional overrides: response(url) for the api. + * @returns {object} The control instance, document, recorded requests. + */ +function loadPrompt(options = {}) { + const document = createDocument(); + document.createRange = () => ({ + setStart() { }, + setEnd() { }, + collapse() { }, + selectNodeContents() { }, + cloneRange() { return this; }, + toString() { return ""; } + }); + + const selection = { rangeCount: 0, removeAllRanges() { }, addRange() { } }; + const requests = []; + + const sandbox = { + console, + URL, + AbortController, + // capture-only timers: history load and debounce never fire on their own + setTimeout: () => 0, + clearTimeout: () => { }, + document, + window: { getSelection: () => selection, location: { origin: "http://localhost" } }, + Node: { ELEMENT_NODE: 1, TEXT_NODE: 3 } + }; + vm.createContext(sandbox); + vm.runInContext(BOOTSTRAP, sandbox, { filename: "bootstrap" }); + + sandbox.webexpress.webapp.ServiceRegistry = { + // the prompt resolves its endpoint from the wx-service island; the + // stub serves the configured data service without the full engine + fromElement: () => ({ data: { baseUri: "/api/items" } }), + request: async (url, init) => { + requests.push(url); + return options.response ? options.response(url) : { ok: false }; + } + }; + + vm.runInContext(fs.readFileSync(promptJs, "utf8"), sandbox, { filename: promptJs }); + + const host = document.createElement("div"); + const ctrl = new sandbox.webexpress.webapp.WqlPromptCtrl(host); + + return { ctrl, document, requests }; +} + +/** + * Creates a highlighted line span the way the wql highlighter renders lines. + * @param {object} document - The document stub. + * @param {string} text - The line text. + * @returns {object} The line element. + */ +function lineSpan(document, text) { + const span = document.createElement("span"); + span.className = "wx-code-line"; + span.textContent = text; + return span; +} + +// --------------------------------------------------------------------------- +// text extraction from the highlighted contenteditable +// --------------------------------------------------------------------------- + +test("a single highlighted line yields its text without a trailing newline", () => { + const { ctrl, document } = loadPrompt(); + ctrl._input.appendChild(lineSpan(document, "Text = 1")); + + assert.equal(ctrl._getInputText(), "Text = 1"); +}); + +test("multiple highlighted lines are separated by exactly one newline", () => { + const { ctrl, document } = loadPrompt(); + ctrl._input.appendChild(lineSpan(document, "Text = 1")); + ctrl._input.appendChild(lineSpan(document, "order by Text")); + + assert.equal(ctrl._getInputText(), "Text = 1\norder by Text"); +}); + +test("the text stays stable across repeated highlight cycles", () => { + const { ctrl, document } = loadPrompt(); + ctrl._input.appendChild(lineSpan(document, "Text = 1")); + + // the old implementation grew a trailing newline on every cycle + for (let i = 0; i < 3; i++) { + ctrl._highlightSyntax(); + assert.equal(ctrl._getInputText(), "Text = 1", `cycle ${i + 1}`); + } +}); + +test("line breaks and zero-width spaces in raw content are handled", () => { + const { ctrl, document } = loadPrompt(); + ctrl._input.appendChild(document.createTextNode("a​")); + ctrl._input.appendChild(document.createElement("br")); + ctrl._input.appendChild(document.createTextNode("b")); + + assert.equal(ctrl._getInputText(), "a\nb"); +}); + +// --------------------------------------------------------------------------- +// suggestion application (smart formatting per analyze expression type) +// --------------------------------------------------------------------------- + +/** + * Prepares the control for an _applySuggestion call with a fixed context. + * @param {object} ctrl - The prompt control. + * @param {string} type - The lower-cased expression type. + * @param {boolean} quoted - Whether the cursor is inside a string literal. + * @returns {Array} The captured replacement calls [start, end, text]. + */ +function armApply(ctrl, type, quoted) { + const calls = []; + ctrl._getInputText = () => "Text = "; + ctrl._getCursorOffset = () => 7; + ctrl._insertReplacementAt = (start, end, text) => calls.push([start, end, text]); + ctrl._currentContext = { type, prefix: "", tokenStart: 7, tokenEnd: 7, quoted: !!quoted }; + return calls; +} + +test("a parameter suggestion is quoted automatically", () => { + const { ctrl } = loadPrompt(); + const calls = armApply(ctrl, "parameter", false); + + ctrl._applySuggestion("Helena"); + + assert.deepEqual(calls, [[7, 7, '"Helena" ']]); +}); + +test("a parameter suggestion inside an open literal is not quoted again", () => { + const { ctrl } = loadPrompt(); + const calls = armApply(ctrl, "parameter", true); + + ctrl._applySuggestion("Helena"); + + assert.deepEqual(calls, [[7, 7, "Helena "]]); +}); + +test("an open parenthesis suggestion starts a quoted set", () => { + const { ctrl } = loadPrompt(); + const calls = armApply(ctrl, "openparenthesis", false); + + ctrl._applySuggestion("Helena"); + + assert.deepEqual(calls, [[7, 7, '("Helena" ']]); +}); + +test("a separator suggestion inserts a comma with a space", () => { + const { ctrl } = loadPrompt(); + const calls = armApply(ctrl, "separator", false); + + ctrl._applySuggestion(","); + + assert.deepEqual(calls, [[7, 7, ", "]]); +}); + +test("an operator suggestion is inserted as-is with a trailing space", () => { + const { ctrl } = loadPrompt(); + const calls = armApply(ctrl, "operator", false); + + ctrl._applySuggestion("!="); + + assert.deepEqual(calls, [[7, 7, "!= "]]); +}); + +// --------------------------------------------------------------------------- +// analyze round trip (context, suggestions, error wiring) +// --------------------------------------------------------------------------- + +test("the live analysis never raises the error state while typing", async () => { + // the syntax check runs on submit only; during typing the prompt offers + // the next tokens even when the statement is not (yet) valid + const { ctrl } = loadPrompt({ + response: () => ({ + ok: true, + data: { isValidSoFar: false, errorMessage: "broken query", suggestions: ["~", "="] } + }) + }); + ctrl._input.textContent = "id "; + + await ctrl._refreshContextAndSuggestions(); + + assert.equal(ctrl._lastError, null, "no error while typing"); + assert.equal(ctrl._input.classList.contains("is-invalid"), false); + assert.deepEqual(ctrl._suggestions, ["~", "="], "suggestions are still offered"); +}); + +test("an incomplete statement does not raise the error state", async () => { + // the server omits the error message for input that only ends mid-statement + // (e.g. an attribute without an operator yet, fresh from a tab suggestion) + const { ctrl } = loadPrompt({ + response: () => ({ + ok: true, + data: { + isValidSoFar: false, + errorMessage: null, + currentExpressionType: "Operator", + suggestions: ["~", "=", "!="] + } + }) + }); + ctrl._input.textContent = "id "; + + await ctrl._refreshContextAndSuggestions(); + + assert.equal(ctrl._lastError, null, "no error is shown while typing"); + assert.equal(ctrl._input.classList.contains("is-invalid"), false); + assert.equal(ctrl._currentContext.type, "operator", "the next expected token is offered"); + assert.deepEqual(ctrl._suggestions, ["~", "=", "!="]); +}); + +test("a valid analyze response builds the context and clears the error state", async () => { + const { ctrl, requests } = loadPrompt({ + response: () => ({ + ok: true, + data: { + isValidSoFar: true, + currentExpressionType: "Attribute", + prefix: "Hel", + attribute: "Text", + quoted: false, + suggestions: ["Hello", "Helena"] + } + }) + }); + ctrl._setInvalidState("stale error"); + ctrl._input.textContent = "Hel"; + + await ctrl._refreshContextAndSuggestions(); + + assert.equal(ctrl._lastError, null, "the error state is cleared"); + assert.equal(ctrl._input.classList.contains("is-invalid"), false); + assert.equal(ctrl._currentContext.type, "attribute"); + assert.equal(ctrl._currentContext.tokenStart, 0, "the prefix anchors the token start"); + assert.equal(ctrl._currentContext.attribute, "Text"); + assert.deepEqual(ctrl._suggestions, ["Hello", "Helena"]); + assert.ok(requests[0].indexOf("/api/items/analyze?wql=Hel") !== -1, "the analyze endpoint is called"); +}); + +test("the history endpoint fills the history buffer", async () => { + const { ctrl } = loadPrompt({ + response: () => ({ ok: true, data: { history: ["a = 1", "b = 2"] } }) + }); + + await ctrl._loadHistoryFromApi(); + + assert.deepEqual(ctrl._history, ["a = 1", "b = 2"]); + assert.equal(ctrl._historyIndex, 2); +}); + +// --------------------------------------------------------------------------- +// submission and history navigation +// --------------------------------------------------------------------------- + +test("submitting dispatches the filter event and deduplicates the history", async () => { + const { ctrl } = loadPrompt({ + response: () => ({ ok: true, data: { isValidSoFar: true } }) + }); + ctrl._input.textContent = " Text = 'x' "; + + await ctrl._submitInput(); + await ctrl._submitInput(); + + assert.deepEqual(ctrl._history, ["Text = 'x'"], "the same query is stored once"); + const events = ctrl._dispatched.filter((e) => e.type === "wx-change-filter"); + assert.equal(events.length, 2); + assert.equal(events[0].detail.value, "Text = 'x'"); +}); + +test("submitting an invalid statement shows the error and is not dispatched", async () => { + const { ctrl } = loadPrompt({ + response: () => ({ + ok: true, + data: { isValidSoFar: false, errorMessage: "broken query" } + }) + }); + ctrl._input.textContent = "Text !! x"; + + await ctrl._submitInput(); + + assert.equal(ctrl._lastError, "broken query", "the submit reports the syntax error"); + assert.equal(ctrl._input.classList.contains("is-invalid"), true); + assert.deepEqual(ctrl._history, [], "an invalid query does not enter the history"); + assert.equal(ctrl._dispatched.filter((e) => e.type === "wx-change-filter").length, 0, + "no filter event is dispatched"); +}); + +test("a validation outage does not block the submission", async () => { + const { ctrl } = loadPrompt({ + response: () => { throw new Error("offline"); } + }); + ctrl._input.textContent = "Text = 'x'"; + + await ctrl._submitInput(); + + assert.equal(ctrl._dispatched.filter((e) => e.type === "wx-change-filter").length, 1, + "the query is submitted anyway"); +}); + +test("history navigation restores entries and keeps the unsent draft", () => { + const { ctrl } = loadPrompt(); + ctrl._history = ["a = 1", "b = 2"]; + ctrl._historyIndex = 2; + ctrl._input.textContent = "draft"; + + ctrl._navigateHistory(-1); + assert.equal(ctrl._getInputText(), "b = 2"); + + ctrl._navigateHistory(-1); + assert.equal(ctrl._getInputText(), "a = 1"); + + ctrl._navigateHistory(1); + ctrl._navigateHistory(1); + assert.equal(ctrl._getInputText(), "draft", "the unsent draft returns"); +}); + +test("the clear button resets the prompt to a fresh input line", () => { + const { ctrl } = loadPrompt(); + ctrl._history = ["a = 1"]; + ctrl._historyIndex = 0; + ctrl._input.textContent = "a = 1"; + ctrl._setInvalidState("stale"); + + assert.ok(ctrl._clearBtn, "the clear button exists"); + ctrl._clearBtn.dispatchEvent({ type: "click" }); + + assert.equal(ctrl._getInputText(), ""); + assert.equal(ctrl._historyIndex, 1, "the index points behind the history"); + assert.equal(ctrl._lastError, null); + assert.equal(ctrl._input.classList.contains("is-invalid"), false); +}); diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs index d3274d2..6d08fb7 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestDashboard : FragmentControlRestDashboard + public sealed class TestFragmentControlDataDashboard : FragmentControlDataDashboard { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestDashboard(IFragmentContext fragmentContext) + public TestFragmentControlDataDashboard(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs index e1a599c..c286570 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestWorkflow : FragmentControlRestWorkflow + public sealed class TestFragmentControlDataDropdown : FragmentControlDataDropdown { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestWorkflow(IFragmentContext fragmentContext) + public TestFragmentControlDataDropdown(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs index e1e6b9f..d20c688 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormDelete : FragmentControlRestFormDelete + public sealed class TestFragmentControlDataFormDelete : FragmentControlDataFormDelete { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormDelete(IFragmentContext fragmentContext) + public TestFragmentControlDataFormDelete(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs index 8a2a82d..8dbf28c 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestDropdown : FragmentControlRestDropdown + public sealed class TestFragmentControlDataFormEdit : FragmentControlDataFormEdit { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestDropdown(IFragmentContext fragmentContext) + public TestFragmentControlDataFormEdit(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs index 20f1180..c8e2ef0 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormNew : FragmentControlRestFormAdd + public sealed class TestFragmentControlDataFormNew : FragmentControlDataFormAdd { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormNew(IFragmentContext fragmentContext) + public TestFragmentControlDataFormNew(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs index 1ce5cae..e0c58e0 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestQuickfilter : FragmentControlRestQuickfilter + public sealed class TestFragmentControlDataQuickfilter : FragmentControlDataQuickfilter { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestQuickfilter(IFragmentContext fragmentContext) + public TestFragmentControlDataQuickfilter(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs similarity index 76% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs index b4c3f5e..3e3f743 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section] [Scope] - public sealed class TestFragmentControlRestTabTemplate : FragmentControlRestTabTemplate + public sealed class TestFragmentControlDataTabTemplate : FragmentControlDataTabTemplate { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestTabTemplate(IFragmentContext fragmentContext) + public TestFragmentControlDataTabTemplate(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs similarity index 78% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs index 42d5dd1..4406a57 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestTable : FragmentControlRestTable + public sealed class TestFragmentControlDataTable : FragmentControlDataTable { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestTable(IFragmentContext fragmentContext) + public TestFragmentControlDataTable(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs similarity index 78% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs index c94b88c..04db11b 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestWizard : FragmentControlRestWizard + public sealed class TestFragmentControlDataWizard : FragmentControlDataWizard { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestWizard(IFragmentContext fragmentContext) + public TestFragmentControlDataWizard(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs index 9e63d5f..e372317 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormEdit : FragmentControlRestFormEdit + public sealed class TestFragmentControlDataWorkflow : FragmentControlDataWorkflow { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormEdit(IFragmentContext fragmentContext) + public TestFragmentControlDataWorkflow(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAdvancedSearch.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAdvancedSearch.cs index 90a7627..b96eb7b 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAdvancedSearch.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAdvancedSearch.cs @@ -1,5 +1,6 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -50,7 +51,7 @@ public void RestUri(string uriString, string expected) var visualTree = new VisualTreeControl(componentHub, context.PageContext); var control = new ControlAdvancedSearch() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs index 503313c..97d627d 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs @@ -1,4 +1,5 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestAvatarDropdown(id) + var control = new ControlDataAvatarDropdown(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/avatar", @"
")] + [InlineData("https://example.com/api/avatar", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestAvatarDropdown() + var control = new ControlDataAvatarDropdown() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs index d7823ad..decdd1b 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs @@ -1,4 +1,5 @@ using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -26,7 +27,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment(id) + var control = new ControlDataComment(id) { }; @@ -37,45 +38,22 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the comment control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/comments/INC-00123", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var context = UnitTestControlFixture.CreateRenderContextMock(); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the UsersUri property of the comment control. /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/users", @"
")] + [InlineData("https://example.com/api/users", @"
")] public void UsersUri(string uriString, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { - UsersUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.Rest("users").WithBaseUri(uriString).WithMethod("GET") : null }; // act @@ -99,7 +77,7 @@ public void CurrentUser(string user, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { CurrentUser = _ => user }; @@ -124,7 +102,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { Readonly = _ => readOnly }; @@ -147,16 +125,16 @@ public void ImageUploadUri() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { - ImageUploadUri = _ => new UriEndpoint("https://example.com/api/upload") + ServiceFactory = _ => DataServiceDescriptor.Rest("upload").WithBaseUri("https://example.com/api/upload").WithMethod("POST") }; // act var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -170,7 +148,7 @@ public void Categories() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment("c") + var control = new ControlDataComment("c") { Categories = _ => "{\"general\":{\"id\":\"general\"}}" }; @@ -192,12 +170,14 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment("c1") + var control = new ControlDataComment("c1") { - RestUri = _ => new UriEndpoint("https://example.com/api/comments/INC-1"), - UsersUri = _ => new UriEndpoint("https://example.com/api/users"), + ServiceFactories = + { + _ => DataServiceDescriptor.Rest("users").WithBaseUri("https://example.com/api/users").WithMethod("GET"), + _ => DataServiceDescriptor.Rest("upload").WithBaseUri("https://example.com/api/upload").WithMethod("POST") + }, CurrentUser = _ => "u-alice", - ImageUploadUri = _ => new UriEndpoint("https://example.com/api/upload"), Readonly = _ => false }; @@ -205,7 +185,7 @@ public void AllAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -219,7 +199,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs index 2041b15..97aaa0c 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api dashboard control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestDashboard + public class UnitTestControlDataDashboard { /// /// Tests the id property of the api dashboard control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard(id) + var control = new ControlDataDashboard(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api dashboard control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the column capability flags emit their data attributes only when true. @@ -76,7 +52,7 @@ public void ColumnFlags(bool editable, bool movable, bool deletable, string expe var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard() + var control = new ControlDataDashboard() { EditableColumn = _ => editable, MovableColumn = _ => movable, diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs similarity index 88% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs index 96655b8..ebeae48 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs @@ -1,4 +1,5 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST dropdown control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestDropdown + public class UnitTestControlDataDropdown { /// /// Tests the id property of the REST dropdown control. @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown(id) + var control = new ControlDataDropdown(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act @@ -74,7 +75,7 @@ public void MaxItems(int maxItems, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { MaxItems = _ => maxItems }; @@ -100,7 +101,7 @@ public void SearchPlaceholder(string searchPlaceholder, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { SearchPlaceholder = _ => searchPlaceholder }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs similarity index 92% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs index 9b3bf3c..b189e80 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs @@ -1,5 +1,6 @@ using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -11,7 +12,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the rest form control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestForm + public class UnitTestControlDataForm { /// /// Tests the id property of the rest form control. @@ -25,7 +26,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(id) + var control = new ControlDataForm(id) { }; @@ -54,7 +55,7 @@ public void BackgroundColor(TypeColorBackground color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { BackgroundColor = _ => new PropertyColorBackground(color) }; @@ -78,7 +79,7 @@ public void Name(string name, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { Name = _ => name }; @@ -91,21 +92,20 @@ public void Name(string name, string expected) } /// - /// Tests the uri property of the rest form control. + /// Tests the declared data service of the rest form control. /// [Theory] [InlineData(null, @"
*
")] - [InlineData("", @"
*
")] - [InlineData("http://localhost:8080/webui", @"
*
")] - public void Uri(string uri, string expected) + [InlineData("http://localhost:8080/webui", @"
*
")] + public void DataService(string uri, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { - Uri = _ => uri is not null ? new UriEndpoint(uri) : null + ServiceFactory = uri is not null ? _ => DataServiceDescriptor.FormData(uri) : null }; // act @@ -131,7 +131,7 @@ public void Method(RequestMethod method, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(null) + var control = new ControlDataForm(null) { Method = _ => method }; @@ -157,7 +157,7 @@ public void Mode(TypeRestFormMode mode, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(null) + var control = new ControlDataForm(null) { Mode = _ => mode.ToMode() }; @@ -181,7 +181,7 @@ public void FormLayout(TypeLayoutForm formLayout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { FormLayout = _ => formLayout }; @@ -206,7 +206,7 @@ public void ItemLayout(TypeLayoutFormItem itemLayout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { ItemLayout = _ => itemLayout }; @@ -233,7 +233,7 @@ public void Justify(TypeJustifiedFlex justify, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { Justify = _ => justify, }; @@ -255,7 +255,7 @@ public void EmptyForm() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(); + var control = new ControlDataForm(); // act var html = control.Render(context, visualTree)?.ToString().Trim(); @@ -274,7 +274,7 @@ public void EmptyFormChangeSubmitText() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(); + var control = new ControlDataForm(); control.AddPrimaryButton(new ControlFormItemButtonSubmit("") { Text = _ => "sendbutton" @@ -300,7 +300,7 @@ public void Value(string value, string expected) var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); var control = new ControlFormItemInputText(null); - var form = new ControlRestForm().Add(control).Initialize(renderContext => + var form = new ControlDataForm().Add(control).Initialize(renderContext => { renderContext.SetValue(control, new ControlFormInputValueString(value)); }); diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs similarity index 84% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs index 4e457ad..0c4750e 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs @@ -1,6 +1,6 @@ using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; -using WebExpress.WebCore.WebUri; +using WebExpress.WebApp.WebData; using WebExpress.WebUI.WebPage; namespace WebExpress.WebApp.Test.WebControl @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the form editor control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestFormEditor + public class UnitTestControlDataFormEditor { /// /// Tests the id property of the form editor control. @@ -23,7 +23,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor(id); + var control = new ControlDataFormEditor(id); // act var html = control.Render(context, visualTree); @@ -33,18 +33,19 @@ public void Id(string id, string expected) } /// - /// Tests that the rest uri property is rendered as a data attribute. + /// Tests that the declared data service is rendered as the wx-service + /// island element. /// [Fact] - public void RestUri() + public void DataService() { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { - RestUri = _ => new UriEndpoint("/api/1/FormStructure") + ServiceFactory = _ => DataServiceDescriptor.FormData("/api/1/FormStructure") }; // act @@ -52,7 +53,7 @@ public void RestUri() // validation AssertExtensions.EqualWithPlaceholders( - @"
", + @"
", html); } @@ -68,7 +69,7 @@ public void Preview(bool preview, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Preview = _ => preview }; @@ -95,7 +96,7 @@ public void Indent(int indent, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Indent = _ => indent }; @@ -119,7 +120,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Readonly = _ => readOnly }; @@ -138,13 +139,13 @@ public void Readonly(bool readOnly, string expected) public void Defaults() { // arrange - var control = new ControlRestFormEditor(); + var control = new ControlDataFormEditor(); // validation - Assert.Equal(ControlRestFormEditor._defaultIndent, control.Indent?.Invoke(null)); + Assert.Equal(ControlDataFormEditor._defaultIndent, control.Indent?.Invoke(null)); Assert.True(control.Preview?.Invoke(null)); Assert.False(control.Readonly?.Invoke(null) ?? false); - Assert.Null(control.RestUri?.Invoke(null)); + Assert.Null(control.ServiceFactory); } } } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs similarity index 84% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs index cbdb14e..894eb65 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs @@ -1,4 +1,5 @@ using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -10,7 +11,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST-backed check control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputCheck + public class UnitTestControlDataFormItemInputCheck { /// /// Tests the id property of the REST check control. @@ -25,7 +26,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(id) + var control = new ControlDataFormItemInputCheck(id) { }; @@ -48,7 +49,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck() + var control = new ControlDataFormItemInputCheck() { }; @@ -72,7 +73,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Name = _ => name }; @@ -99,7 +100,7 @@ public void Description(string description, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Description = _ => description }; @@ -124,7 +125,7 @@ public void Layout(TypeLayoutCheck layout, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Layout = _ => layout }; @@ -149,7 +150,7 @@ public void Inline(bool inline, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Inline = _ => inline }; @@ -163,13 +164,13 @@ public void Inline(bool inline, string expected) /// /// Tests the RestUri property of the REST check control. Verifies that - /// the configured endpoint is exposed via the data-uri attribute - /// on the root element so the client-side module can target it for + /// the configured endpoint is exposed via the wx-service island on the + /// root element so the client-side module can target it for /// GET (initial read) and POST (state changes). /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/check", @"
")] + [InlineData("https://example.com/api/check", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -177,9 +178,9 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act @@ -201,9 +202,9 @@ public void NoInitialValueOmitsDataValue() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck("chk") + var control = new ControlDataFormItemInputCheck("chk") { - RestUri = _ => new UriEndpoint("https://example.com/api/check") + ServiceFactory = _ => DataServiceDescriptor.QueryData("https://example.com/api/check") }; var form = new ControlForm().Add(control); @@ -212,12 +213,12 @@ public void NoInitialValueOmitsDataValue() // validation AssertExtensions.EqualWithPlaceholders( - @"*
*", + @"*
*", html); } /// - /// Tests that + /// Tests that /// causes the control to emit the data-value attribute so the /// client can skip the initial GET request and use the supplied value /// directly. @@ -231,9 +232,9 @@ public void InitialCheckedEmitsDataValue(bool initial, string expectedValue, str var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck("chk") + var control = new ControlDataFormItemInputCheck("chk") { - RestUri = _ => new UriEndpoint("https://example.com/api/check"), + ServiceFactory = _ => DataServiceDescriptor.QueryData("https://example.com/api/check"), InitialChecked = _ => initial }; var form = new ControlForm().Add(control); @@ -243,7 +244,7 @@ public void InitialCheckedEmitsDataValue(bool initial, string expectedValue, str // validation AssertExtensions.EqualWithPlaceholders( - $@"*
{expectedInput}
*", + $@"*
{expectedInput}
*", html); } } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs index b683398..d4caeff 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST password input control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputPassword + public class UnitTestControlDataFormItemInputPassword { /// /// Tests the id property of the REST password control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(id) + var control = new ControlDataFormItemInputPassword(id) { }; @@ -48,7 +48,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword() + var control = new ControlDataFormItemInputPassword() { }; @@ -72,7 +72,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Name = _ => name }; @@ -98,7 +98,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Placeholder = _ => placeholder }; @@ -124,7 +124,7 @@ public void MinLength(uint? minLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { MinLength = _ => minLength }; @@ -150,7 +150,7 @@ public void MaxLength(uint? maxLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { MaxLength = _ => maxLength }; @@ -175,7 +175,7 @@ public void Required(bool required, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Required = _ => required }; @@ -200,7 +200,7 @@ public void ValueForm(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null); + var control = new ControlDataFormItemInputPassword(null); var form = new ControlForm().Add(control) .Initialize(renderContext => { @@ -229,7 +229,7 @@ public void ValueItem(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) .Initialize(arg => { arg.Value.Text = value; @@ -263,7 +263,7 @@ public void ValidateForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword("password-box").Initialize(args => + var control = new ControlDataFormItemInputPassword("password-box").Initialize(args => { args.Value.Text = value; }); @@ -310,7 +310,7 @@ public void ProcessForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword("password-box") + var control = new ControlDataFormItemInputPassword("password-box") .Initialize(args => { args.Value.Text = value; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs similarity index 89% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs index 748820b..bc46462 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs @@ -1,4 +1,5 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -10,7 +11,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST selection control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputSelection + public class UnitTestControlDataFormItemInputSelection { /// /// Tests the id property of the form REST selection control. @@ -25,7 +26,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(id) + var control = new ControlDataFormItemInputSelection(id) { }; @@ -48,7 +49,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection() + var control = new ControlDataFormItemInputSelection() { }; @@ -72,7 +73,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { Name = _ => name }; @@ -98,7 +99,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { Placeholder = _ => placeholder }; @@ -123,7 +124,7 @@ public void MultiSelect(bool multiSelect, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { MultiSelect = _ => multiSelect }; @@ -140,7 +141,7 @@ public void MultiSelect(bool multiSelect, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -148,9 +149,9 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act @@ -174,7 +175,7 @@ public void MaxItems(int maxItems, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { MaxItems = _ => maxItems }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs index d5cf3ea..0fd0e24 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs @@ -1,4 +1,5 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebParameter; using WebExpress.WebCore.WebUri; @@ -11,7 +12,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST unique control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputUnique + public class UnitTestControlDataFormItemInputUnique { /// /// Tests the id property of the REST unique control. @@ -26,7 +27,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(id) + var control = new ControlDataFormItemInputUnique(id) { }; @@ -49,7 +50,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique() + var control = new ControlDataFormItemInputUnique() { }; @@ -73,7 +74,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Name = _ => name }; @@ -98,7 +99,7 @@ public void Description(string description, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Description = _ => description }; @@ -124,7 +125,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Placeholder = _ => placeholder }; @@ -150,7 +151,7 @@ public void MinLength(uint? minLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { MinLength = _ => minLength }; @@ -176,7 +177,7 @@ public void MaxLength(uint? maxLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { MaxLength = _ => maxLength }; @@ -201,7 +202,7 @@ public void Required(bool required, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Required = _ => required }; @@ -226,7 +227,7 @@ public void Pattern(string pattern, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Pattern = _ => pattern }; @@ -243,7 +244,7 @@ public void Pattern(string pattern, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -251,9 +252,9 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act @@ -276,7 +277,7 @@ public void ValueForm(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null); + var control = new ControlDataFormItemInputUnique(null); var form = new ControlForm().Add(control) .Initialize(renderContext => { @@ -305,7 +306,7 @@ public void ValueItem(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) .Initialize(arg => { arg.Value.Text = value; @@ -339,7 +340,7 @@ public void ValidateForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box").Initialize(args => + var control = new ControlDataFormItemInputUnique("text-box").Initialize(args => { args.Value.Text = value; }); @@ -386,7 +387,7 @@ public void ValidateItem(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Validate ( x => @@ -430,7 +431,7 @@ public void ProcessForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Initialize(args => { args.Value.Text = value; @@ -474,7 +475,7 @@ public void ProcessItem(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Initialize(x => x.Value.Text = value) .Process(x => processed = true); var form = new ControlForm() { Name = _ => "form" } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs index f9d3ad9..e15d2e4 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api kanban control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestKanban + public class UnitTestControlDataKanban { /// /// Tests the id property of the api kanban control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban(id) + var control = new ControlDataKanban(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api kanban control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the column capability flags emit their data attributes only when true. @@ -76,7 +52,7 @@ public void ColumnFlags(bool editable, bool movable, bool deletable, string expe var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban() + var control = new ControlDataKanban() { EditableColumn = _ => editable, MovableColumn = _ => movable, diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs similarity index 76% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs index 17f187f..40a17e6 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api list control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestList + public class UnitTestControlDataList { /// /// Tests the id property of the api list control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(id) + var control = new ControlDataList(id) { }; @@ -36,31 +36,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api list control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - /// /// Tests the layout property of the list control. /// @@ -76,7 +51,7 @@ public void Layout(TypeLayoutList layout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList() + var control = new ControlDataList() { Layout = _ => layout }; @@ -102,7 +77,7 @@ public void Title(string title, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(null) { Title = _ => title }; + var control = new ControlDataList(null) { Title = _ => title }; // act var html = control.Render(context, visualTree); @@ -123,7 +98,7 @@ public void Sortable(bool sortable, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(null) { Sortable = _ => sortable }; + var control = new ControlDataList(null) { Sortable = _ => sortable }; // act var html = control.Render(context, visualTree); diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs new file mode 100644 index 0000000..6b17427 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs @@ -0,0 +1,114 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST list control emits the C# authored wx-state island + /// element through the IDataIsland interface, alongside the wx-service + /// island. The state island is consumed by the engine through + /// webexpress.webapp.Data.readState. These tests assert both the emission + /// and the default (an absent or empty state emits no island). + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataListData + { + /// + /// Tests that a declared state emits the wx-state island element. + /// + [Fact] + public void StateEmitsTheStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create().Set("page", 0).Set("pageSize", 50) + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("
+ [Fact] + public void EmptyStateEmitsNoStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create() + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that state values are HTML encoded, so quotes in a value do not + /// break the markup. + /// + [Fact] + public void StateValuesAreHtmlEncoded() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create().Set("items", new[] { "a", "b" }) + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("["a","b"]", html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs new file mode 100644 index 0000000..d301ad5 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs @@ -0,0 +1,85 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST list control emits the C# authored wx-service island + /// element. The JavaScript consumes the island through + /// ServiceRegistry.fromElement, so these tests assert both the default + /// (no declared service emits no island) and the emission shape. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataListService + { + /// + /// Tests that a control without a declared data service emits no island. + /// + [Fact] + public void NoServiceEmitsNoIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList(); + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that a declared data service emits the wx-service island + /// element as the first child of the host. + /// + [Fact] + public void DataServiceEmitsTheIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("", html); + } + + /// + /// Tests that the island carries the mappings as child elements rather + /// than encoded json, so the markup stays inspectable. + /// + [Fact] + public void IslandCarriesMappingsAsElements() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("", html); + Assert.Contains("", html); + Assert.DoesNotContain("data-wx-service", html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs similarity index 86% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs index 4a4a12e..16501a8 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs @@ -1,4 +1,5 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebApiControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST login form control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestLoginForm + public class UnitTestControlDataLoginForm { /// /// Tests the id property of the login form control. @@ -23,7 +24,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin(id); + var control = new ControlDataLogin(id); // act var html = control.Render(context, visualTree); @@ -37,16 +38,16 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/login", @"
")] + [InlineData("https://example.com/api/login", @"
")] public void RestUri(string uriString, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.SubmitData(uriString) : null }; // act @@ -68,7 +69,7 @@ public void RedirectUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { RedirectUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -92,7 +93,7 @@ public void Title(string title, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { Title = _ => title }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs similarity index 81% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs index 71cf71a..b8a8257 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs @@ -1,5 +1,6 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api quickfilter control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestQuickfilter + public class UnitTestControlDataQuickfilter { /// /// Tests the id property of the api quickfilter control. @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestQuickfilter(id) + var control = new ControlDataQuickfilter(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestQuickfilter() + var control = new ControlDataQuickfilter() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs similarity index 81% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs index 20cecaf..919adc4 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the scrum backlog control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestScrumBacklog + public class UnitTestControlDataScrumBacklog { /// /// Tests the id property of the scrum backlog control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog(id) + var control = new ControlDataScrumBacklog(id) { }; @@ -46,9 +46,8 @@ public void RenderAttributes() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog("scrum") + var control = new ControlDataScrumBacklog("scrum") { - RestUri = _ => new UriEndpoint("https://example.com/api/scrum/backlog"), Title = _ => "Backlog", Selectable = _ => false, IconActive = _ => "active-icon", @@ -66,7 +65,7 @@ public void RenderAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -82,7 +81,7 @@ public void Readonly(bool readOnly, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog() + var control = new ControlDataScrumBacklog() { Readonly = _ => readOnly }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs similarity index 82% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs index a484743..4cd0116 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs @@ -1,5 +1,6 @@ using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the scrum sprint control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestScrumSprint + public class UnitTestControlDataScrumSprint { /// /// Tests the id property of the scrum sprint control. @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumSprint(id) + var control = new ControlDataScrumSprint(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/scrum/sprint", @"
")] + [InlineData("https://example.com/api/scrum/sprint", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumSprint() + var control = new ControlDataScrumSprint() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs similarity index 81% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs index e31fd03..9b33021 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs @@ -1,4 +1,5 @@ using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebControl; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -6,14 +7,14 @@ namespace WebExpress.WebApp.Test.WebControl { /// - /// Tests : the dropdown shell + /// Tests : the dropdown shell /// should be promoted to the theme-specific JS class /// (wx-webapp-dropdown-theme) and the REST URI should arrive on /// the data-uri attribute so the JS layer can fetch the theme /// list and PUT the user's selection. No surrounding form is required. /// [Collection("NonParallelTests")] - public class UnitTestControlRestSelectionTheme + public class UnitTestControlDataSelectionTheme { /// /// Without an explicit id the control auto-generates one and still @@ -26,7 +27,7 @@ public void AutoId_RendersThemeClass() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme(); + var control = new ControlDataSelectionTheme(); // act var html = control.Render(context, visualTree); @@ -47,7 +48,7 @@ public void ExplicitId_RendersOnHost() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme("themePicker"); + var control = new ControlDataSelectionTheme("themePicker"); // act var html = control.Render(context, visualTree); @@ -59,21 +60,21 @@ public void ExplicitId_RendersOnHost() } /// - /// The REST URI carried by - /// is emitted on data-uri. + /// The REST URI carried by + /// is emitted as the wx-service island. /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/themes", @"
")] + [InlineData("https://example.com/api/themes", @"
")] public void RestUri_RendersAsDataUri(string uri, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme("themePicker") + var control = new ControlDataSelectionTheme("themePicker") { - RestUri = _ => uri is not null ? new UriEndpoint(uri) : null + ServiceFactory = uri is not null ? _ => DataServiceDescriptor.Data(uri) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs new file mode 100644 index 0000000..fb42b56 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the C# rollout of the wx-service island element across the control + /// families that already have a tested JavaScript descriptor: kanban, + /// dashboard, tile, comment, scrum backlog, workflow and tab. Each family + /// asserts that a declared data service emits the island element. The + /// default (no service emits no island) is covered by the existing per + /// control render tests, which would fail if the emission were + /// unconditional. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataServiceIslandRollout + { + /// + /// Renders a control through the standard mock render context and visual + /// tree, so each test stays a single expressive line. + /// + /// The render invocation. + /// The rendered html string. + private static string Render(Func render) + { + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + + return render(context, visualTree).ToString(); + } + + // kanban + + [Fact] + public void KanbanEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataKanban() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/board") }.Render(ctx, vt)); + Assert.Contains("class=\"wx-webapp-kanban\"", html); + Assert.Contains("
[Collection("NonParallelTests")] - public class UnitTestControlRestTab + public class UnitTestControlDataTab { /// /// Tests the id property of the api tab control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab(id) + var control = new ControlDataTab(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api tab control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the readonly property emits a data-readonly attribute only when true. @@ -73,7 +49,7 @@ public void Readonly(bool readOnly, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() + var control = new ControlDataTab() { Readonly = _ => readOnly }; @@ -98,7 +74,7 @@ public void MovableTab(bool movable, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() + var control = new ControlDataTab() { MovableTab = _ => movable }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs index 9822a40..554c154 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api tab template control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestTabTemplate + public class UnitTestControlDataTabTemplate { /// /// Tests the id property of the api tab control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate(id) + var control = new ControlDataTabTemplate(id) { }; @@ -47,7 +47,7 @@ public void Metadata() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Icon = _ => new IconUser(), Name = _ => "User Template", @@ -74,7 +74,7 @@ public void Multiplicity(int multiplicity, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Multiplicity = _ => multiplicity }; @@ -97,7 +97,7 @@ public void MultiplicityUnlimited() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Multiplicity = _ => null }; @@ -116,7 +116,7 @@ public void MultiplicityUnlimited() public void InterfaceId() { // arrange - IControlRestTabTemplate template = new ControlRestTabTemplate("template-id"); + IControlDataTabTemplate template = new ControlDataTabTemplate("template-id"); // validation Assert.Equal("template-id", template.Id); @@ -133,7 +133,7 @@ public void Bind() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Bind = _ => new Binding().Add(new BindTemplate() .Add("uri", TypeBindMode.Attr, ".wx-webapp-dashboard", "data-uri") diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs index 95a39b5..65aa29d 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs @@ -6,17 +6,17 @@ namespace WebExpress.WebApp.Test.WebControl { /// - /// Tests the api tile control. + /// Tests the api table control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestTile + public class UnitTestControlDataTable { /// - /// Tests the id property of the api tile control. + /// Tests the id property of the api table control. /// [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] + [InlineData(null, @"
")] + [InlineData("id", @"
")] public void Id(string id, string expected) { // arrange @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTile(id) + var control = new ControlDataTable(id) { }; @@ -35,22 +35,23 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } + /// - /// Tests the RestUri property of the api tile control. + /// Tests the page size property of the API table control. /// [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) + [InlineData(0, @"
")] + [InlineData(10, @"
")] + public void PageSize(uint pageSize, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTile() + var control = new ControlDataTable() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + PageSize = _ => pageSize }; // act @@ -60,4 +61,4 @@ public void RestUri(string uriString, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs new file mode 100644 index 0000000..daf7e26 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs @@ -0,0 +1,61 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST table control emits the C# authored wx-service + /// island element. The JavaScript consumes the island through + /// ServiceRegistry.fromElement, so these tests assert both the default + /// (no declared service emits no island) and the emission shape. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataTableService + { + /// + /// Tests that a control without a declared data service emits no island. + /// + [Fact] + public void NoServiceEmitsNoIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTable(); + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that a declared data service emits the wx-service island + /// element with the table mappings. + /// + [Fact] + public void DataServiceEmitsTheIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTable() + { + ServiceFactory = _ => DataServiceDescriptor.TableData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("
[Collection("NonParallelTests")] - public class UnitTestControlRestTag + public class UnitTestControlDataTag { /// /// Tests the id property of the tag control. @@ -26,7 +27,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag(id) + var control = new ControlDataTag(id) { }; @@ -42,16 +43,16 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/tags/INC-1", @"
")] + [InlineData("https://example.com/api/tags/INC-1", @"
")] public void RestUri(string uriString, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act @@ -74,7 +75,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Readonly = _ => readOnly }; @@ -99,7 +100,7 @@ public void Placeholder(string placeholder, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Placeholder = _ => placeholder }; @@ -126,7 +127,7 @@ public void SystemColor(TypeColorTag color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Color = _ => new PropertyColorTag(color) }; @@ -152,7 +153,7 @@ public void UserColor(string color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Color = _ => new PropertyColorTag(color) }; @@ -178,7 +179,7 @@ public void Value(string[] value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Value = _ => value }; @@ -200,9 +201,9 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag("t1") + var control = new ControlDataTag("t1") { - RestUri = _ => new UriEndpoint("https://example.com/api/tags/INC-1"), + ServiceFactory = _ => DataServiceDescriptor.QueryData("https://example.com/api/tags/INC-1"), Placeholder = _ => "add tag", Value = _ => ["alpha", "beta"], Readonly = _ => true, @@ -213,7 +214,7 @@ public void AllAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -227,7 +228,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs new file mode 100644 index 0000000..577497f --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs @@ -0,0 +1,39 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the api tile control. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataTile + { + /// + /// Tests the id property of the api tile control. + /// + [Theory] + [InlineData(null, @"
")] + [InlineData("id", @"
")] + public void Id(string id, string expected) + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTile(id) + { + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, html); + } + + } +} \ No newline at end of file diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs similarity index 82% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs index 26a023a..01fadaa 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs @@ -1,5 +1,6 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api wizard control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestWizard + public class UnitTestControlDataWizard { /// /// Tests the id property of the api wizard control. @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizard(id) + var control = new ControlDataWizard(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizard() + var control = new ControlDataWizard() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.FormData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs similarity index 92% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs index ff7c580..e4d79a0 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api wizard page control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestWizardPage + public class UnitTestControlDataWizardPage { /// /// Tests the id property of the api wizard page control. @@ -23,7 +23,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizardPage(id) + var control = new ControlDataWizardPage(id) { }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs new file mode 100644 index 0000000..db8d706 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs @@ -0,0 +1,39 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the api workflow control. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataWorkflow + { + /// + /// Tests the id property of the api workflow control. + /// + [Theory] + [InlineData(null, @"
")] + [InlineData("id", @"
")] + public void Id(string id, string expected) + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataWorkflow(id) + { + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, html); + } + + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs similarity index 81% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs index ffad2c4..638747f 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs @@ -1,5 +1,6 @@ -using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -9,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api list control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestWqlPrompt + public class UnitTestControlDataWqlPrompt { /// /// Tests the id property of the api list control. @@ -24,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWqlPrompt(id) + var control = new ControlDataWqlPrompt(id) { }; @@ -40,7 +41,7 @@ public void Id(string id, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] + [InlineData("https://example.com/api/data", @"
")] public void RestUri(string uriString, string expected) { // arrange @@ -48,9 +49,9 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWqlPrompt() + var control = new ControlDataWqlPrompt() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.QueryData(uriString) : null }; // act diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs deleted file mode 100644 index 0c86ec7..0000000 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs +++ /dev/null @@ -1,88 +0,0 @@ -using WebExpress.WebApp.Test.Fixture; -using WebExpress.WebApp.WebControl; -using WebExpress.WebCore.WebUri; -using WebExpress.WebUI.WebPage; - -namespace WebExpress.WebApp.Test.WebControl -{ - /// - /// Tests the api table control. - /// - [Collection("NonParallelTests")] - public class UnitTestControlRestTable - { - /// - /// Tests the id property of the api table control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] - public void Id(string id, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable(id) - { - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the RestUri property of the API table control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the page size property of the API table control. - /// - [Theory] - [InlineData(0, @"
")] - [InlineData(10, @"
")] - public void PageSize(uint pageSize, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable() - { - PageSize = _ => pageSize - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - } -} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs deleted file mode 100644 index 1b6ce27..0000000 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs +++ /dev/null @@ -1,63 +0,0 @@ -using WebExpress.WebApp.Test.Fixture; -using WebExpress.WebApp.WebControl; -using WebExpress.WebCore.WebUri; -using WebExpress.WebUI.WebPage; - -namespace WebExpress.WebApp.Test.WebControl -{ - /// - /// Tests the api workflow control. - /// - [Collection("NonParallelTests")] - public class UnitTestControlRestWorkflow - { - /// - /// Tests the id property of the api workflow control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] - public void Id(string id, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWorkflow(id) - { - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the RestUri property of the api workflow control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWorkflow() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - } -} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs index b6391b2..9d55ec0 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs @@ -1,5 +1,6 @@ using WebExpress.WebApp.Test.Fixture; using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebPage; @@ -26,7 +27,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher(id); + var control = new ControlDataWatcher(id); // act var html = control.Render(context, visualTree); @@ -40,16 +41,16 @@ public void Id(string id, string expected) ///
[Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/watchers/INC-00123", @"
")] + [InlineData("https://example.com/api/watchers/INC-00123", @"
")] public void RestUri(string uriString, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.Data(uriString) : null }; // act @@ -64,16 +65,16 @@ public void RestUri(string uriString, string expected) /// [Theory] [InlineData(null, @"
")] - [InlineData("https://example.com/api/users", @"
")] + [InlineData("https://example.com/api/users", @"
")] public void UsersUri(string uriString, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { - UsersUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + ServiceFactory = uriString is not null ? _ => DataServiceDescriptor.Rest("users").WithBaseUri(uriString).WithMethod("GET") : null }; // act @@ -96,7 +97,7 @@ public void MaxVisible(int? maxVisible, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { MaxVisible = _ => maxVisible }; @@ -121,7 +122,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { Readonly = _ => readOnly }; @@ -143,10 +144,13 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher("o1") + var control = new ControlDataWatcher("o1") { - RestUri = _ => new UriEndpoint("https://example.com/api/watchers/INC-1"), - UsersUri = _ => new UriEndpoint("https://example.com/api/users"), + ServiceFactories = + { + _ => DataServiceDescriptor.Data("https://example.com/api/watchers/INC-1"), + _ => DataServiceDescriptor.Rest("users").WithBaseUri("https://example.com/api/users").WithMethod("GET") + }, MaxVisible = _ => 5, Readonly = _ => true }; @@ -155,7 +159,7 @@ public void AllAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -169,7 +173,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs new file mode 100644 index 0000000..4c63fce --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs @@ -0,0 +1,228 @@ +using System.Net.Http; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebData +{ + /// + /// Tests the fluent C# authoring surface of the data layer: the + /// that produces a service descriptor and the + /// State and Service extensions that let a control declare its state and + /// service by chaining, matching the View, State and Service concept. The + /// endpoint resolution through the sitemap is exercised by the tutorial pages; + /// these tests cover the builder shape and that the chain emits the islands. + /// + [Collection("NonParallelTests")] + public class UnitTestDataAuthoring + { + /// + /// Tests that the builder produces a descriptor that carries the declared + /// method, update method, query mapping and response mapping. + /// + [Fact] + public void BuilderProducesDescriptorShape() + { + // act + var island = new DataServiceBuilder("data") + .Method(HttpMethod.Get) + .UpdateMethod(HttpMethod.Put) + .Query(q => q.Map("search", "q").Map("page", "p")) + .Response(r => r.Items("items").Total("total")) + .Build(null) + .ToIslandElement() + .ToString(); + + // validation + Assert.Contains("name=\"data\"", island); + Assert.Contains("method=\"GET\"", island); + Assert.Contains("update-method=\"PUT\"", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + } + + /// + /// Tests that the fluent State extension makes the control emit the + /// wx-state island element. + /// + [Fact] + public void FluentStateEmitsTheStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .State(s => s.Set("page", 0).Set("pageSize", 25)); + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains(" + [Fact] + public void ServiceFactoryConvenienceReplacesAllServices() + { + // arrange + var control = new ControlDataList("myList") + .Service("load", svc => svc.Method(HttpMethod.Get)) + .Service("submit", svc => svc.Method(HttpMethod.Post)); + + // act + control.ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders"); + + // validation + Assert.Single(control.ServiceFactories); + Assert.NotNull(control.ServiceFactory); + } + + /// + /// Tests that the typed query helpers map the closed logical vocabulary + /// to the historical wire names, so the standard mapping needs no string + /// at the call site. + /// + [Fact] + public void TypedQueryHelpersMapTheVocabulary() + { + // act + var island = new DataServiceBuilder("data") + .Method(HttpMethod.Get) + .Query(q => q.Search().Wql().Filter().Page().PageSize().OrderBy().OrderDir()) + .Response(r => r.Items().Total()) + .Build(null) + .ToIslandElement() + .ToString(); + + // validation: identical to the historical list mapping + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + Assert.Contains("", island); + } + + /// + /// Tests that the typed state helpers set the closed state vocabulary, + /// so the initial state needs no string at the call site. + /// + [Fact] + public void TypedStateHelpersSetTheVocabulary() + { + // act + var island = DataState.Create().Page(0).PageSize(25).ToIslandElement().ToString(); + + // validation + Assert.Contains("0", island); + Assert.Contains("25", island); + } + + /// + /// Tests that the fluent Template extension makes the control emit the + /// data-wx-template attribute that the client Templates registry resolves. + /// + [Fact] + public void FluentTemplateEmitsTheTemplateAttribute() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .Template("orders-view"); + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("data-wx-template=\"orders-view\"", html); + } + + /// + /// Tests that the family preset declares the standard data service in a + /// single typed call. The endpoint resolution through the sitemap is + /// exercised by the tutorial pages; this test asserts that the preset + /// registers exactly one service factory. + /// + [Fact] + public void FamilyPresetRegistersTheStandardService() + { + // act + var control = new ControlDataList("myList") + .State(s => s.Page(0).PageSize(25)) + .DataService(); + + // validation + Assert.Single(control.ServiceFactories); + Assert.NotNull(control.StateFactory); + } + + /// + /// A marker endpoint for the preset test. + /// + private sealed class FakeEndpoint : WebExpress.WebCore.WebEndpoint.IEndpoint + { + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs new file mode 100644 index 0000000..91c1b68 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs @@ -0,0 +1,155 @@ +using WebExpress.WebApp.WebData; + +namespace WebExpress.WebApp.Test.WebData +{ + /// + /// Tests the C# service descriptor that renders into the wx-service island + /// element. The test pins the island shape that the JavaScript + /// ServiceRegistry consumes: the scalar parts as attributes and the query, + /// response, header and error mappings as child elements. + /// + public class UnitTestDataServiceDescriptor + { + /// + /// Tests that the list data descriptor renders the historical list + /// query and response names as mapping elements. + /// + [Fact] + public void ListDataIslandCarriesTheListMappings() + { + var island = DataServiceDescriptor.ListData("/api/orders").ToIslandElement().ToString(); + + Assert.Contains("