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