From 6b50f2dbeb590ae7af04522c5d29307dd58de14f Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 00:03:45 +0700 Subject: [PATCH] feat(spec): add keyedBy(element, tags) identity helper Joins element.find({testTag: tag})?.text per tag with U+001F as the delimiter so user-visible text can never collide with the separator. Returns empty string for an undefined element. --- pkg/spec/src/extract.ts | 14 ++++++++++++- pkg/spec/src/index.ts | 2 +- pkg/spec/test/api.test.ts | 42 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/pkg/spec/src/extract.ts b/pkg/spec/src/extract.ts index 320e6e2..4c08087 100644 --- a/pkg/spec/src/extract.ts +++ b/pkg/spec/src/extract.ts @@ -1,5 +1,17 @@ -import type { Extracted, State } from "./types.ts"; +import type { AccessibilityElement, Extracted, State } from "./types.ts"; export function extract(getter: (state: State) => T): Extracted { return globalThis.__sanderling__.extract(getter); } + +const KEY_DELIMITER = "\x1f"; + +export function keyedBy( + element: AccessibilityElement | undefined, + tags: readonly string[], +): string { + if (!element) return ""; + return tags + .map(tag => element.find({ testTag: tag })?.text ?? "") + .join(KEY_DELIMITER); +} diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index 66ee05a..f462d4d 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -23,7 +23,7 @@ export type { WeightedEntry, } from "./types.ts"; -export { extract } from "./extract.ts"; +export { extract, keyedBy } from "./extract.ts"; export { always, eventually, next, now } from "./ltl.ts"; export { InputText, diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index 8b9f4fd..f919bf8 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -12,6 +12,7 @@ import { eventually, extract, from, + keyedBy, next, now, pressKey, @@ -281,6 +282,47 @@ test("from forwards items to the runtime", () => { assert.equal(sampler.generate(), "a"); }); +function elementWithChildren( + cells: Record, +): AccessibilityElement { + return { + find: selector => { + if (typeof selector === "string" || Array.isArray(selector)) return undefined; + const tag = (selector as Record).testTag; + if (!tag) return undefined; + const text = cells[tag]; + if (text === undefined) return undefined; + return { text, find: () => undefined, findAll: () => [] }; + }, + findAll: () => [], + }; +} + +test("keyedBy joins testTag-resolved texts with a stable delimiter", () => { + installFakeRuntime(); + const row = elementWithChildren({ + TxnDate: "2026-04-26", + TxnNote: "Coffee", + TxnAmount: "$5.00", + }); + const key = keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]); + assert.equal(key, "2026-04-26\x1fCoffee\x1f$5.00"); +}); + +test("keyedBy returns empty string for an undefined element", () => { + installFakeRuntime(); + assert.equal(keyedBy(undefined, ["TxnDate"]), ""); +}); + +test("keyedBy substitutes empty strings for missing children", () => { + installFakeRuntime(); + const row = elementWithChildren({ TxnDate: "2026-04-26" }); + assert.equal( + keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), + "2026-04-26\x1f\x1f", + ); +}); + test("default generators proxy through to the runtime", () => { installFakeRuntime(); assert.equal(taps.__sanderlingActionGenerator, true);