misskey/packages/frontend/test/aiscript/ui.test.ts

826 lines
18 KiB
TypeScript

/*
* 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,
},
});
});
});
});