|
| 1 | +import { assert, describe, test } from "@rezi-ui/testkit"; |
| 2 | +import type { StateUpdater } from "../../app/updateQueue.js"; |
| 3 | +import { bind, bindChecked, bindSelect, bindTransform } from "../bind.js"; |
| 4 | + |
| 5 | +function createStateHarness<S extends Record<string, unknown>>( |
| 6 | + initial: S, |
| 7 | +): { |
| 8 | + getState: () => S; |
| 9 | + update: (updater: StateUpdater<S>) => void; |
| 10 | + getUpdateCount: () => number; |
| 11 | +} { |
| 12 | + let state = initial; |
| 13 | + let updateCount = 0; |
| 14 | + return { |
| 15 | + getState: () => state, |
| 16 | + update: (updater) => { |
| 17 | + updateCount++; |
| 18 | + if (typeof updater === "function") { |
| 19 | + state = updater(state); |
| 20 | + return; |
| 21 | + } |
| 22 | + state = updater; |
| 23 | + }, |
| 24 | + getUpdateCount: () => updateCount, |
| 25 | + }; |
| 26 | +} |
| 27 | + |
| 28 | +describe("form.bind - input binding", () => { |
| 29 | + test("bind returns string value for existing string field", () => { |
| 30 | + const h = createStateHarness({ name: "Ada" }); |
| 31 | + const props = bind(h.getState(), "name", h.update); |
| 32 | + assert.equal(props.value, "Ada"); |
| 33 | + }); |
| 34 | + |
| 35 | + test("bind converts numeric value to string", () => { |
| 36 | + const h = createStateHarness({ age: 42 }); |
| 37 | + const props = bind(h.getState(), "age", h.update); |
| 38 | + assert.equal(props.value, "42"); |
| 39 | + }); |
| 40 | + |
| 41 | + test("bind converts undefined value to empty string", () => { |
| 42 | + const h = createStateHarness({ name: undefined as unknown as string }); |
| 43 | + const props = bind(h.getState(), "name", h.update); |
| 44 | + assert.equal(props.value, ""); |
| 45 | + }); |
| 46 | + |
| 47 | + test("bind converts null value to empty string", () => { |
| 48 | + const h = createStateHarness({ name: null as unknown as string }); |
| 49 | + const props = bind(h.getState(), "name", h.update); |
| 50 | + assert.equal(props.value, ""); |
| 51 | + }); |
| 52 | + |
| 53 | + test("bind onInput updates top-level field", () => { |
| 54 | + const h = createStateHarness({ name: "Ada" }); |
| 55 | + const props = bind(h.getState(), "name", h.update); |
| 56 | + |
| 57 | + assert.ok(props.onInput); |
| 58 | + props.onInput?.("Grace", 5); |
| 59 | + assert.equal(h.getState().name, "Grace"); |
| 60 | + assert.equal(h.getUpdateCount(), 1); |
| 61 | + }); |
| 62 | + |
| 63 | + test("bind onInput updates nested field path", () => { |
| 64 | + const h = createStateHarness({ |
| 65 | + profile: { address: { city: "Paris", country: "FR" }, role: "admin" }, |
| 66 | + status: "active", |
| 67 | + }); |
| 68 | + const before = h.getState(); |
| 69 | + const beforeProfile = before.profile; |
| 70 | + const beforeAddress = before.profile.address; |
| 71 | + |
| 72 | + const props = bind(h.getState(), "profile.address.city", h.update); |
| 73 | + assert.ok(props.onInput); |
| 74 | + props.onInput?.("Berlin", 6); |
| 75 | + |
| 76 | + const after = h.getState(); |
| 77 | + assert.equal(after.profile.address.city, "Berlin"); |
| 78 | + assert.equal(after.profile.address.country, "FR"); |
| 79 | + assert.equal(after.status, "active"); |
| 80 | + assert.notEqual(after, before); |
| 81 | + assert.notEqual(after.profile, beforeProfile); |
| 82 | + assert.notEqual(after.profile.address, beforeAddress); |
| 83 | + }); |
| 84 | + |
| 85 | + test("bind creates missing nested objects for path assignment", () => { |
| 86 | + const h = createStateHarness<{ address?: { city?: string } }>({}); |
| 87 | + const props = bind(h.getState(), "address.city", h.update); |
| 88 | + assert.ok(props.onInput); |
| 89 | + props.onInput?.("Tokyo", 5); |
| 90 | + |
| 91 | + assert.deepEqual(h.getState(), { address: { city: "Tokyo" } }); |
| 92 | + }); |
| 93 | + |
| 94 | + test("bind preserves references on untouched branches", () => { |
| 95 | + const h = createStateHarness({ |
| 96 | + left: { value: "A" }, |
| 97 | + right: { value: "B" }, |
| 98 | + }); |
| 99 | + const beforeRight = h.getState().right; |
| 100 | + |
| 101 | + const props = bind(h.getState(), "left.value", h.update); |
| 102 | + assert.ok(props.onInput); |
| 103 | + props.onInput?.("AA", 2); |
| 104 | + |
| 105 | + const after = h.getState(); |
| 106 | + assert.equal(after.left.value, "AA"); |
| 107 | + assert.equal(after.right.value, "B"); |
| 108 | + assert.equal(after.right, beforeRight); |
| 109 | + }); |
| 110 | + |
| 111 | + test("bind ignores empty path string updates", () => { |
| 112 | + const h = createStateHarness({ name: "Ada" }); |
| 113 | + const props = bind(h.getState(), "", h.update); |
| 114 | + assert.ok(props.onInput); |
| 115 | + props.onInput?.("Grace", 5); |
| 116 | + |
| 117 | + assert.deepEqual(h.getState(), { name: "Ada" }); |
| 118 | + }); |
| 119 | +}); |
| 120 | + |
| 121 | +describe("form.bind - transform binding", () => { |
| 122 | + test("bindTransform uses custom get formatter", () => { |
| 123 | + const h = createStateHarness({ cents: 1234 }); |
| 124 | + const props = bindTransform(h.getState(), "cents", h.update, { |
| 125 | + get: (value) => `$${(Number(value) / 100).toFixed(2)}`, |
| 126 | + set: (value) => Math.round(Number(value.replace("$", "")) * 100), |
| 127 | + }); |
| 128 | + assert.equal(props.value, "$12.34"); |
| 129 | + }); |
| 130 | + |
| 131 | + test("bindTransform onInput uses custom set parser", () => { |
| 132 | + const h = createStateHarness({ qty: 1 }); |
| 133 | + const props = bindTransform(h.getState(), "qty", h.update, { |
| 134 | + get: (value) => String(value), |
| 135 | + set: (value) => Number(value), |
| 136 | + }); |
| 137 | + |
| 138 | + assert.ok(props.onInput); |
| 139 | + props.onInput?.("7", 1); |
| 140 | + assert.equal(h.getState().qty, 7); |
| 141 | + }); |
| 142 | + |
| 143 | + test("bindTransform supports nested field paths", () => { |
| 144 | + const h = createStateHarness({ profile: { rank: 3 } }); |
| 145 | + const props = bindTransform(h.getState(), "profile.rank", h.update, { |
| 146 | + get: (value) => `#${String(value)}`, |
| 147 | + set: (value) => Number(value.slice(1)), |
| 148 | + }); |
| 149 | + |
| 150 | + assert.ok(props.onInput); |
| 151 | + props.onInput?.("#9", 2); |
| 152 | + assert.equal(h.getState().profile.rank, 9); |
| 153 | + }); |
| 154 | + |
| 155 | + test("bindTransform creates nested path when missing", () => { |
| 156 | + const h = createStateHarness<{ metrics?: { score?: number } }>({}); |
| 157 | + const props = bindTransform(h.getState(), "metrics.score", h.update, { |
| 158 | + get: (value) => String(value ?? 0), |
| 159 | + set: (value) => Number(value), |
| 160 | + }); |
| 161 | + |
| 162 | + assert.ok(props.onInput); |
| 163 | + props.onInput?.("12", 2); |
| 164 | + assert.deepEqual(h.getState(), { metrics: { score: 12 } }); |
| 165 | + }); |
| 166 | + |
| 167 | + test("bindTransform get receives undefined for missing path", () => { |
| 168 | + const seen: unknown[] = []; |
| 169 | + const h = createStateHarness<{ profile?: { alias?: string } }>({}); |
| 170 | + const props = bindTransform(h.getState(), "profile.alias", h.update, { |
| 171 | + get: (value) => { |
| 172 | + seen.push(value); |
| 173 | + return value === undefined ? "" : String(value); |
| 174 | + }, |
| 175 | + set: (value) => value, |
| 176 | + }); |
| 177 | + |
| 178 | + assert.equal(props.value, ""); |
| 179 | + assert.deepEqual(seen, [undefined]); |
| 180 | + }); |
| 181 | +}); |
| 182 | + |
| 183 | +describe("form.bind - checkbox binding", () => { |
| 184 | + test("bindChecked coerces truthy and falsy values", () => { |
| 185 | + const a = createStateHarness({ remember: "yes" as unknown as boolean }); |
| 186 | + const b = createStateHarness({ remember: 0 as unknown as boolean }); |
| 187 | + |
| 188 | + assert.equal(bindChecked(a.getState(), "remember", a.update).checked, true); |
| 189 | + assert.equal(bindChecked(b.getState(), "remember", b.update).checked, false); |
| 190 | + }); |
| 191 | + |
| 192 | + test("bindChecked onChange updates top-level boolean", () => { |
| 193 | + const h = createStateHarness({ remember: false }); |
| 194 | + const props = bindChecked(h.getState(), "remember", h.update); |
| 195 | + assert.ok(props.onChange); |
| 196 | + props.onChange?.(true); |
| 197 | + assert.equal(h.getState().remember, true); |
| 198 | + }); |
| 199 | + |
| 200 | + test("bindChecked onChange supports nested path assignment", () => { |
| 201 | + const h = createStateHarness<{ settings?: { marketing?: boolean } }>({}); |
| 202 | + const props = bindChecked(h.getState(), "settings.marketing", h.update); |
| 203 | + assert.ok(props.onChange); |
| 204 | + props.onChange?.(true); |
| 205 | + |
| 206 | + assert.deepEqual(h.getState(), { settings: { marketing: true } }); |
| 207 | + }); |
| 208 | +}); |
| 209 | + |
| 210 | +describe("form.bind - select binding", () => { |
| 211 | + test("bindSelect converts nullish field value to empty string", () => { |
| 212 | + const h = createStateHarness<{ country?: string }>({}); |
| 213 | + const props = bindSelect(h.getState(), "country", h.update); |
| 214 | + assert.equal(props.value, ""); |
| 215 | + }); |
| 216 | + |
| 217 | + test("bindSelect reads nested field value", () => { |
| 218 | + const h = createStateHarness({ profile: { locale: "fr-FR" } }); |
| 219 | + const props = bindSelect(h.getState(), "profile.locale", h.update); |
| 220 | + assert.equal(props.value, "fr-FR"); |
| 221 | + }); |
| 222 | + |
| 223 | + test("bindSelect onChange updates top-level field", () => { |
| 224 | + const h = createStateHarness({ country: "US" }); |
| 225 | + const props = bindSelect(h.getState(), "country", h.update); |
| 226 | + assert.ok(props.onChange); |
| 227 | + props.onChange?.("DE"); |
| 228 | + assert.equal(h.getState().country, "DE"); |
| 229 | + }); |
| 230 | + |
| 231 | + test("bindSelect onChange supports nested path", () => { |
| 232 | + const h = createStateHarness<{ profile?: { locale?: string } }>({}); |
| 233 | + const props = bindSelect(h.getState(), "profile.locale", h.update); |
| 234 | + assert.ok(props.onChange); |
| 235 | + props.onChange?.("en-US"); |
| 236 | + |
| 237 | + assert.deepEqual(h.getState(), { profile: { locale: "en-US" } }); |
| 238 | + }); |
| 239 | + |
| 240 | + test("bindSelect does not mutate previous nested object", () => { |
| 241 | + const h = createStateHarness({ profile: { locale: "en", timezone: "UTC" } }); |
| 242 | + const beforeProfile = h.getState().profile; |
| 243 | + const props = bindSelect(h.getState(), "profile.locale", h.update); |
| 244 | + |
| 245 | + assert.ok(props.onChange); |
| 246 | + props.onChange?.("de"); |
| 247 | + |
| 248 | + const after = h.getState(); |
| 249 | + assert.equal(after.profile.locale, "de"); |
| 250 | + assert.equal(after.profile.timezone, "UTC"); |
| 251 | + assert.notEqual(after.profile, beforeProfile); |
| 252 | + }); |
| 253 | +}); |
0 commit comments