diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..537a474 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests +on: + pull_request: + branches: + - main +jobs: + build: + runs-on: ubuntu-20.04 + strategy: + matrix: + node-version: [20] + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + - name: Run tests + run: pnpm test diff --git a/package.json b/package.json index 86471c3..3b76da4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "paraglide-js compile --project ./project.inlang && vite build", "preview": "vite preview", "test": "vitest", + "update:compat": "pnpx vite-node ./src/updateCompatData.ts", "test:e2e": "NODE_NO_WARNINGS=1 playwright test", "test:gen": "npx playwright codegen http://localhost:5173/", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/src/lib/playground/format.utils.test.ts b/src/lib/playground/format.utils.test.ts new file mode 100644 index 0000000..7a50045 --- /dev/null +++ b/src/lib/playground/format.utils.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "vitest"; +import { + getItemsFromOption, + prepareInputValues, + prepareSchemaForOutput, + schemaToCode, + schemaToFormatOptions, + schemaToPrimaryFormatterOutput, + updateOptionOnSchema +} from "./format.utils"; +import { + dateTimeFormatOptionFactory, + eventFactory, + numberFormatOptionFactory, + numberFormatSchemaFactory +} from "$utils/factory"; +import { dateTimeFormatSchema } from "./schemas/dateTimeFormat.schema"; +import type { PlaygroundOption, PlaygroundSchema } from "./playground.schema"; +import { durationFormatSchema } from "./schemas/durationFormat.schema"; + +describe("updateOptionOnSchema", () => { + test("update text field option", () => { + expect( + updateOptionOnSchema( + { ...dateTimeFormatSchema }, + eventFactory({ + target: { + name: "hour", + value: "8" + } + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + dateTimeFormatOptionFactory({ + inputType: "select", + name: "hour", + value: "8", + valueType: "string", + defaultValue: undefined, + selected: undefined + }) + ]) + }) + ); + }); + + test("update checkbox field option", () => { + expect( + updateOptionOnSchema( + { ...dateTimeFormatSchema }, + eventFactory({ + target: { + name: "hour12", + checked: true, + type: "checkbox" + } + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + dateTimeFormatOptionFactory({ + inputType: "radio", + name: "hour12", + valueType: "boolean", + defaultValue: undefined, + selected: true + }) + ]) + }) + ); + }); + + test("update radio field option", () => { + expect( + updateOptionOnSchema( + { ...dateTimeFormatSchema }, + eventFactory({ + target: { + name: "hour12", + value: "true", + type: "radio" + } + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + dateTimeFormatOptionFactory({ + inputType: "radio", + name: "hour12", + value: true, + valueType: "boolean", + defaultValue: undefined, + selected: undefined + }) + ]) + }) + ); + }); + test("clamp field with min/max options", () => { + expect( + updateOptionOnSchema( + { ...dateTimeFormatSchema }, + eventFactory({ + target: { + name: "fractionalSecondDigits", + value: "6" + } + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + dateTimeFormatOptionFactory({ + inputType: "text", + name: "fractionalSecondDigits", + min: 0, + max: 3, + value: 3, + valueType: "number", + defaultValue: undefined, + selected: undefined + }) + ]) + }) + ); + }); +}); + +describe("schemaToFormatOptions", () => { + test("keep options with values", () => { + expect( + schemaToFormatOptions( + numberFormatSchemaFactory({ + options: [ + { + name: "roundingIncrement", + inputType: "text", + value: "12" + } + ] + }) + ) + ).toEqual({ + roundingIncrement: "12" + }); + }); + test("remove empty values", () => { + expect( + schemaToFormatOptions( + numberFormatSchemaFactory({ + options: [ + { + name: "roundingIncrement", + inputType: "text", + value: "" + } + ] + }) + ) + ).toEqual({}); + }); + test("remove inactive values", () => { + expect( + schemaToFormatOptions( + numberFormatSchemaFactory({ + options: [ + { + name: "roundingIncrement", + inputType: "text", + value: "12", + selected: false + } + ] + }) + ) + ).toEqual({}); + }); +}); + +describe("getItemsFromOption", () => { + test("get items", () => { + expect( + getItemsFromOption( + "NumberFormat", + numberFormatOptionFactory({ + name: "style" + }) + ) + ).toEqual([ + ["currency", "currency"], + ["unit", "unit"] + ]); + }); +}); + +describe("prepareSchemaForOutput", () => { + test("get options when any", () => { + expect( + prepareSchemaForOutput( + numberFormatSchemaFactory({ + options: [ + { + name: "roundingIncrement", + inputType: "text", + value: "12" + } + ] + }) + ) + ).toEqual({ + options: { + roundingIncrement: "12" + }, + hasOptions: true + }); + }); + + test("do not get options when empty", () => { + expect( + prepareSchemaForOutput( + numberFormatSchemaFactory({ + options: [ + { + name: "roundingIncrement", + inputType: "text", + value: undefined + } + ] + }) + ) + ).toEqual({ + options: {}, + hasOptions: false + }); + }); +}); + +describe("prepareInputValues", () => { + test("format date input values", () => { + const [date1, date2] = prepareInputValues({ ...dateTimeFormatSchema }); + expect(date1).toBeInstanceOf(Date); + expect(date2).toBeInstanceOf(Date); + }); + + test("format duration format input values", () => { + const [value] = prepareInputValues({ ...durationFormatSchema }); + expect(value).toEqual({ + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7, + milliseconds: 8, + microseconds: 9, + nanoseconds: 10 + }); + }); + + test("format regular values", () => { + const [value] = prepareInputValues(numberFormatSchemaFactory()); + expect(value).toEqual(1091); + }); +}); + +describe("schemaToPrimaryFormatterOutput", () => { + test("format default", () => { + expect(schemaToPrimaryFormatterOutput(numberFormatSchemaFactory(), [])).toEqual("1,091"); + }); +}); + +describe("schemaToCode", () => { + test("format default", () => { + expect(schemaToCode(numberFormatSchemaFactory(), [])).toEqual("new Intl.NumberFormat(undefined).format(1091)\n"); + }) +}) diff --git a/src/lib/playground/format.utils.ts b/src/lib/playground/format.utils.ts index 0960185..fac97a8 100644 --- a/src/lib/playground/format.utils.ts +++ b/src/lib/playground/format.utils.ts @@ -75,7 +75,7 @@ export const getItemsFromOption = ( return options?.map((option) => [option, option]) ?? []; }; -const prepareSchemaForOutput = ( +export const prepareSchemaForOutput = ( schema: PlaygroundSchema, ) => { const options = schemaToFormatOptions(schema); diff --git a/src/lib/playground/validate.test.ts b/src/lib/playground/validate.test.ts new file mode 100644 index 0000000..dad1bd2 --- /dev/null +++ b/src/lib/playground/validate.test.ts @@ -0,0 +1,159 @@ +import { test, describe, expect } from "vitest"; +import { optionIsActive, validateAndUpdateSchema } from "./validate"; +import { numberFormatOptionFactory, numberFormatSchemaFactory } from "$utils/factory"; +import type { PlaygroundOption, PlaygroundSchema } from "./playground.schema"; +import { numberFormatSchema } from "./schemas/numberFormat.schema"; + +describe("option is active", () => { + test("option is active if selected", () => { + expect( + optionIsActive( + numberFormatOptionFactory({ + selected: true + }) + ) + ).toBeTruthy(); + }); + + test("option is active if value", () => { + expect( + optionIsActive( + numberFormatOptionFactory({ + selected: undefined, + value: 10 + }) + ) + ).toBeTruthy(); + }); + + test("option is active if defaulValue", () => { + expect( + optionIsActive( + numberFormatOptionFactory({ + selected: undefined, + value: undefined, + defaultValue: 10 + }) + ) + ).toBeTruthy(); + }); +}); + +describe("option is inactive", () => { + test("option is inactive if unselected", () => { + expect( + optionIsActive( + numberFormatOptionFactory({ + selected: false + }) + ) + ).toBeFalsy(); + }); + + test("option is inactive if no value", () => { + expect( + optionIsActive( + numberFormatOptionFactory({ + selected: undefined, + value: undefined, + defaultValue: undefined + }) + ) + ).toBeFalsy(); + }); +}); + +describe("validateAndUpdateSchema", () => { + test("remove invalid options", () => { + const option = numberFormatOptionFactory({ + name: "style", + value: undefined, + defaultValue: undefined, + valueType: "string", + inputType: "select" + }); + expect( + validateAndUpdateSchema( + numberFormatSchemaFactory({ + options: [option], + invalidOptionCombos: numberFormatSchema.invalidOptionCombos + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.not.arrayContaining>([option]) + }) + ); + }); + + test("set unit if missing when style is unit", () => { + const styleOption = numberFormatOptionFactory({ + name: "style", + value: "unit", + defaultValue: undefined, + valueType: "string", + inputType: "select" + }); + const unitOption = numberFormatOptionFactory({ + name: "unit", + value: undefined, + defaultValue: undefined, + valueType: "string", + inputType: "select" + }); + expect( + validateAndUpdateSchema( + numberFormatSchemaFactory({ + options: [styleOption, unitOption], + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + numberFormatOptionFactory({ + name: "style", + valueType: "string", + defaultValue: "currency", + inputType: "select", + value: "unit" + }), + numberFormatOptionFactory({ + name: "unit", + valueType: "string", + defaultValue: undefined, + value: "degree", + inputType: "select" + }), + ]) + }) + ); + }); + + test("pass through option based on schema and set selected and value", () => { + const option = numberFormatOptionFactory({ + name: "maximumFractionDigits", + }); + expect( + validateAndUpdateSchema( + numberFormatSchemaFactory({ + options: [option], + }) + ) + ).toEqual( + expect.objectContaining>>({ + options: expect.arrayContaining>([ + numberFormatOptionFactory({ + name: "maximumFractionDigits", + defaultValue: undefined, + max: 20, + min: 1, + valueType: "number", + inputType: "text", + value: undefined, + selected: undefined, + }), + ]) + }) + ); + }); +}); diff --git a/src/lib/types/common.ts b/src/lib/types/common.ts new file mode 100644 index 0000000..9719f6e --- /dev/null +++ b/src/lib/types/common.ts @@ -0,0 +1,27 @@ +/** + * Makes every property of an object optional, even nested properties. + */ +type DeepPartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? DeepPartial[] + : T[P] extends Readonly[] + ? Readonly>[] + : DeepPartial; +}; + +/** + * Ensures that the consumer can override any prop of the model + * and that the return type of the factory matches the type + * of the stub. + * @example + * ``` + * type Person = { firstName: string, lastName: string }; + * export const person: Factory = (overrides = {}) => ({ + * firstName: "A default first name", + * lastName: "A default last name", + * // Any overrides from consumer + * ...overrides + * }) + * ``` + */ +export type Factory = (props?: DeepPartial) => Model; \ No newline at end of file diff --git a/src/lib/utils/factory.ts b/src/lib/utils/factory.ts new file mode 100644 index 0000000..bb62bb1 --- /dev/null +++ b/src/lib/utils/factory.ts @@ -0,0 +1,46 @@ +import type { PlaygroundOption, PlaygroundSchema } from "$lib/playground/playground.schema"; +import type { Factory } from "$types/common"; + +export const numberFormatOptionFactory: Factory> = (overrides = {}) => ({ + name: "minimumIntegerDigits", + valueType: "number", + inputType: "text", + defaultValue: undefined, + value: undefined, + selected: undefined, + ...overrides, +}) + +export const dateTimeFormatOptionFactory: Factory> = (overrides = {}) => ({ + name: "hour", + valueType: "number", + inputType: "text", + defaultValue: undefined, + value: undefined, + selected: undefined, + ...overrides, +}) + +export const numberFormatSchemaFactory: Factory> = (overrides = {}) => ({ + inputValues: [1091, 2000], + ...overrides, + method: "NumberFormat", + primaryFormatter: "format", + inputValueType: "number", + invalidOptionCombos: {}, + options: overrides?.options?.map(numberFormatOptionFactory) ?? [], + secondaryFormatters: ["formatToParts", "formatRange", "formatRangeToParts"], +}) + +export const htmlInputElementFactory: Factory = (overrides = {}) => { + return { + ...overrides + } as HTMLInputElement; +} + +export const eventFactory: Factory & { target: HTMLInputElement | null }> = (overrides = {}) => { + return { + ...overrides, + target: overrides.target ? htmlInputElementFactory(overrides.target) : null, + } as Omit & { target: HTMLInputElement | null }; +} diff --git a/src/lib/utils/format-utils.test.ts b/src/lib/utils/format-utils.test.ts new file mode 100644 index 0000000..d311aa6 --- /dev/null +++ b/src/lib/utils/format-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "vitest"; +import { camelCaseToWords, clampValue, formatLocaleForUrl, formatLocalesForPrint, tryFormat } from "./format-utils"; +import { numberFormatOptionFactory } from "./factory"; + +describe("clampValue", () => { + test("clamp max", () => { + expect( + clampValue( + numberFormatOptionFactory({ + valueType: "number", + max: 9 + }), + "12" + ) + ).toEqual(9); + }); + + test("clamp min", () => { + expect( + clampValue( + numberFormatOptionFactory({ + valueType: "number", + min: 1 + }), + "-19" + ) + ).toEqual(1); + }); + + test("no clamp if no number", () => { + expect( + clampValue( + numberFormatOptionFactory({ + valueType: "boolean", + min: 1 + }), + "true" + ) + ).toEqual("true"); + }); + + test("fallback to defaultValue if faulty value", () => { + expect( + clampValue( + numberFormatOptionFactory({ + valueType: "number", + min: 1 + }), + "true" + ) + ).toEqual(undefined); + }); +}); + +describe("tryFormat", () => { + test("fail on faulty format", () => { + expect( + tryFormat(() => + Intl.NumberFormat(undefined, { + style: "currency" + }).format(19) + ) + ).toEqual("Currency code is required with currency style."); + }); + + test("output of fine format", () => { + expect( + tryFormat(() => + Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD" + }).format(19) + ) + ).toEqual("$19.00"); + }); +}); + +describe("camelCaseToWords", () => { + test("convert", () => { + expect(camelCaseToWords("iAmACamel")).toEqual("I Am A Camel"); + }); +}); + +describe("formatLocalesForPrint", () => { + test("format one locale", () => { + expect(formatLocalesForPrint(["sv"])).toEqual('"sv"'); + }); + + test("format multiple locale", () => { + expect(formatLocalesForPrint(["sv", "en"])).toEqual("[\"sv\",\"en\"]"); + }); + + test("format zero locale", () => { + expect(formatLocalesForPrint([])).toEqual("undefined"); + }); +}); + +describe("formatLocaleForUrl", () => { + test("format locales", () => { + expect(formatLocaleForUrl(["sv"])).toEqual('?locale=sv'); + }); + test("format locales", () => { + expect(formatLocaleForUrl(["sv", "en"])).toEqual('?locale=sv,en'); + }); +}); diff --git a/src/routes/utils/+server.ts b/src/routes/utils/+server.ts deleted file mode 100644 index 614b963..0000000 --- a/src/routes/utils/+server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { writeCompatData } from "$utils/write-compat-data"; -import type { RequestEvent } from "@sveltejs/kit"; - -export function POST(event: RequestEvent) { - const secret = event.url.searchParams.get("secret"); - const envSecret = process.env.SECRET; - if (envSecret && secret && secret === process.env.SECRET) { - writeCompatData(); - return new Response("ok"); - } - return new Response("no no"); -} diff --git a/src/updateCompatData.ts b/src/updateCompatData.ts new file mode 100644 index 0000000..349fd80 --- /dev/null +++ b/src/updateCompatData.ts @@ -0,0 +1,3 @@ +import { writeCompatData } from "$utils/write-compat-data"; + +writeCompatData(); \ No newline at end of file