summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authorTake-John <takejohn@takejohn.jp>2025-01-07 21:28:48 +0900
committerGitHub <noreply@github.com>2025-01-07 12:28:48 +0000
commitbbe80af1dde195ff0ac6713db967b556acebb30c (patch)
tree075a07368d678125cc0fd0327271823102e3e332 /packages/frontend
parentfix(frontend): frontend / frontend-embedにあるtsconfig.jsonのmoduleをES2... (diff)
downloadsharkey-bbe80af1dde195ff0ac6713db967b556acebb30c.tar.gz
sharkey-bbe80af1dde195ff0ac6713db967b556acebb30c.tar.bz2
sharkey-bbe80af1dde195ff0ac6713db967b556acebb30c.zip
Fix: aiscriptディレクトリ内の型エラー解消と単体テスト (#15191)
* AiScript APIの型エラーに対処 * AiScript UI APIのテスト作成 * onInputなどがPromiseを返すように * AiScript共通APIのテスト作成 * CHANGELOG記載 * 定数のテストをconcurrentに * vi.mockを使用 * misskeyApiをmisskeyApiUntypedのエイリアスとする * 期待されるエラーメッセージを修正 * Mk:removeのテスト * misskeyApiの型を変更
Diffstat (limited to 'packages/frontend')
-rw-r--r--packages/frontend/src/scripts/aiscript/api.ts38
-rw-r--r--packages/frontend/src/scripts/aiscript/common.ts15
-rw-r--r--packages/frontend/src/scripts/aiscript/ui.ts139
-rw-r--r--packages/frontend/src/scripts/misskey-api.ts18
-rw-r--r--packages/frontend/test/aiscript/api.test.ts401
-rw-r--r--packages/frontend/test/aiscript/common.test.ts23
-rw-r--r--packages/frontend/test/aiscript/ui.test.ts825
7 files changed, 1395 insertions, 64 deletions
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
index 8afe88eec6..e203c51bba 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<string> {
return new Promise(ok => {
@@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise<string> {
});
}
-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 0000000000..de6fa1d633
--- /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<A extends readonly string[]>(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 2b386bebb8..ca92b27ff5 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<void>;
};
export type AsUiButton = AsUiComponentBase & {
type: 'button';
text?: string;
- onClick?: () => void;
+ onClick?: () => Promise<void>;
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<void>;
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<void>;
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<void>;
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<void>;
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<void>;
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 AsUiComponent> = T extends AsUiButtons
+ ? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] }
+ : Omit<T, 'id' | 'type'>;
+
export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
// TODO
}
-function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
+function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> {
utils.assertObject(def);
const children = def.value.get('children');
@@ -153,30 +166,32 @@ function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 't
return {
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;
}),
};
}
-function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
+function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> {
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<AsUiContainer,
return {
children: 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;
}) : [],
align: align?.value,
fgColor: fgColor?.value,
@@ -205,7 +222,7 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer,
};
}
-function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
+function getTextOptions(def: values.Value | undefined): Options<AsUiText> {
utils.assertObject(def);
const text = def.value.get('text');
@@ -217,7 +234,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
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);
return {
text: text?.value,
@@ -228,7 +245,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
};
}
-function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> {
+function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> {
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<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
+function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> {
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<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
+function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> {
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<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
+function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> {
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<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
+function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> {
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<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
+function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> {
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<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
+function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> {
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<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
+function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> {
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<AsUiFolder, 'id' | 'type'> {
+function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> {
utils.assertObject(def);
const children = def.value.get('children');
@@ -450,7 +467,9 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id'
return {
children: 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;
}) : [],
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<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
+function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> {
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<values.Value>): Omit<AsUiPostForm, 'id' | 'type'> {
+function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> {
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<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
+ type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
+
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<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
+ function createComponentInstance<T extends AsUiComponent, C>(
+ type: T['type'],
+ def: values.Value | undefined,
+ id: values.Value | undefined,
+ getOptions: OptionsConverter<T, C>,
+ 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<string, values.Value>([
['id', values.STR(_id)],
['update', values.FN_NATIVE(([def], opts) => {
utils.assertObject(def);
@@ -547,7 +574,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], 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<AsUiComponent>[], 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 e7a92e2d5c..dc07ad477b 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<E extends Endpoint> = Misskey.Endpoints[E]['req'];
+
+export type AnyRequest<E extends Endpoint | (string & unknown)> =
+ (E extends Endpoint ? Request<E> : never) | object;
+
+export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> =
+ E extends Endpoint
+ ? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : 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<E, P> : ResT,
+ E extends Endpoint | NonNullable<string> = Endpoint,
+ P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never,
+ _ResT = ResT extends void ? Response<E, P> : 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 0000000000..2a15a74249
--- /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<values.Value[]> {
+ 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<Partial<typeof import('@/account.js').$i> | 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 0000000000..acc48826ea
--- /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 0000000000..5f77edbb49
--- /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<ExeResult> {
+ const rootRef = ref<AsUiRoot>();
+ const componentRefs = ref<Ref<AsUiComponent>[]>([]);
+ 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,
+ },
+ });
+ });
+ });
+});