mirror of
https://github.com/aphrody-labs/typesafe-sdk-js.git
synced 2026-10-13 20:00:15 +00:00
Release v0.6.0
Co-authored-by: Allie Laabs <allie@typesafe.ai> Co-authored-by: Evin Sellin <evinism@gmail.com>
This commit is contained in:
13 files changed
+104
-178
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 (2026-09-15)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- accept `Score.criteria` as an ordered sequence instead of a dictionary keyed by integers
|
||||
|
||||
## v0.5.7 (2026-09-11)
|
||||
|
||||
This is the initial public release of TypeSafe JavaScript and TypeScript SDK. Learn more in the [documentation](https://docs.typesafe.ai/sdk/javascript).
|
||||
+5
-5
@@ -22,11 +22,11 @@ try {
|
||||
angry: null,
|
||||
}),
|
||||
urgency: score("How urgent is this ticket?", ["can wait", "this week", "today", "right now"]),
|
||||
refundRisk: score("How likely is the customer to demand a refund?", {
|
||||
0: "unlikely",
|
||||
1: "possible",
|
||||
2: "likely",
|
||||
}),
|
||||
refundRisk: score("How likely is the customer to demand a refund?", [
|
||||
"unlikely",
|
||||
"possible",
|
||||
"likely",
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typesafe-ai/sdk",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@typesafe-ai/sdk",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@typesafe-ai/sdk",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "^0.18.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typesafe-ai/sdk",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.0",
|
||||
"description": "TypeScript SDK for the TypeSafe API",
|
||||
"license": "MIT",
|
||||
"author": "evinism",
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ import {
|
||||
redactHeaders,
|
||||
withLevel,
|
||||
} from "./logging";
|
||||
import { toWireQuestions } from "./questions";
|
||||
import { validateQuestions } from "./questions";
|
||||
import { Models } from "./resources/models";
|
||||
import {
|
||||
DEFAULT_RETRY_POLICY,
|
||||
@@ -294,7 +294,7 @@ export class TypeSafeClient {
|
||||
* @param request - State, questions, and an optional model override.
|
||||
* @param options - Per-call timeout, retry, headers, and cancellation settings.
|
||||
* @returns Answers typed by question name and criteria, with model and token usage.
|
||||
* @throws {TypeSafeError} Questions or score criteria are empty, or score keys are invalid.
|
||||
* @throws {TypeSafeError} Questions are empty, or score criteria are not a list of at least two entries.
|
||||
* @throws {APIError} The server returns a non-2xx response after retries.
|
||||
* @throws {APIConnectionError} The request cannot connect or times out after retries.
|
||||
* @throws {APIUserAbortError} The caller aborts the request.
|
||||
@@ -312,10 +312,10 @@ export class TypeSafeClient {
|
||||
request: SystemOneRequest<Q>,
|
||||
options: RequestOptions = {},
|
||||
): APIPromise<SystemOneResult<Q>> {
|
||||
validateQuestions(request.questions);
|
||||
const body = {
|
||||
...request,
|
||||
model: request.model ?? this.defaultModel,
|
||||
questions: toWireQuestions(request.questions),
|
||||
} satisfies SystemOneRequestPayload;
|
||||
|
||||
return this.#request<SystemOneResult<Q>>("POST", "/v1/systemone", {
|
||||
|
||||
+24
-52
@@ -6,8 +6,6 @@ import type {
|
||||
NoulQuestion,
|
||||
Questions,
|
||||
ScoreCriteria,
|
||||
ScoreList,
|
||||
ScoreMap,
|
||||
ScoreQuestion,
|
||||
} from "./types";
|
||||
|
||||
@@ -34,16 +32,19 @@ export const noul = (
|
||||
* Create a score question using an ordered rubric.
|
||||
*
|
||||
* @param instructions - The question as text, a JSON object or array, or `null`.
|
||||
* @param criteria - A nonempty array or map indexed from zero with no gaps; descriptions may be `null`.
|
||||
* @param criteria - At least two descriptions indexed by score from zero; entries may be `null`.
|
||||
*/
|
||||
export const score = <const T extends ScoreCriteria>(
|
||||
instructions: EntryType,
|
||||
criteria: T,
|
||||
): ScoreQuestion<T> => ({
|
||||
type: "score",
|
||||
instructions,
|
||||
criteria,
|
||||
});
|
||||
): ScoreQuestion<T> => {
|
||||
if (!Array.isArray(criteria)) {
|
||||
throw new TypeSafeError(
|
||||
"Score criteria must be a list of descriptions indexed by score from zero, not a map.",
|
||||
);
|
||||
}
|
||||
return { type: "score", instructions, criteria };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a question that selects between named alternatives.
|
||||
@@ -62,56 +63,27 @@ export const choice = <const T extends ChoiceCriteria>(
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire normalization
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isScoreList = (criteria: ScoreCriteria): criteria is ScoreList => Array.isArray(criteria);
|
||||
|
||||
/** Validate score keys and convert the map to a nonempty array indexed from zero. */
|
||||
export const scoreMapToList = (map: ScoreMap, name: string): ScoreList => {
|
||||
const keys = Object.keys(map)
|
||||
.map((key) => {
|
||||
const n = Number(key);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new TypeSafeError(
|
||||
`Score question "${name}" has criteria key "${key}"; keys must be non-negative integers.`,
|
||||
);
|
||||
}
|
||||
return n;
|
||||
})
|
||||
.sort((a, b) => a - b);
|
||||
if (keys.length === 0) throw noScores(name);
|
||||
|
||||
const expected = keys.map((_, i) => i);
|
||||
if (keys.some((k, i) => k !== expected[i])) {
|
||||
throw new TypeSafeError(
|
||||
`Score question "${name}" defines scores ${keys.join(", ")}, but scores must run from 0 ` +
|
||||
`with no gaps (expected ${expected.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
return keys.map((k) => map[k]) as unknown as ScoreList;
|
||||
};
|
||||
|
||||
const noScores = (name: string): TypeSafeError =>
|
||||
new TypeSafeError(`Score question "${name}" has no criteria; at least one score is required.`);
|
||||
|
||||
/** Validate nonempty questions and score criteria, converting score maps to arrays. */
|
||||
export const toWireQuestions = <Q extends Questions>(questions: Q): Q => {
|
||||
/** Reject empty question sets and score questions without a list of at least two criteria. */
|
||||
export const validateQuestions = (questions: Questions): void => {
|
||||
if (Object.keys(questions).length === 0) {
|
||||
throw new TypeSafeError("At least one question is required.");
|
||||
}
|
||||
let changed = false;
|
||||
const wire: Questions = Object.create(null);
|
||||
for (const [name, question] of Object.entries(questions)) {
|
||||
if (question.type !== "score") {
|
||||
wire[name] = question;
|
||||
} else if (isScoreList(question.criteria)) {
|
||||
if (question.criteria.length === 0) throw noScores(name);
|
||||
wire[name] = question;
|
||||
} else {
|
||||
wire[name] = { ...question, criteria: scoreMapToList(question.criteria, name) };
|
||||
changed = true;
|
||||
if (question.type !== "score") continue;
|
||||
if (!Array.isArray(question.criteria)) {
|
||||
throw new TypeSafeError(
|
||||
`Score question "${name}" has criteria that are not a list; ` +
|
||||
"score criteria must be a list of descriptions indexed by score from zero.",
|
||||
);
|
||||
}
|
||||
if (question.criteria.length < 2) {
|
||||
throw new TypeSafeError(
|
||||
`Score question "${name}" has ${question.criteria.length} criteria; ` +
|
||||
"at least two scores are required.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return changed ? (wire as Q) : questions;
|
||||
};
|
||||
+6
-17
@@ -45,16 +45,8 @@ export interface ChoiceQuestion<T extends ChoiceCriteria = ChoiceCriteria> {
|
||||
criteria: T;
|
||||
}
|
||||
|
||||
/** A nonempty array indexed by score from zero; `null` leaves a score undescribed. */
|
||||
export type ScoreList = readonly [EntryType, ...EntryType[]];
|
||||
|
||||
/** Score descriptions keyed from zero with no gaps; `null` leaves a score undescribed. */
|
||||
export type ScoreMap = {
|
||||
readonly [score: number]: EntryType;
|
||||
};
|
||||
|
||||
/** An ordered rubric expressed as an array or score map. */
|
||||
export type ScoreCriteria = ScoreList | ScoreMap;
|
||||
/** At least two descriptions indexed by score from zero; `null` leaves a score undescribed. */
|
||||
export type ScoreCriteria = readonly [EntryType, EntryType, ...EntryType[]];
|
||||
|
||||
/** A question that assigns a score using an ordered rubric. */
|
||||
export interface ScoreQuestion<T extends ScoreCriteria = ScoreCriteria> {
|
||||
@@ -97,12 +89,10 @@ export interface ChoiceResponse<T extends ChoiceCriteria = ChoiceCriteria> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Score keys inferred from the rubric; tuple keys are numeric strings, map keys are numbers. */
|
||||
export type ScoreOf<T extends ScoreCriteria> = T extends readonly unknown[]
|
||||
? number extends T["length"]
|
||||
? number
|
||||
: Extract<keyof T, `${number}`>
|
||||
: Extract<keyof T, number>;
|
||||
/** Score keys inferred from the rubric; a fixed-length tuple yields its indices, otherwise `number`. */
|
||||
export type ScoreOf<T extends ScoreCriteria> = number extends T["length"]
|
||||
? number
|
||||
: Extract<keyof T, `${number}`>;
|
||||
|
||||
/** Rubric descriptions keyed by score. */
|
||||
export type ScoreLegend<T extends ScoreCriteria> = {
|
||||
@@ -166,7 +156,6 @@ export interface ModelCard {
|
||||
* State and named questions for `systemOne`.
|
||||
*
|
||||
* Additional properties on a request variable are forwarded, including `null` values.
|
||||
* Score maps are converted to arrays before sending.
|
||||
*/
|
||||
export interface SystemOneRequest<Q extends Questions = Questions> {
|
||||
/** Text, a JSON object or array, or `null` to evaluate. */
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
// Keep in sync with package.json "version". Checked by `npm run check:version`.
|
||||
export const VERSION = "0.5.7";
|
||||
export const VERSION = "0.6.0";
|
||||
+22
-53
@@ -312,32 +312,26 @@ describe("question builders", () => {
|
||||
expect(r.answers.q.choice).toBe("b");
|
||||
});
|
||||
|
||||
it("score keeps whichever shape it was given", () => {
|
||||
it("score keeps the list it was given and rejects maps", () => {
|
||||
expect(score("q", ["bad", "good"]).criteria).toEqual(["bad", "good"]);
|
||||
expect(score("q", { 0: "bad", 1: "good" }).criteria).toEqual({ 0: "bad", 1: "good" });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
|
||||
expect(() => score("q", { 0: "bad", 1: "good" } as any)).toThrow(
|
||||
"Score criteria must be a list of descriptions indexed by score from zero, not a map.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire format", () => {
|
||||
it("preserves null state, instructions, and criteria values during normalization", async () => {
|
||||
it("preserves null state, instructions, and criteria values", async () => {
|
||||
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
|
||||
const questions = {
|
||||
noul: noul(null, { true: null, false: null }),
|
||||
noCriteria: noul(null, null),
|
||||
choice: choice(null, { yes: null, no: null }),
|
||||
list: score(null, [null, "high"]),
|
||||
map: score(null, { 1: "high", 0: null }),
|
||||
score: score(null, [null, "high"]),
|
||||
};
|
||||
await new TypeSafeClient({ apiKey: "k", fetch }).systemOne({ state: null, questions });
|
||||
expect(requests[0]?.body).toEqual({
|
||||
model: DEFAULT_MODEL,
|
||||
state: null,
|
||||
questions: {
|
||||
...questions,
|
||||
map: { type: "score", instructions: null, criteria: [null, "high"] },
|
||||
},
|
||||
});
|
||||
expect(questions.map.criteria).toEqual({ 0: null, 1: "high" });
|
||||
expect(requests[0]?.body).toEqual({ model: DEFAULT_MODEL, state: null, questions });
|
||||
});
|
||||
|
||||
it("allows omitted instructions and preserves JSON arrays", async () => {
|
||||
@@ -345,7 +339,7 @@ describe("wire format", () => {
|
||||
const questions = {
|
||||
noul: { type: "noul", criteria: null },
|
||||
choice: { type: "choice", criteria: { yes: [null, { example: true }] } },
|
||||
score: { type: "score", criteria: { 0: [null, "low"], 1: null } },
|
||||
score: { type: "score", criteria: [[null, "low"], null] },
|
||||
arrayInstructions: noul([null, { examples: [1, false] }], { true: ["yes", null] }),
|
||||
defaultInstructions: noul(),
|
||||
} satisfies Questions;
|
||||
@@ -356,7 +350,6 @@ describe("wire format", () => {
|
||||
state,
|
||||
questions: {
|
||||
...questions,
|
||||
score: { type: "score", criteria: [[null, "low"], null] },
|
||||
defaultInstructions: { type: "noul", instructions: null },
|
||||
},
|
||||
});
|
||||
@@ -381,44 +374,25 @@ describe("wire format", () => {
|
||||
expect(wire.q?.criteria).toEqual(["bad", "ok", "great"]);
|
||||
});
|
||||
|
||||
it("converts a well-formed score map to a list", async () => {
|
||||
const wire = await send({ q: score("q", { 0: "bad", 1: "ok", 2: "great" }) });
|
||||
expect(wire.q?.criteria).toEqual(["bad", "ok", "great"]);
|
||||
// Key order in the source object does not matter.
|
||||
const shuffled = await send({ q: score("q", { 2: "great", 0: "bad", 1: "ok" }) });
|
||||
expect(shuffled.q?.criteria).toEqual(["bad", "ok", "great"]);
|
||||
});
|
||||
|
||||
it("rejects score maps with gaps or a non-zero start, naming the question", async () => {
|
||||
await expect(send({ q: score("q", { 0: "bad", 2: "great" }) })).rejects.toThrow(
|
||||
'Score question "q" defines scores 0, 2, but scores must run from 0 with no gaps (expected 0, 1)',
|
||||
);
|
||||
await expect(send({ urgency: score("q", { 1: "low", 2: "high" }) })).rejects.toThrow(
|
||||
'Score question "urgency" defines scores 1, 2',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-integer score keys, empty criteria, and empty question sets before sending", async () => {
|
||||
it("rejects non-list score criteria, fewer than two criteria, and empty question sets before sending", async () => {
|
||||
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
|
||||
const client = new TypeSafeClient({ apiKey: "k", fetch });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
|
||||
const bad = (criteria: any) =>
|
||||
client.systemOne({ state: "s", questions: { q: score("q", criteria) } });
|
||||
expect(() => bad({ 1.5: "x" })).toThrow(TypeSafeError);
|
||||
expect(() => bad({ "-1": "x" })).toThrow("non-negative integers");
|
||||
expect(() => bad({})).toThrow("at least one score");
|
||||
expect(() => bad([])).toThrow("at least one score");
|
||||
client.systemOne({ state: "s", questions: { q: { type: "score", criteria } } });
|
||||
expect(() => bad({ 0: "bad", 1: "ok" })).toThrow(TypeSafeError);
|
||||
expect(() => bad({ 0: "bad", 1: "ok" })).toThrow(
|
||||
'Score question "q" has criteria that are not a list',
|
||||
);
|
||||
expect(() => bad([])).toThrow(
|
||||
'Score question "q" has 0 criteria; at least two scores are required.',
|
||||
);
|
||||
expect(() => bad(["only"])).toThrow("at least two scores");
|
||||
expect(() => client.systemOne({ state: "s", questions: {} })).toThrow(
|
||||
"At least one question is required",
|
||||
);
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not mutate the caller's questions", async () => {
|
||||
const questions = { q: score("q", { 0: "bad", 1: "ok" }) };
|
||||
await send(questions);
|
||||
expect(questions.q.criteria).toEqual({ 0: "bad", 1: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("1.0 primitive contract", () => {
|
||||
@@ -436,22 +410,17 @@ describe("1.0 primitive contract", () => {
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("forwards extra fields and null without losing score normalization", async () => {
|
||||
it("forwards extra fields and null", async () => {
|
||||
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
|
||||
const client = new TypeSafeClient({ apiKey: "k", fetch });
|
||||
const request = {
|
||||
state: "s",
|
||||
questions: { q: score("?", { 0: "low", 1: "high" }) },
|
||||
questions: { q: score("?", ["low", "high"]) },
|
||||
future_option: null,
|
||||
nested: { enabled: true },
|
||||
};
|
||||
await client.systemOne(request);
|
||||
expect(requests[0]?.body).toEqual({
|
||||
...request,
|
||||
model: "jev-latest",
|
||||
questions: { q: { type: "score", instructions: "?", criteria: ["low", "high"] } },
|
||||
});
|
||||
expect(request.questions.q.criteria).toEqual({ 0: "low", 1: "high" });
|
||||
expect(requests[0]?.body).toEqual({ ...request, model: "jev-latest" });
|
||||
await client.systemOne({ state: "s", questions: { q: noul("?") } });
|
||||
expect(requests[1]?.body).not.toHaveProperty("future_option");
|
||||
});
|
||||
|
||||
@@ -84,23 +84,6 @@ describeLive("live API", () => {
|
||||
expect(sum(urgency.probabilities)).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it("accepts a score map, which is sent as a list", async () => {
|
||||
const data = await client.systemOne({
|
||||
state: ticket,
|
||||
questions: {
|
||||
refundRisk: score("How likely is a refund demand?", {
|
||||
0: "unlikely",
|
||||
1: "possible",
|
||||
2: "likely",
|
||||
}),
|
||||
},
|
||||
});
|
||||
show("score map", data.answers.refundRisk);
|
||||
expect(data.answers.refundRisk.legend).toEqual({ 0: "unlikely", 1: "possible", 2: "likely" });
|
||||
expect(data.answers.refundRisk.score).toBeGreaterThanOrEqual(0);
|
||||
expect(data.answers.refundRisk.score).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("accepts rich descriptions and one-sided noul criteria", async () => {
|
||||
const data = await client.systemOne({
|
||||
state: ticket,
|
||||
@@ -145,7 +128,7 @@ describeLive("live API", () => {
|
||||
|
||||
it("surfaces server-side validation errors readably", async () => {
|
||||
// A description type the SDK does not validate but the API rejects, to see how a 422 renders.
|
||||
const malformed = { q: score("?", [123 as unknown as string]) };
|
||||
const malformed = { q: score("?", [123 as unknown as string, "ok"]) };
|
||||
const err = await client
|
||||
.systemOne({ state: "x", questions: malformed }, { retry: { maxRetries: 0 } })
|
||||
.catch((e: unknown) => e);
|
||||
@@ -156,8 +139,10 @@ describeLive("live API", () => {
|
||||
|
||||
it("catches shapes the API would reject before sending", () => {
|
||||
expect(() => client.systemOne({ state: "x", questions: {} })).toThrow(TypeSafeError);
|
||||
expect(() =>
|
||||
client.systemOne({ state: "x", questions: { q: score("?", { 0: "a", 2: "c" }) } }),
|
||||
).toThrow(TypeSafeError);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
|
||||
const empty: any = [];
|
||||
expect(() => client.systemOne({ state: "x", questions: { q: score("?", empty) } })).toThrow(
|
||||
TypeSafeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -69,9 +69,9 @@ describe("release regressions", () => {
|
||||
expect(headers.has("x-typesafe-retry-count")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves own __proto__ questions through score-map normalization without mutation", async () => {
|
||||
it("preserves own __proto__ questions without mutation", async () => {
|
||||
const questions = JSON.parse('{"__proto__":{"type":"noul","instructions":"?"}}');
|
||||
questions.score = score("?", { 0: "no", 1: "yes" });
|
||||
questions.score = score("?", ["no", "yes"]);
|
||||
const original = JSON.stringify(questions);
|
||||
const { fetch, requests } = mockFetch(() => json({}));
|
||||
await new TypeSafeClient({ apiKey: "k", fetch }).systemOne({ state: "s", questions });
|
||||
|
||||
+25
-20
@@ -25,7 +25,7 @@ describe("systemOne result inference", () => {
|
||||
questions: {
|
||||
a: noul("x"),
|
||||
b: choice("y", { yes: null, no: "desc" }),
|
||||
c: score("z", { 0: "bad", 1: "ok" }),
|
||||
c: score("z", ["bad", "ok"]),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,9 +41,7 @@ describe("systemOne result inference", () => {
|
||||
readonly no: number;
|
||||
}>();
|
||||
|
||||
expectTypeOf(r.answers.c).toEqualTypeOf<
|
||||
ScoreResponse<{ readonly 0: "bad"; readonly 1: "ok" }>
|
||||
>();
|
||||
expectTypeOf(r.answers.c).toEqualTypeOf<ScoreResponse<readonly ["bad", "ok"]>>();
|
||||
expectTypeOf(r.answers.c.score).toEqualTypeOf<number>();
|
||||
expectTypeOf(r.answers.c.legend).toEqualTypeOf<{ readonly 0: "bad"; readonly 1: "ok" }>();
|
||||
expectTypeOf(r.answers.c.probabilities).toEqualTypeOf<{
|
||||
@@ -86,7 +84,7 @@ describe("systemOne result inference", () => {
|
||||
});
|
||||
|
||||
it("degrades to numeric indexing for non-literal score lists", async () => {
|
||||
const list: [string, ...string[]] = ["a", "b"];
|
||||
const list: [string, string, ...string[]] = ["a", "b"];
|
||||
const r = await client.systemOne({ state: "s", questions: { c: score("z", list) } });
|
||||
expectTypeOf(r.answers.c.probabilities).toEqualTypeOf<{ readonly [score: number]: number }>();
|
||||
expectTypeOf(r.answers.c.legend).toEqualTypeOf<{ readonly [score: number]: string }>();
|
||||
@@ -138,8 +136,8 @@ describe("systemOne result inference", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("criteria maps", () => {
|
||||
it("rejects label-list shorthand", () => {
|
||||
describe("criteria shapes", () => {
|
||||
it("rejects label-list shorthand for choice", () => {
|
||||
// @ts-expect-error criteria must be a map
|
||||
choice("q", ["a", "b"]);
|
||||
// @ts-expect-error empty label lists are not maps
|
||||
@@ -147,6 +145,18 @@ describe("criteria maps", () => {
|
||||
const labels: string[] = ["a", "b"];
|
||||
// @ts-expect-error dynamic label lists are not maps
|
||||
choice("q", labels);
|
||||
});
|
||||
|
||||
it("rejects numeric-keyed maps for score", () => {
|
||||
// @ts-expect-error score criteria must be a list
|
||||
score("q", { 0: "bad", 1: "ok" });
|
||||
// @ts-expect-error empty lists are not valid rubrics
|
||||
score("q", []);
|
||||
// @ts-expect-error a rubric needs at least two levels
|
||||
score("q", ["only"]);
|
||||
const dynamic: string[] = ["bad", "ok"];
|
||||
// @ts-expect-error a plain string[] may be empty
|
||||
score("q", dynamic);
|
||||
// @ts-expect-error inference is a client method, not a resource
|
||||
client.systemOne.run({ state: "s", questions: { q: noul("?") } });
|
||||
});
|
||||
@@ -157,21 +167,14 @@ describe("question helpers", () => {
|
||||
// @ts-expect-error numbers are not valid descriptions
|
||||
choice("q", { yes: 1 });
|
||||
// @ts-expect-error numbers are not valid descriptions
|
||||
score("q", { 0: 1 });
|
||||
// @ts-expect-error numbers are not valid descriptions
|
||||
score("q", [1, 2]);
|
||||
score("q", ["ok", null]);
|
||||
score("q", { 0: null, 1: "ok" });
|
||||
noul(null, { true: null, false: null });
|
||||
noul(null, null);
|
||||
noul();
|
||||
choice(null, { yes: null });
|
||||
score(null, [null]);
|
||||
// An empty list is not catchable at the type level (it satisfies the map shape); the client
|
||||
// rejects it at runtime instead.
|
||||
score("q", []);
|
||||
score(null, [null, null]);
|
||||
choice("q", { yes: null, no: "ok", maybe: { detail: "rich" } });
|
||||
score("q", { 0: "bad", 1: "ok", 2: { detail: "rich" } });
|
||||
score("q", ["bad", "ok", { detail: "rich" }]);
|
||||
noul("q", { true: { detail: "rich" } });
|
||||
noul("q", { false: "only one side" });
|
||||
@@ -184,13 +187,16 @@ describe("question helpers", () => {
|
||||
noul: { type: "noul" },
|
||||
choice: { type: "choice", criteria: { yes: null, no: [null, "example"] } },
|
||||
list: score(null, [null, "high"]),
|
||||
map: { type: "score", criteria: { 0: null, 1: "high" } },
|
||||
literal: { type: "score", criteria: [null, "high"] },
|
||||
},
|
||||
});
|
||||
expectTypeOf(r.answers.noul).toEqualTypeOf<NoulResponse>();
|
||||
expectTypeOf(r.answers.choice.choice).toEqualTypeOf<"yes" | "no">();
|
||||
expectTypeOf(r.answers.list.legend).toEqualTypeOf<{ readonly 0: null; readonly 1: "high" }>();
|
||||
expectTypeOf(r.answers.map.legend).toEqualTypeOf<{ readonly 0: null; readonly 1: "high" }>();
|
||||
expectTypeOf(r.answers.literal.legend).toEqualTypeOf<{
|
||||
readonly 0: null;
|
||||
readonly 1: "high";
|
||||
}>();
|
||||
expectTypeOf(r.answers.list.probabilities).toEqualTypeOf<{
|
||||
readonly 0: number;
|
||||
readonly 1: number;
|
||||
@@ -200,7 +206,7 @@ describe("question helpers", () => {
|
||||
questions: {
|
||||
q: noul([null, "instructions"], { true: [null, "criterion"] }),
|
||||
c: choice(["instructions"], { yes: [null, "criterion"] }),
|
||||
s: score(["instructions"], [[null, "criterion"]]),
|
||||
s: score(["instructions"], [[null, "criterion"], null]),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -209,8 +215,7 @@ describe("question helpers", () => {
|
||||
expectTypeOf(noul("x")).toMatchTypeOf<Question>();
|
||||
expectTypeOf(choice("x", { a: null })).toMatchTypeOf<Question>();
|
||||
expectTypeOf(choice("x", { a: null, b: null })).toMatchTypeOf<Question>();
|
||||
expectTypeOf(score("x", { 0: "a" })).toMatchTypeOf<Question>();
|
||||
expectTypeOf(score("x", ["a"])).toMatchTypeOf<Question>();
|
||||
expectTypeOf(score("x", ["a", "b"])).toMatchTypeOf<Question>();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user