diff --git a/CHANGELOG.md b/CHANGELOG.md index f065ed307f85..10de8b5fc57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ (Cherry-picked from https://github.com/TeamNijimiss/misskey/commit/800359623e41a662551d774de15b0437b6849bb4) - Fix: ノート作成画面でファイルの添付可能個数を超えてもノートボタンが押せていた問題を修正 - Fix: 「アカウントを管理」画面で、ユーザー情報の取得に失敗したアカウント(削除されたアカウントなど)が表示されない問題を修正 +- Enhance: AiScriptの拡張API関数において引数の型チェックをより厳格に ### Server - Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 8afe88eec6d3..e203c51bba62 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -3,14 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { utils, values } from '@syuilo/aiscript'; +import { errors, utils, values } from '@syuilo/aiscript'; import * as Misskey from 'misskey-js'; +import { url, lang } from '@@/js/config.js'; +import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { url, lang } from '@@/js/config.js'; + +const DIALOG_TYPES = [ + 'error', + 'info', + 'success', + 'warning', + 'waiting', + 'question', +] as const; export function aiScriptReadline(q: string): Promise { return new Promise(ok => { @@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise { }); } -export function createAiScriptEnv(opts) { +export function createAiScriptEnv(opts: { storageKey: string, token?: string }) { return { USER_ID: $i ? values.STR($i.id) : values.NULL, - USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_NAME: $i?.name ? values.STR($i.name) : values.NULL, USER_USERNAME: $i ? values.STR($i.username) : values.NULL, CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), LOCALE: values.STR(lang), SERVER_URL: values.STR(url), 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } await os.alert({ type: type ? type.value : 'info', title: title.value, @@ -39,6 +54,11 @@ export function createAiScriptEnv(opts) { return values.NULL; }), 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } const confirm = await os.confirm({ type: type ? type.value : 'question', title: title.value, @@ -48,14 +68,20 @@ export function createAiScriptEnv(opts) { }), 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { utils.assertString(ep); - if (ep.value.includes('://')) throw new Error('invalid endpoint'); + if (ep.value.includes('://')) { + throw new errors.AiScriptRuntimeError('invalid endpoint'); + } if (token) { utils.assertString(token); // バグがあればundefinedもあり得るため念のため if (typeof token.value !== 'string') throw new Error('invalid token'); } const actualToken: string|null = token?.value ?? opts.token ?? null; - return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => { + if (param == null) { + throw new errors.AiScriptRuntimeError('expected param'); + } + utils.assertObject(param); + return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => { return utils.jsToVal(res); }, err => { return values.ERROR('request_failed', utils.jsToVal(err)); diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/scripts/aiscript/common.ts new file mode 100644 index 000000000000..de6fa1d63361 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/common.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { errors, utils, type values } from '@syuilo/aiscript'; + +export function assertStringAndIsIn(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } { + utils.assertString(value); + const str = value.value; + if (!expects.includes(str)) { + const expected = expects.map((expect) => `"${expect}"`).join(', '); + throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`); + } +} diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2b386bebb81a..ca92b27ff54e 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -7,6 +7,15 @@ import { utils, values } from '@syuilo/aiscript'; import { v4 as uuid } from 'uuid'; import { ref, Ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { assertStringAndIsIn } from './common.js'; + +const ALIGNS = ['left', 'center', 'right'] as const; +const FONTS = ['serif', 'sans-serif', 'monospace'] as const; +const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const; + +type Align = (typeof ALIGNS)[number]; +type Font = (typeof FONTS)[number]; +type BorderStyle = (typeof BORDER_STYLES)[number]; export type AsUiComponentBase = { id: string; @@ -21,13 +30,13 @@ export type AsUiRoot = AsUiComponentBase & { export type AsUiContainer = AsUiComponentBase & { type: 'container'; children?: AsUiComponent['id'][]; - align?: 'left' | 'center' | 'right'; + align?: Align; bgColor?: string; fgColor?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; borderWidth?: number; borderColor?: string; - borderStyle?: 'hidden' | 'dotted' | 'dashed' | 'solid' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'; + borderStyle?: BorderStyle; borderRadius?: number; padding?: number; rounded?: boolean; @@ -40,7 +49,7 @@ export type AsUiText = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; }; export type AsUiMfm = AsUiComponentBase & { @@ -49,14 +58,14 @@ export type AsUiMfm = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; - onClickEv?: (evId: string) => void + font?: Font; + onClickEv?: (evId: string) => Promise; }; export type AsUiButton = AsUiComponentBase & { type: 'button'; text?: string; - onClick?: () => void; + onClick?: () => Promise; primary?: boolean; rounded?: boolean; disabled?: boolean; @@ -69,7 +78,7 @@ export type AsUiButtons = AsUiComponentBase & { export type AsUiSwitch = AsUiComponentBase & { type: 'switch'; - onChange?: (v: boolean) => void; + onChange?: (v: boolean) => Promise; default?: boolean; label?: string; caption?: string; @@ -77,7 +86,7 @@ export type AsUiSwitch = AsUiComponentBase & { export type AsUiTextarea = AsUiComponentBase & { type: 'textarea'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise; default?: string; label?: string; caption?: string; @@ -85,7 +94,7 @@ export type AsUiTextarea = AsUiComponentBase & { export type AsUiTextInput = AsUiComponentBase & { type: 'textInput'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise; default?: string; label?: string; caption?: string; @@ -93,7 +102,7 @@ export type AsUiTextInput = AsUiComponentBase & { export type AsUiNumberInput = AsUiComponentBase & { type: 'numberInput'; - onInput?: (v: number) => void; + onInput?: (v: number) => Promise; default?: number; label?: string; caption?: string; @@ -105,7 +114,7 @@ export type AsUiSelect = AsUiComponentBase & { text: string; value: string; }[]; - onChange?: (v: string) => void; + onChange?: (v: string) => Promise; default?: string; label?: string; caption?: string; @@ -140,11 +149,15 @@ export type AsUiPostForm = AsUiComponentBase & { export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm; +type Options = T extends AsUiButtons + ? Omit & { 'buttons'?: Options[] } + : Omit; + export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise) { // TODO } -function getRootOptions(def: values.Value | undefined): Omit { +function getRootOptions(def: values.Value | undefined): Options { utils.assertObject(def); const children = def.value.get('children'); @@ -153,30 +166,32 @@ function getRootOptions(def: values.Value | undefined): Omit { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }), }; } -function getContainerOptions(def: values.Value | undefined): Omit { +function getContainerOptions(def: values.Value | undefined): Options { utils.assertObject(def); const children = def.value.get('children'); if (children) utils.assertArray(children); const align = def.value.get('align'); - if (align) utils.assertString(align); + if (align) assertStringAndIsIn(align, ALIGNS); const bgColor = def.value.get('bgColor'); if (bgColor) utils.assertString(bgColor); const fgColor = def.value.get('fgColor'); if (fgColor) utils.assertString(fgColor); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); const borderWidth = def.value.get('borderWidth'); if (borderWidth) utils.assertNumber(borderWidth); const borderColor = def.value.get('borderColor'); if (borderColor) utils.assertString(borderColor); const borderStyle = def.value.get('borderStyle'); - if (borderStyle) utils.assertString(borderStyle); + if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES); const borderRadius = def.value.get('borderRadius'); if (borderRadius) utils.assertNumber(borderRadius); const padding = def.value.get('padding'); @@ -189,7 +204,9 @@ function getContainerOptions(def: values.Value | undefined): Omit { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], align: align?.value, fgColor: fgColor?.value, @@ -205,7 +222,7 @@ function getContainerOptions(def: values.Value | undefined): Omit { +function getTextOptions(def: values.Value | undefined): Options { utils.assertObject(def); const text = def.value.get('text'); @@ -217,7 +234,7 @@ function getTextOptions(def: values.Value | undefined): Omit Promise): Omit { +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const text = def.value.get('text'); @@ -240,7 +257,7 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg const color = def.value.get('color'); if (color) utils.assertString(color); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); const onClickEv = def.value.get('onClickEv'); if (onClickEv) utils.assertFunction(onClickEv); @@ -250,13 +267,13 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg bold: bold?.value, color: color?.value, font: font?.value, - onClickEv: (evId: string) => { - if (onClickEv) call(onClickEv, [values.STR(evId)]); + onClickEv: async (evId: string) => { + if (onClickEv) await call(onClickEv, [values.STR(evId)]); }, }; } -function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -269,8 +286,8 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -278,7 +295,7 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF }; } -function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -291,8 +308,8 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -300,7 +317,7 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn }; } -function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -313,8 +330,8 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -322,7 +339,7 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. }; } -function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const text = def.value.get('text'); @@ -338,8 +355,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text?.value, - onClick: () => { - if (onClick) call(onClick, []); + onClick: async () => { + if (onClick) await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -347,7 +364,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const buttons = def.value.get('buttons'); @@ -369,8 +386,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text.value, - onClick: () => { - call(onClick, []); + onClick: async () => { + await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -380,7 +397,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const onChange = def.value.get('onChange'); @@ -393,8 +410,8 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, if (caption) utils.assertString(caption); return { - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -402,7 +419,7 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const items = def.value.get('items'); @@ -428,8 +445,8 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, value: value ? value.value : text.value, }; }) : [], - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -437,7 +454,7 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getFolderOptions(def: values.Value | undefined): Omit { +function getFolderOptions(def: values.Value | undefined): Options { utils.assertObject(def); const children = def.value.get('children'); @@ -450,7 +467,9 @@ function getFolderOptions(def: values.Value | undefined): Omit { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], title: title?.value ?? '', opened: opened?.value ?? true, @@ -475,7 +494,7 @@ function getPostFormProps(form: values.VObj): PostFormPropsForAsUi { }; } -function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const text = def.value.get('text'); @@ -497,7 +516,7 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu }; } -function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { +function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { utils.assertObject(def); const form = def.value.get('form'); @@ -511,18 +530,26 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn } export function registerAsUiLib(components: Ref[], done: (root: Ref) => void) { + type OptionsConverter = (def: values.Value | undefined, call: C) => Options; + const instances = {}; - function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise) => any, call: (fn: values.VFn, args: values.Value[]) => Promise) { + function createComponentInstance( + type: T['type'], + def: values.Value | undefined, + id: values.Value | undefined, + getOptions: OptionsConverter, + call: C, + ) { if (id) utils.assertString(id); const _id = id?.value ?? uuid(); const component = ref({ ...getOptions(def, call), type, id: _id, - }); + } as T); components.push(component); - const instance = values.OBJ(new Map([ + const instance = values.OBJ(new Map([ ['id', values.STR(_id)], ['update', values.FN_NATIVE(([def], opts) => { utils.assertObject(def); @@ -547,7 +574,7 @@ export function registerAsUiLib(components: Ref[], done: (root: R 'Ui:patch': values.FN_NATIVE(([id, val], opts) => { utils.assertString(id); utils.assertArray(val); - patch(id.value, val.value, opts.call); + // patch(id.value, val.value, opts.call); // TODO }), 'Ui:get': values.FN_NATIVE(([id], opts) => { @@ -566,7 +593,9 @@ export function registerAsUiLib(components: Ref[], done: (root: R rootComponent.value.children = children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }); }), diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index e7a92e2d5cda..dc07ad477b4a 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -9,12 +9,24 @@ import { apiUrl } from '@@/js/config.js'; import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); +export type Endpoint = keyof Misskey.Endpoints; + +export type Request = Misskey.Endpoints[E]['req']; + +export type AnyRequest = + (E extends Endpoint ? Request : never) | object; + +export type Response> = + E extends Endpoint + ? P extends Request ? Misskey.api.SwitchCaseResponseType : never + : object; + // Implements Misskey.api.ApiClient.request export function misskeyApi< ResT = void, - E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, - P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], - _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT, + E extends Endpoint | NonNullable = Endpoint, + P extends AnyRequest = E extends Endpoint ? Request : never, + _ResT = ResT extends void ? Response : ResT, >( endpoint: E, data: P & { i?: string | null; } = {} as any, diff --git a/packages/frontend/test/aiscript/api.test.ts b/packages/frontend/test/aiscript/api.test.ts new file mode 100644 index 000000000000..2a15a74249a4 --- /dev/null +++ b/packages/frontend/test/aiscript/api.test.ts @@ -0,0 +1,401 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { miLocalStorage } from '@/local-storage.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { errors, Interpreter, Parser, values } from '@syuilo/aiscript'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +async function exe(script: string): Promise { + const outputs: values.Value[] = []; + const interpreter = new Interpreter( + createAiScriptEnv({ storageKey: 'widget' }), + { + in: aiScriptReadline, + out: (value) => { + outputs.push(value); + } + } + ); + const ast = Parser.parse(script); + await interpreter.exec(ast); + return outputs; +} + +let $iMock = vi.hoisted | null >( + () => null +); + +vi.mock('@/account.js', () => { + return { + get $i() { + return $iMock; + }, + }; +}); + +const osMock = vi.hoisted(() => { + return { + inputText: vi.fn(), + alert: vi.fn(), + confirm: vi.fn(), + }; +}); + +vi.mock('@/os.js', () => { + return osMock; +}); + +const misskeyApiMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/scripts/misskey-api.js', () => { + return { misskeyApi: misskeyApiMock }; +}); + +describe('AiScript common API', () => { + afterAll(() => { + vi.unstubAllGlobals(); + }); + + describe('readline', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test.sequential('ok', async () => { + osMock.inputText.mockImplementationOnce(async ({ title }) => { + expect(title).toBe('question'); + return { + canceled: false, + result: 'Hello', + }; + }); + const [res] = await exe(` + <: readline('question') + `); + expect(res).toStrictEqual(values.STR('Hello')); + expect(osMock.inputText).toHaveBeenCalledOnce(); + }); + + test.sequential('cancelled', async () => { + osMock.inputText.mockImplementationOnce(async ({ title }) => { + expect(title).toBe('question'); + return { + canceled: true, + result: undefined, + }; + }); + const [res] = await exe(` + <: readline('question') + `); + expect(res).toStrictEqual(values.STR('')); + expect(osMock.inputText).toHaveBeenCalledOnce(); + }); + }); + + describe('user constants', () => { + describe.sequential('logged in', () => { + beforeAll(() => { + $iMock = { + id: 'xxxxxxxx', + name: '藍', + username: 'ai', + }; + }); + + test.concurrent('USER_ID', async () => { + const [res] = await exe(` + <: USER_ID + `); + expect(res).toStrictEqual(values.STR('xxxxxxxx')); + }); + + test.concurrent('USER_NAME', async () => { + const [res] = await exe(` + <: USER_NAME + `); + expect(res).toStrictEqual(values.STR('藍')); + }); + + test.concurrent('USER_USERNAME', async () => { + const [res] = await exe(` + <: USER_USERNAME + `); + expect(res).toStrictEqual(values.STR('ai')); + }); + }); + + describe.sequential('not logged in', () => { + beforeAll(() => { + $iMock = null; + }); + + test.concurrent('USER_ID', async () => { + const [res] = await exe(` + <: USER_ID + `); + expect(res).toStrictEqual(values.NULL); + }); + + test.concurrent('USER_NAME', async () => { + const [res] = await exe(` + <: USER_NAME + `); + expect(res).toStrictEqual(values.NULL); + }); + + test.concurrent('USER_USERNAME', async () => { + const [res] = await exe(` + <: USER_USERNAME + `); + expect(res).toStrictEqual(values.NULL); + }); + }); + }); + + describe('dialog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test.sequential('ok', async () => { + osMock.alert.mockImplementationOnce(async ({ type, title, text }) => { + expect(type).toBe('success'); + expect(title).toBe('Hello'); + expect(text).toBe('world'); + }); + const [res] = await exe(` + <: Mk:dialog('Hello', 'world', 'success') + `); + expect(res).toStrictEqual(values.NULL); + expect(osMock.alert).toHaveBeenCalledOnce(); + }); + + test.sequential('omit type', async () => { + osMock.alert.mockImplementationOnce(async ({ type, title, text }) => { + expect(type).toBe('info'); + expect(title).toBe('Hello'); + expect(text).toBe('world'); + }); + const [res] = await exe(` + <: Mk:dialog('Hello', 'world') + `); + expect(res).toStrictEqual(values.NULL); + expect(osMock.alert).toHaveBeenCalledOnce(); + }); + + test.sequential('invalid type', async () => { + await expect(() => exe(` + <: Mk:dialog('Hello', 'world', 'invalid') + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + expect(osMock.alert).not.toHaveBeenCalled(); + }); + }); + + describe('confirm', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test.sequential('ok', async () => { + osMock.confirm.mockImplementationOnce(async ({ type, title, text }) => { + expect(type).toBe('success'); + expect(title).toBe('Hello'); + expect(text).toBe('world'); + return { canceled: false }; + }); + const [res] = await exe(` + <: Mk:confirm('Hello', 'world', 'success') + `); + expect(res).toStrictEqual(values.TRUE); + expect(osMock.confirm).toHaveBeenCalledOnce(); + }); + + test.sequential('omit type', async () => { + osMock.confirm + .mockImplementationOnce(async ({ type, title, text }) => { + expect(type).toBe('question'); + expect(title).toBe('Hello'); + expect(text).toBe('world'); + return { canceled: false }; + }); + const [res] = await exe(` + <: Mk:confirm('Hello', 'world') + `); + expect(res).toStrictEqual(values.TRUE); + expect(osMock.confirm).toHaveBeenCalledOnce(); + }); + + test.sequential('canceled', async () => { + osMock.confirm.mockImplementationOnce(async ({ type, title, text }) => { + expect(type).toBe('question'); + expect(title).toBe('Hello'); + expect(text).toBe('world'); + return { canceled: true }; + }); + const [res] = await exe(` + <: Mk:confirm('Hello', 'world') + `); + expect(res).toStrictEqual(values.FALSE); + expect(osMock.confirm).toHaveBeenCalledOnce(); + }); + + test.sequential('invalid type', async () => { + const confirm = osMock.confirm; + await expect(() => exe(` + <: Mk:confirm('Hello', 'world', 'invalid') + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + expect(confirm).not.toHaveBeenCalled(); + }); + }); + + describe('api', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test.sequential('successful', async () => { + misskeyApiMock.mockImplementationOnce( + async (endpoint, data, token) => { + expect(endpoint).toBe('ping'); + expect(data).toStrictEqual({}); + expect(token).toBeNull(); + return { pong: 1735657200000 }; + } + ); + const [res] = await exe(` + <: Mk:api('ping', {}) + `); + expect(res).toStrictEqual(values.OBJ(new Map([ + ['pong', values.NUM(1735657200000)], + ]))); + expect(misskeyApiMock).toHaveBeenCalledOnce(); + }); + + test.sequential('with token', async () => { + misskeyApiMock.mockImplementationOnce( + async (endpoint, data, token) => { + expect(endpoint).toBe('ping'); + expect(data).toStrictEqual({}); + expect(token).toStrictEqual('xxxxxxxx'); + return { pong: 1735657200000 }; + } + ); + const [res] = await exe(` + <: Mk:api('ping', {}, 'xxxxxxxx') + `); + expect(res).toStrictEqual(values.OBJ(new Map([ + ['pong', values.NUM(1735657200000 )], + ]))); + expect(misskeyApiMock).toHaveBeenCalledOnce(); + }); + + test.sequential('request failed', async () => { + misskeyApiMock.mockRejectedValueOnce('Not Found'); + const [res] = await exe(` + <: Mk:api('this/endpoint/should/not/be/found', {}) + `); + expect(res).toStrictEqual( + values.ERROR('request_failed', values.STR('Not Found')) + ); + expect(misskeyApiMock).toHaveBeenCalledOnce(); + }); + + test.sequential('invalid endpoint', async () => { + await expect(() => exe(` + Mk:api('https://example.com/api/ping', {}) + `)).rejects.toStrictEqual( + new errors.AiScriptRuntimeError('invalid endpoint'), + ); + expect(misskeyApiMock).not.toHaveBeenCalled(); + }); + + test.sequential('missing param', async () => { + await expect(() => exe(` + Mk:api('ping') + `)).rejects.toStrictEqual( + new errors.AiScriptRuntimeError('expected param'), + ); + expect(misskeyApiMock).not.toHaveBeenCalled(); + }); + }); + + describe('save and load', () => { + beforeEach(() => { + miLocalStorage.removeItem('aiscript:widget:key'); + }); + + afterEach(() => { + miLocalStorage.removeItem('aiscript:widget:key'); + }); + + test.sequential('successful', async () => { + const [res] = await exe(` + Mk:save('key', 'value') + <: Mk:load('key') + `); + expect(miLocalStorage.getItem('aiscript:widget:key')).toBe('"value"'); + expect(res).toStrictEqual(values.STR('value')); + }); + + test.sequential('missing value to save', async () => { + await expect(() => exe(` + Mk:save('key') + `)).rejects.toStrictEqual( + new errors.AiScriptRuntimeError('Expect anything, but got nothing.'), + ); + }); + + test.sequential('not value found to load', async () => { + const [res] = await exe(` + <: Mk:load('key') + `); + expect(res).toStrictEqual(values.NULL); + }); + + test.sequential('remove existing', async () => { + const res = await exe(` + Mk:save('key', 'value') + <: Mk:load('key') + <: Mk:remove('key') + <: Mk:load('key') + `); + expect(res).toStrictEqual([values.STR('value'), values.NULL, values.NULL]); + }); + + test.sequential('remove nothing', async () => { + const res = await exe(` + <: Mk:load('key') + <: Mk:remove('key') + <: Mk:load('key') + `); + expect(res).toStrictEqual([values.NULL, values.NULL, values.NULL]); + }); + }); + + test.concurrent('url', async () => { + vi.stubGlobal('location', { href: 'https://example.com/' }); + const [res] = await exe(` + <: Mk:url() + `); + expect(res).toStrictEqual(values.STR('https://example.com/')); + }); + + test.concurrent('nyaize', async () => { + const [res] = await exe(` + <: Mk:nyaize('な') + `); + expect(res).toStrictEqual(values.STR('にゃ')); + }); +}); diff --git a/packages/frontend/test/aiscript/common.test.ts b/packages/frontend/test/aiscript/common.test.ts new file mode 100644 index 000000000000..acc48826eaf7 --- /dev/null +++ b/packages/frontend/test/aiscript/common.test.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assertStringAndIsIn } from "@/scripts/aiscript/common.js"; +import { values } from "@syuilo/aiscript"; +import { describe, expect, test } from "vitest"; + +describe('AiScript common script', () => { + test('assertStringAndIsIn', () => { + expect( + () => assertStringAndIsIn(values.STR('a'), ['a', 'b']) + ).not.toThrow(); + expect( + () => assertStringAndIsIn(values.STR('c'), ['a', 'b']) + ).toThrow('"c" is not in "a", "b"'); + expect(() => assertStringAndIsIn( + values.STR('invalid'), + ['left', 'center', 'right'] + )).toThrow('"invalid" is not in "left", "center", "right"'); + }); +}); diff --git a/packages/frontend/test/aiscript/ui.test.ts b/packages/frontend/test/aiscript/ui.test.ts new file mode 100644 index 000000000000..5f77edbb4973 --- /dev/null +++ b/packages/frontend/test/aiscript/ui.test.ts @@ -0,0 +1,825 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAsUiLib } from '@/scripts/aiscript/ui.js'; +import { errors, Interpreter, Parser, values } from '@syuilo/aiscript'; +import { describe, expect, test } from 'vitest'; +import { type Ref, ref } from 'vue'; +import type { + AsUiButton, + AsUiButtons, + AsUiComponent, + AsUiMfm, + AsUiNumberInput, + AsUiRoot, + AsUiSelect, + AsUiSwitch, + AsUiText, + AsUiTextarea, + AsUiTextInput, +} from '@/scripts/aiscript/ui.js'; + +type ExeResult = { + root: AsUiRoot; + get: (id: string) => AsUiComponent; + outputs: values.Value[]; +} +async function exe(script: string): Promise { + const rootRef = ref(); + const componentRefs = ref[]>([]); + const outputs: values.Value[] = []; + + const interpreter = new Interpreter( + registerAsUiLib(componentRefs.value, (root) => { + rootRef.value = root.value; + }), + { + out: (value) => { + outputs.push(value); + } + } + ); + const ast = Parser.parse(script); + await interpreter.exec(ast); + + const root = rootRef.value; + if (root === undefined) { + expect.unreachable('root must not be undefined'); + } + const components = componentRefs.value.map( + (componentRef) => componentRef.value, + ); + expect(root).toBe(components[0]); + expect(root.type).toBe('root'); + const get = (id: string) => { + const component = componentRefs.value.find( + (componentRef) => componentRef.value.id === id, + ); + if (component === undefined) { + expect.unreachable(`component "${id}" is not defined`); + } + return component.value; + }; + return { root, get, outputs }; +} + +describe('AiScript UI API', () => { + test.concurrent('root', async () => { + const { root } = await exe(''); + expect(root.children).toStrictEqual([]); + }); + + describe('get', () => { + test.concurrent('some', async () => { + const { outputs } = await exe(` + Ui:C:text({}, 'id') + <: Ui:get('id') + `); + const output = outputs[0] as values.VObj; + expect(output.type).toBe('obj'); + expect(output.value.size).toBe(2); + expect(output.value.get('id')).toStrictEqual(values.STR('id')); + expect(output.value.get('update')!.type).toBe('fn'); + }); + + test.concurrent('none', async () => { + const { outputs } = await exe(` + <: Ui:get('id') + `); + expect(outputs).toStrictEqual([values.NULL]); + }); + }); + + describe('update', () => { + test.concurrent('normal', async () => { + const { get } = await exe(` + let text = Ui:C:text({ text: 'a' }, 'id') + text.update({ text: 'b' }) + `); + const text = get('id') as AsUiText; + expect(text.text).toBe('b'); + }); + + test.concurrent('skip unknown key', async () => { + const { get } = await exe(` + let text = Ui:C:text({ text: 'a' }, 'id') + text.update({ + text: 'b' + unknown: null + }) + `); + const text = get('id') as AsUiText; + expect(text.text).toBe('b'); + expect('unknown' in text).toBeFalsy(); + }); + }); + + describe('container', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let text = Ui:C:text({ + text: 'text' + }, 'id1') + let container = Ui:C:container({ + children: [text] + align: 'left' + bgColor: '#fff' + fgColor: '#000' + font: 'sans-serif' + borderWidth: 1 + borderColor: '#f00' + borderStyle: 'hidden' + borderRadius: 2 + padding: 3 + rounded: true + hidden: false + }, 'id2') + Ui:render([container]) + `); + expect(root.children).toStrictEqual(['id2']); + expect(get('id2')).toStrictEqual({ + type: 'container', + id: 'id2', + children: ['id1'], + align: 'left', + bgColor: '#fff', + fgColor: '#000', + font: 'sans-serif', + borderColor: '#f00', + borderWidth: 1, + borderStyle: 'hidden', + borderRadius: 2, + padding: 3, + rounded: true, + hidden: false, + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:container({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'container', + id: 'id', + children: [], + align: undefined, + fgColor: undefined, + bgColor: undefined, + font: undefined, + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + borderRadius: undefined, + padding: undefined, + rounded: undefined, + hidden: undefined, + }); + }); + + test.concurrent('invalid children', async () => { + await expect(() => exe(` + Ui:C:container({ + children: 0 + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + + test.concurrent('invalid align', async () => { + await expect(() => exe(` + Ui:C:container({ + align: 'invalid' + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + + test.concurrent('invalid font', async () => { + await expect(() => exe(` + Ui:C:container({ + font: 'invalid' + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + + test.concurrent('invalid borderStyle', async () => { + await expect(() => exe(` + Ui:C:container({ + borderStyle: 'invalid' + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + }); + + describe('text', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let text = Ui:C:text({ + text: 'a' + size: 1 + bold: true + color: '#000' + font: 'sans-serif' + }, 'id') + Ui:render([text]) + `); + expect(root.children).toStrictEqual(['id']); + expect(get('id')).toStrictEqual({ + type: 'text', + id: 'id', + text: 'a', + size: 1, + bold: true, + color: '#000', + font: 'sans-serif', + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:text({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'text', + id: 'id', + text: undefined, + size: undefined, + bold: undefined, + color: undefined, + font: undefined, + }); + }); + + test.concurrent('invalid font', async () => { + await expect(() => exe(` + Ui:C:text({ + font: 'invalid' + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + }); + + describe('mfm', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let mfm = Ui:C:mfm({ + text: 'text' + size: 1 + bold: true + color: '#000' + font: 'sans-serif' + onClickEv: print + }, 'id') + Ui:render([mfm]) + `); + expect(root.children).toStrictEqual(['id']); + const { onClickEv, ...mfm } = get('id') as AsUiMfm; + expect(mfm).toStrictEqual({ + type: 'mfm', + id: 'id', + text: 'text', + size: 1, + bold: true, + color: '#000', + font: 'sans-serif', + }); + await onClickEv!('a'); + expect(outputs).toStrictEqual([values.STR('a')]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:mfm({}, 'id') + `); + const { onClickEv, ...mfm } = get('id') as AsUiMfm; + expect(onClickEv).toBeTypeOf('function'); + expect(mfm).toStrictEqual({ + type: 'mfm', + id: 'id', + text: undefined, + size: undefined, + bold: undefined, + color: undefined, + font: undefined, + }); + }); + + test.concurrent('invalid font', async () => { + await expect(() => exe(` + Ui:C:mfm({ + font: 'invalid' + }) + `)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError); + }); + }); + + describe('textInput', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let text_input = Ui:C:textInput({ + onInput: print + default: 'a' + label: 'b' + caption: 'c' + }, 'id') + Ui:render([text_input]) + `); + expect(root.children).toStrictEqual(['id']); + const { onInput, ...textInput } = get('id') as AsUiTextInput; + expect(textInput).toStrictEqual({ + type: 'textInput', + id: 'id', + default: 'a', + label: 'b', + caption: 'c', + }); + await onInput!('d'); + expect(outputs).toStrictEqual([values.STR('d')]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:textInput({}, 'id') + `); + const { onInput, ...textInput } = get('id') as AsUiTextInput; + expect(onInput).toBeTypeOf('function'); + expect(textInput).toStrictEqual({ + type: 'textInput', + id: 'id', + default: undefined, + label: undefined, + caption: undefined, + }); + }); + }); + + describe('textarea', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let textarea = Ui:C:textarea({ + onInput: print + default: 'a' + label: 'b' + caption: 'c' + }, 'id') + Ui:render([textarea]) + `); + expect(root.children).toStrictEqual(['id']); + const { onInput, ...textarea } = get('id') as AsUiTextarea; + expect(textarea).toStrictEqual({ + type: 'textarea', + id: 'id', + default: 'a', + label: 'b', + caption: 'c', + }); + await onInput!('d'); + expect(outputs).toStrictEqual([values.STR('d')]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:textarea({}, 'id') + `); + const { onInput, ...textarea } = get('id') as AsUiTextarea; + expect(onInput).toBeTypeOf('function'); + expect(textarea).toStrictEqual({ + type: 'textarea', + id: 'id', + default: undefined, + label: undefined, + caption: undefined, + }); + }); + }); + + describe('numberInput', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let number_input = Ui:C:numberInput({ + onInput: print + default: 1 + label: 'a' + caption: 'b' + }, 'id') + Ui:render([number_input]) + `); + expect(root.children).toStrictEqual(['id']); + const { onInput, ...numberInput } = get('id') as AsUiNumberInput; + expect(numberInput).toStrictEqual({ + type: 'numberInput', + id: 'id', + default: 1, + label: 'a', + caption: 'b', + }); + await onInput!(2); + expect(outputs).toStrictEqual([values.NUM(2)]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:numberInput({}, 'id') + `); + const { onInput, ...numberInput } = get('id') as AsUiNumberInput; + expect(onInput).toBeTypeOf('function'); + expect(numberInput).toStrictEqual({ + type: 'numberInput', + id: 'id', + default: undefined, + label: undefined, + caption: undefined, + }); + }); + }); + + describe('button', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let button = Ui:C:button({ + text: 'a' + onClick: @() { <: 'clicked' } + primary: true + rounded: false + disabled: false + }, 'id') + Ui:render([button]) + `); + expect(root.children).toStrictEqual(['id']); + const { onClick, ...button } = get('id') as AsUiButton; + expect(button).toStrictEqual({ + type: 'button', + id: 'id', + text: 'a', + primary: true, + rounded: false, + disabled: false, + }); + await onClick!(); + expect(outputs).toStrictEqual([values.STR('clicked')]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:button({}, 'id') + `); + const { onClick, ...button } = get('id') as AsUiButton; + expect(onClick).toBeTypeOf('function'); + expect(button).toStrictEqual({ + type: 'button', + id: 'id', + text: undefined, + primary: undefined, + rounded: undefined, + disabled: undefined, + }); + }); + }); + + describe('buttons', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let buttons = Ui:C:buttons({ + buttons: [] + }, 'id') + Ui:render([buttons]) + `); + expect(root.children).toStrictEqual(['id']); + expect(get('id')).toStrictEqual({ + type: 'buttons', + id: 'id', + buttons: [], + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:buttons({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'buttons', + id: 'id', + buttons: [], + }); + }); + + test.concurrent('some buttons', async () => { + const { root, get, outputs } = await exe(` + let buttons = Ui:C:buttons({ + buttons: [ + { + text: 'a' + onClick: @() { <: 'clicked a' } + primary: true + rounded: false + disabled: false + } + { + text: 'b' + onClick: @() { <: 'clicked b' } + primary: true + rounded: false + disabled: false + } + ] + }, 'id') + Ui:render([buttons]) + `); + expect(root.children).toStrictEqual(['id']); + const { buttons, ...buttonsOptions } = get('id') as AsUiButtons; + expect(buttonsOptions).toStrictEqual({ + type: 'buttons', + id: 'id', + }); + expect(buttons!.length).toBe(2); + const { onClick: onClickA, ...buttonA } = buttons![0]; + expect(buttonA).toStrictEqual({ + text: 'a', + primary: true, + rounded: false, + disabled: false, + }); + const { onClick: onClickB, ...buttonB } = buttons![1]; + expect(buttonB).toStrictEqual({ + text: 'b', + primary: true, + rounded: false, + disabled: false, + }); + await onClickA!(); + await onClickB!(); + expect(outputs).toStrictEqual( + [values.STR('clicked a'), values.STR('clicked b')] + ); + }); + }); + + describe('switch', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let switch = Ui:C:switch({ + onChange: print + default: false + label: 'a' + caption: 'b' + }, 'id') + Ui:render([switch]) + `); + expect(root.children).toStrictEqual(['id']); + const { onChange, ...switchOptions } = get('id') as AsUiSwitch; + expect(switchOptions).toStrictEqual({ + type: 'switch', + id: 'id', + default: false, + label: 'a', + caption: 'b', + }); + await onChange!(true); + expect(outputs).toStrictEqual([values.TRUE]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:switch({}, 'id') + `); + const { onChange, ...switchOptions } = get('id') as AsUiSwitch; + expect(onChange).toBeTypeOf('function'); + expect(switchOptions).toStrictEqual({ + type: 'switch', + id: 'id', + default: undefined, + label: undefined, + caption: undefined, + }); + }); + }); + + describe('select', () => { + test.concurrent('all options', async () => { + const { root, get, outputs } = await exe(` + let select = Ui:C:select({ + items: [ + { text: 'A', value: 'a' } + { text: 'B', value: 'b' } + ] + onChange: print + default: 'a' + label: 'c' + caption: 'd' + }, 'id') + Ui:render([select]) + `); + expect(root.children).toStrictEqual(['id']); + const { onChange, ...select } = get('id') as AsUiSelect; + expect(select).toStrictEqual({ + type: 'select', + id: 'id', + items: [ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ], + default: 'a', + label: 'c', + caption: 'd', + }); + await onChange!('b'); + expect(outputs).toStrictEqual([values.STR('b')]); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:select({}, 'id') + `); + const { onChange, ...select } = get('id') as AsUiSelect; + expect(onChange).toBeTypeOf('function'); + expect(select).toStrictEqual({ + type: 'select', + id: 'id', + items: [], + default: undefined, + label: undefined, + caption: undefined, + }); + }); + + test.concurrent('omit item values', async () => { + const { get } = await exe(` + let select = Ui:C:select({ + items: [ + { text: 'A' } + { text: 'B' } + ] + }, 'id') + `); + const { onChange, ...select } = get('id') as AsUiSelect; + expect(onChange).toBeTypeOf('function'); + expect(select).toStrictEqual({ + type: 'select', + id: 'id', + items: [ + { text: 'A', value: 'A' }, + { text: 'B', value: 'B' }, + ], + default: undefined, + label: undefined, + caption: undefined, + }); + }); + }); + + describe('folder', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let folder = Ui:C:folder({ + children: [] + title: 'a' + opened: true + }, 'id') + Ui:render([folder]) + `); + expect(root.children).toStrictEqual(['id']); + expect(get('id')).toStrictEqual({ + type: 'folder', + id: 'id', + children: [], + title: 'a', + opened: true, + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:folder({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'folder', + id: 'id', + children: [], + title: '', + opened: true, + }); + }); + + test.concurrent('some children', async () => { + const { get } = await exe(` + let text = Ui:C:text({ + text: 'text' + }, 'id1') + Ui:C:folder({ + children: [text] + }, 'id2') + `); + expect(get('id2')).toStrictEqual({ + type: 'folder', + id: 'id2', + children: ['id1'], + title: '', + opened: true, + }); + }); + }); + + describe('postFormButton', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let post_form_button = Ui:C:postFormButton({ + text: 'a' + primary: true + rounded: false + form: { + text: 'b' + cw: 'c' + visibility: 'public' + localOnly: true + } + }, 'id') + Ui:render([post_form_button]) + `); + expect(root.children).toStrictEqual(['id']); + expect(get('id')).toStrictEqual({ + type: 'postFormButton', + id: 'id', + text: 'a', + primary: true, + rounded: false, + form: { + text: 'b', + cw: 'c', + visibility: 'public', + localOnly: true, + }, + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:postFormButton({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'postFormButton', + id: 'id', + text: undefined, + primary: undefined, + rounded: undefined, + form: { text: '' }, + }); + }); + }); + + describe('postForm', () => { + test.concurrent('all options', async () => { + const { root, get } = await exe(` + let post_form = Ui:C:postForm({ + form: { + text: 'a' + cw: 'b' + visibility: 'public' + localOnly: true + } + }, 'id') + Ui:render([post_form]) + `); + expect(root.children).toStrictEqual(['id']); + expect(get('id')).toStrictEqual({ + type: 'postForm', + id: 'id', + form: { + text: 'a', + cw: 'b', + visibility: 'public', + localOnly: true, + }, + }); + }); + + test.concurrent('minimum options', async () => { + const { get } = await exe(` + Ui:C:postForm({}, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'postForm', + id: 'id', + form: { text: '' }, + }); + }); + + test.concurrent('minimum options for form', async () => { + const { get } = await exe(` + Ui:C:postForm({ + form: { text: '' } + }, 'id') + `); + expect(get('id')).toStrictEqual({ + type: 'postForm', + id: 'id', + form: { + text: '', + cw: undefined, + visibility: undefined, + localOnly: undefined, + }, + }); + }); + }); +});