test: tests & bug fixes

This commit is contained in:
2023-11-24 00:49:14 +02:00
committed by Chen Asraf
parent ecd7ced3e8
commit 255d2f5fc6
6 changed files with 560 additions and 165 deletions

View File

@@ -23,7 +23,7 @@ export default {
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
@@ -31,10 +31,10 @@ export default {
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
coverageReporters: ["json-summary", "json", "text", "lcov", "clover"],
coverageReporters: ['json-summary', 'json', 'text', 'lcov', 'clover'],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
@@ -88,7 +88,7 @@ export default {
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
preset: 'ts-jest',
// Run tests from one or more projects
// projects: undefined,
@@ -147,9 +147,7 @@ export default {
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
testPathIgnorePatterns: ['/node_modules/', '/_old/'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],

View File

@@ -1,3 +1,6 @@
import z from 'zod'
import { zodEnumFromObjKeys } from './utils'
export const ansiStyles = {
reset: '\x1b[0m',
bold: '\x1b[1m',
@@ -24,12 +27,14 @@ export const ansiColors = {
brightWhite: '\x1b[97m',
}
export type StringStyle = {
color?: keyof typeof ansiColors
bold?: boolean
underline?: boolean
reset?: boolean
}
export const StringStyle = z.object({
bold: z.boolean().optional(),
underline: z.boolean().optional(),
color: zodEnumFromObjKeys(ansiColors).optional(),
reset: z.boolean().optional(),
})
export type StringStyle = z.infer<typeof StringStyle>
export function format(string: string, style: StringStyle = {}): string {
const { color, bold, underline, reset } = style

View File

@@ -1,13 +1,13 @@
import { z } from 'zod'
import { isZodError, ParseError, ValidationError } from './error'
import { HelpGenerator } from './help'
import { defaultHelpConfig, HelpConfig, HelpGenerator } from './help'
import MassargOption, {
MassargFlag,
OptionConfig,
TypedOptionConfig,
MassargHelpFlag,
} from './option'
import { setOrPush } from './utils'
import { setOrPush, deepMerge } from './utils'
import MassargExample, { ExampleConfig } from './example'
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
@@ -26,18 +26,7 @@ export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
.function()
.args(args, z.any())
.returns(z.union([z.promise(z.void()), z.void()])) as z.ZodType<Runner<z.infer<RunArgs>>>,
/**
* Whether to bind the help command to this command
*
* Set this to `true` to automatically add a `help` command to this command's subcommands.
*/
bindHelpCommand: z.boolean().optional(),
/**
* Whether to bind the help option to this command
*
* Set this to `true` to automatically add a `--help` option to this command's options.
*/
bindHelpOption: z.boolean().optional(),
helpConfig: HelpConfig.optional(),
// argsHint: z.string().optional(),
})
@@ -59,6 +48,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
options: MassargOption[] = []
examples: MassargExample[] = []
args: Partial<Args> = {}
helpConfig: Required<HelpConfig>
constructor(options: CommandConfig<Args>) {
CommandConfig(z.any()).parse(options)
@@ -66,12 +56,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
this.description = options.description
this.aliases = options.aliases ?? []
this._run = options.run
if (options.bindHelpCommand) {
this.command(new MassargHelpCommand())
}
if (options.bindHelpOption) {
this.option(new MassargHelpFlag())
}
this.helpConfig = HelpConfig.required().parse(defaultHelpConfig)
}
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args>
@@ -110,15 +95,13 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
): MassargCommand<Args> {
try {
const flag = config instanceof MassargFlag ? config : new MassargFlag(config)
if (flag.isDefault) {
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
throw new ValidationError({
code: 'duplicate_default_option',
message: `Option "${flag.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`,
path: [this.name, flag.name],
})
}
const existing = this.options.find((c) => c.name === flag.name)
if (existing) {
throw new ValidationError({
code: 'duplicate_flag',
message: `Flag "${flag.name}" already exists`,
path: [this.name, flag.name],
})
}
this.options.push(flag as MassargOption)
return this
@@ -140,6 +123,14 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
try {
const option =
config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config)
const existing = this.options.find((c) => c.name === option.name)
if (existing) {
throw new ValidationError({
code: 'duplicate_option',
message: `Option "${option.name}" already exists`,
path: [this.name, option.name],
})
}
if (option.isDefault) {
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
@@ -169,6 +160,20 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
return this
}
help(config: HelpConfig): MassargCommand<Args> {
this.helpConfig = HelpConfig.required().parse(
deepMerge(defaultHelpConfig, config) as HelpConfig,
)
if (this.helpConfig.bindCommand) {
this.command(new MassargHelpCommand())
}
if (this.helpConfig.bindOption) {
this.option(new MassargHelpFlag())
}
return this
}
main<A extends ArgsObject = Args>(run: Runner<A>): MassargCommand<Args> {
this._run = run
return this
@@ -217,46 +222,50 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
let _args: Args = { ...this.args, ...args } as Args
let _argv = [...argv]
const _a = this.args as Record<string, string[]>
// fill defaults
for (const option of this.options) {
if (option.defaultValue !== undefined && _a[option.name] === undefined) {
_args[option.name as keyof Args] = option.defaultValue as Args[keyof Args]
}
}
// parse options
while (_argv.length) {
const arg = _argv.shift()!
const found = this.options.some((o) => o._isOption(arg))
if (found) {
const option = this.options.find((o) => o._match(arg))
if (!option) {
throw new ValidationError({
path: [MassargOption.getName(arg)],
code: 'unknown_option',
message: 'Unknown option',
})
}
const res = option._parseDetails(argv)
_args[res.key as keyof Args] = setOrPush<Args[keyof Args]>(
res.value,
_args[res.key as keyof Args],
option.isArray,
)
_argv = this.parseOption(arg, _argv)
_args = { ..._args, ...this.args }
continue
}
const command = this.commands.find((c) => c.name === arg || c.aliases.includes(arg))
if (command) {
// this is dry run, just exit
if (!parseCommands) {
break
}
// this is real run, parse command, pass unparsed args
return command.parse(_argv, this.args, parent ?? this)
}
// default option - passes arg value even without flag name
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
_argv = this.parseOption(`--${defaultOption.name}`, [arg, ..._argv])
continue
}
// not parsed by any step, add to extra key
_a.extra ??= []
_a.extra.push(arg)
}
if (!parseCommands) {
return _args
}
this.args = { ...this.args, ..._args }
// dry run, just exit
if (!parseCommands) {
return this.args as Args
}
// no sub command found, run main command
if (this._run) {
this._run(this.args, parent ?? this)
}

View File

@@ -1,36 +1,102 @@
import z from 'zod'
import { format, StringStyle, stripColors } from './color'
import MassargCommand from './command'
import { chainStr, indent } from './utils'
export type GenerateTableCommandConfig = {
maxRowLength?: number
namePrefix?: string
aliasPrefix?: string
compact?: boolean
nameStyle?: StringStyle
descriptionStyle?: StringStyle
}
export const GenerateTableCommandConfig = z.object({
maxRowLength: z.number().optional(),
namePrefix: z.string().optional(),
aliasPrefix: z.string().optional(),
compact: z.boolean().optional(),
nameStyle: StringStyle.optional(),
descriptionStyle: StringStyle.optional(),
})
export type GenerateTableCommandConfig = z.infer<typeof GenerateTableCommandConfig>
export type GenerateTableOptionConfig = GenerateTableCommandConfig & {
typeStyle?: StringStyle
defaultStyle?: StringStyle
}
export const GenerateTableOptionConfig = GenerateTableCommandConfig
export type GenerateTableOptionConfig = z.infer<typeof GenerateTableOptionConfig>
export type GenerateHelpOptions = {
// sub-styles
commandOptions?: GenerateTableCommandConfig
optionOptions?: GenerateTableOptionConfig
export const HelpConfig = z.object({
/**
* Whether to bind the help command to this command
*
* Set this to `true` to automatically add a `help` command to this command's subcommands.
*/
bindCommand: z.boolean().optional(),
/**
* Whether to bind the help option to this command
*
* Set this to `true` to automatically add a `--help` option to this command's options.
*/
bindOption: z.boolean().optional(),
// global styles
titleStyle?: StringStyle
descriptionStyle?: StringStyle
subtitleStyle?: StringStyle
usageStyle?: StringStyle
exampleStyles?: {
description?: StringStyle
input?: StringStyle
output?: StringStyle
}
commandOptions: GenerateTableCommandConfig.optional(),
optionOptions: GenerateTableOptionConfig.optional(),
titleStyle: StringStyle.optional(),
descriptionStyle: StringStyle.optional(),
subtitleStyle: StringStyle.optional(),
usageStyle: StringStyle.optional(),
maxRowLength: z.number().optional(),
exampleStyles: z
.object({
description: StringStyle.optional(),
input: StringStyle.optional(),
output: StringStyle.optional(),
})
.optional(),
})
export type HelpConfig = z.infer<typeof HelpConfig>
export const defaultHelpConfig: HelpConfig = {
maxRowLength: 80,
commandOptions: {
namePrefix: '',
aliasPrefix: '',
nameStyle: {
color: 'yellow',
},
descriptionStyle: {
color: 'gray',
},
},
optionOptions: {
namePrefix: '--',
aliasPrefix: '-',
nameStyle: {
color: 'yellow',
},
descriptionStyle: {
color: 'gray',
},
},
descriptionStyle: {},
exampleStyles: {
description: {
bold: true,
},
input: {
color: 'yellow',
},
output: {
color: 'brightWhite',
},
},
bindCommand: false,
bindOption: false,
titleStyle: {
bold: true,
color: 'yellow',
},
usageStyle: {
bold: true,
color: 'yellow',
},
subtitleStyle: {
bold: true,
color: 'brightWhite',
underline: true,
},
}
export type HelpItem = {
@@ -41,105 +107,56 @@ export type HelpItem = {
export class HelpGenerator {
entry: MassargCommand<any>
config: GenerateHelpOptions
config: HelpConfig
constructor(entry: MassargCommand<any>, config?: GenerateHelpOptions) {
constructor(entry: MassargCommand<any>, config?: HelpConfig) {
this.entry = entry
this.config = {
this.config = HelpConfig.parse({
commandOptions: {
...config?.commandOptions,
},
optionOptions: {
...config?.optionOptions,
},
}
})
}
generate(): string {
const entry = this.entry
const options = generateHelpTable(entry.options, {
namePrefix: '--',
aliasPrefix: '-',
...this.config.optionOptions,
})
const commands = generateHelpTable(entry.commands, {
namePrefix: '',
aliasPrefix: '',
...this.config.commandOptions,
})
const options = generateHelpTable(entry.options, this.config.optionOptions)
const commands = generateHelpTable(entry.commands, this.config.commandOptions)
const examples = entry.examples
.map((example) => {
const { description, input, output } = example
return chainStr(
description && [
format(description, {
reset: true,
bold: true,
...this.config.exampleStyles?.description,
}),
'',
],
input &&
format(input, { reset: true, color: 'yellow', ...this.config.exampleStyles?.input }),
output &&
format(output, {
reset: true,
color: 'brightWhite',
...this.config.exampleStyles?.output,
}),
description && [format(description, this.config.exampleStyles?.description), ''],
input && format(input, this.config.exampleStyles?.input),
output && format(output, this.config.exampleStyles?.output),
)
})
.join('\n')
return (
chainStr(
format(`Usage: ${entry.name} [...options]`, {
bold: true,
color: 'yellow',
reset: true,
...this.config.titleStyle,
}),
format(`Usage: ${entry.name} [...options]`, this.config.usageStyle),
'',
format(entry.description, { reset: true, ...this.config.descriptionStyle }),
format(entry.description, this.config.descriptionStyle),
commands.length &&
indent([
'',
format(`Commands for ${entry.name}:`, {
bold: true,
reset: true,
color: 'brightWhite',
underline: true,
...this.config.subtitleStyle,
}),
'',
indent(commands),
]),
indent([
'',
format(`Commands for ${entry.name}:`, this.config.subtitleStyle),
'',
indent(commands),
]),
options.length &&
indent([
'',
format(`Options for ${entry.name}:`, {
bold: true,
reset: true,
color: 'brightWhite',
underline: true,
...this.config.subtitleStyle,
}),
'',
indent(options),
]),
indent([
'',
format(`Options for ${entry.name}:`, this.config.subtitleStyle),
'',
indent(options),
]),
examples.length &&
indent([
'',
format('Examples:', {
bold: true,
reset: true,
color: 'brightWhite',
underline: true,
...this.config.subtitleStyle,
}),
'',
indent(examples),
]),
indent(['', format('Examples:', this.config.subtitleStyle), '', indent(examples)]),
) + '\n'
)
}
@@ -160,17 +177,14 @@ function generateHelpTable<T extends Partial<GenerateTableCommandConfig>>(
}: Partial<T> = {},
): string {
const rows = items.map((o) => {
const name = `${namePrefix}${o.name}${
o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : ''
}`
const name = `${namePrefix}${o.name}${o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : ''
}`
const description = o.description
return { name, description }
})
const maxNameLength = Math.max(...rows.map((o) => o.name.length))
const nameStyle = (name: string) =>
format(name, { color: 'yellow', reset: true, ...config.nameStyle })
const descStyle = (desc: string) =>
format(desc, { color: 'gray', reset: true, ...config.descriptionStyle })
const nameStyle = (name: string) => format(name, config.nameStyle)
const descStyle = (desc: string) => format(desc, config.descriptionStyle)
const table = rows.map((row) => {
const name = nameStyle(row.name.padEnd(maxNameLength + 2))
const description = descStyle(row.description)

View File

@@ -1,3 +1,5 @@
import z from 'zod'
export function setOrPush<T>(
newValue: unknown,
currentValue: T[] | T | undefined,
@@ -46,3 +48,22 @@ export function indent(str: Parseable | Parseable[], indent = 2): string {
.map((s) => ' '.repeat(indent) + s)
.join('\n')
}
export function zodEnumFromObjKeys<K extends string>(obj: Record<K, any>): z.ZodEnum<[K, ...K[]]> {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[]
return z.enum([firstKey, ...otherKeys])
}
export function deepMerge<T1, T2>(obj1: T1, obj2: T2): NonNullable<T1> & NonNullable<T2> {
const res = { ...obj1 } as any
if (obj1 == null) return obj2 as any
if (obj2 == null) return obj1 as any
for (const [key, value] of Object.entries(obj2 as never)) {
if (typeof value === 'object' && typeof res[key] === 'object') {
res[key] = deepMerge(res[key], value)
} else {
res[key] = value
}
}
return res
}

348
test/command.test.ts Normal file
View File

@@ -0,0 +1,348 @@
import { MassargCommand } from '../src/command'
import { defaultHelpConfig } from '../src/help'
import { massarg } from '../src/index'
const opts = {
name: 'test',
description: 'test',
}
test('constructor', () => {
expect(massarg(opts)).toBeInstanceOf(MassargCommand)
})
describe('command', () => {
test('add', () => {
const command = massarg(opts)
expect(command.command).toBeInstanceOf(Function)
expect(command.command({ name: 'test2', description: 'test2', run: jest.fn() })).toBeInstanceOf(
MassargCommand,
)
})
test('add duplicate', () => {
expect(() =>
massarg(opts)
.command({ name: 'test2', description: 'test2', run: jest.fn() })
.command({ name: 'test2', description: 'test2', run: jest.fn() }),
).toThrow('Command "test2" already exists')
})
test('validate', () => {
expect(() =>
massarg(opts).command({
name: 'test2',
description: 123 as any,
run: jest.fn(),
}),
).toThrow('Expected string, received number')
})
})
describe('option', () => {
test('add', () => {
const command = massarg(opts)
expect(command.option).toBeInstanceOf(Function)
expect(
command.option({ name: 'test2', description: 'test2', aliases: [], defaultValue: '' }),
).toBeInstanceOf(MassargCommand)
})
test('validate', () => {
expect(() =>
massarg(opts).option({
name: 'test2',
description: 123 as any,
aliases: [],
defaultValue: '',
}),
).toThrow('Expected string, received number')
})
test('add duplicate', () => {
expect(() =>
massarg(opts)
.option({
name: 'test2',
description: 'test2',
aliases: [],
defaultValue: '',
})
.option({
name: 'test2',
description: 'test2',
aliases: [],
defaultValue: '',
}),
).toThrow('Option "test2" already exists')
})
test('add 2 defaults', () => {
expect(() =>
massarg(opts)
.option({
name: 'test',
description: 'test2',
aliases: [],
isDefault: true,
})
.option({
name: 'test2',
description: 'test2',
aliases: [],
isDefault: true,
}),
).toThrow(
'Option "test2" cannot be set as default because option "test" is already set as default',
)
})
})
describe('flag', () => {
test('add', () => {
const command = massarg(opts)
expect(command.flag).toBeInstanceOf(Function)
expect(command.flag({ name: 'test2', description: 'test2', aliases: [] })).toBeInstanceOf(
MassargCommand,
)
})
test('add duplicate', () => {
expect(() =>
massarg(opts)
.flag({ name: 'test2', description: 'test2', aliases: [] })
.flag({ name: 'test2', description: 'test2', aliases: [] }),
).toThrow('Flag "test2" already exists')
})
test('validate', () => {
expect(() =>
massarg(opts).flag({
name: 'test2',
description: 123 as any,
aliases: [],
}),
).toThrow('Expected string, received number')
})
})
describe('example', () => {
test('example', () => {
const command = massarg(opts)
expect(command.example).toBeInstanceOf(Function)
expect(command.example({ description: 'test', input: '', output: '' })).toBeInstanceOf(
MassargCommand,
)
})
})
describe('help', () => {
test('default value', () => {
const command = massarg(opts)
expect(command.helpConfig).toEqual(defaultHelpConfig)
})
test('init', () => {
const command = massarg(opts).help({
bindOption: true,
optionOptions: {
namePrefix: '__',
},
})
expect(command.help).toBeInstanceOf(Function)
expect(command.helpConfig).toHaveProperty('bindOption', true)
expect(command.helpConfig).toHaveProperty('optionOptions.namePrefix', '__')
expect(command.helpConfig).toHaveProperty('optionOptions.aliasPrefix', '-')
expect(command.helpConfig).toHaveProperty('optionOptions.nameStyle.color', 'yellow')
})
test('binds command', () => {
const command = massarg(opts).help({
bindCommand: true,
})
expect(command.help).toBeInstanceOf(Function)
expect(command.helpConfig).toHaveProperty('bindCommand', true)
expect(command.commands.find((o) => o.name === 'help')).toBeTruthy()
})
test('binds option', () => {
const command = massarg(opts).help({
bindOption: true,
})
expect(command.help).toBeInstanceOf(Function)
expect(command.helpConfig).toHaveProperty('bindOption', true)
expect(command.options.find((o) => o.name === 'help')).toBeTruthy()
})
test('help string', () => {
const command = massarg(opts)
expect(command.helpString()).toContain(`Usage:`)
})
test('print help', () => {
const log = jest.spyOn(console, 'log').mockImplementation(() => {})
const command = massarg(opts)
command.printHelp()
expect(log).toHaveBeenCalled()
})
})
describe('getArgs', () => {
test('basic', () => {
expect(massarg(opts).getArgs([])).toEqual({})
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: [],
})
.getArgs(['--test', 'test']),
).toEqual({ test: 'test' })
})
test('stops after command', () => {
expect(
massarg(opts)
.command({ name: 'test', description: 'test', run: jest.fn() })
.getArgs(['test', '--test', 'test']),
).toEqual({})
})
test('alias', () => {
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: ['t'],
})
.getArgs(['-t', 'test']),
).toEqual({ test: 'test' })
})
test('default value', () => {
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: [],
defaultValue: 'test',
})
.getArgs([]),
).toEqual({ test: 'test' })
})
test('override default', () => {
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: [],
defaultValue: 'test',
})
.getArgs(['--test', 'test2']),
).toEqual({ test: 'test2' })
})
test('override duplicate option', () => {
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: [],
defaultValue: 'test',
})
.getArgs(['--test', 'test2', '--test', 'test3']),
).toEqual({ test: 'test3' })
})
test('default option', () => {
expect(
massarg(opts)
.option({
name: 'test',
description: 'test',
aliases: [],
isDefault: true,
})
.getArgs(['test3']),
).toEqual({ test: 'test3' })
})
test('prefers command over default', () => {
expect(
massarg(opts)
.command({
name: 'test3',
description: 'test3',
run: jest.fn(),
})
.option({
name: 'test',
description: 'test',
aliases: [],
isDefault: true,
})
.getArgs(['test3']),
).toEqual({})
})
test.skip('extra values', () => {
expect(
massarg(opts)
.command({
name: 'test3',
description: 'test3',
run: jest.fn(),
})
.option({
name: 'test',
description: 'test',
aliases: [],
})
.getArgs(['test3', 'test2']),
).toEqual({ extra: ['test2'] })
})
})
describe('parse', () => {
test('runs command', () => {
const fn = jest.fn()
const command = massarg(opts).command({
name: 'test',
description: 'test',
run: fn,
})
expect(command).toBeInstanceOf(MassargCommand)
expect(command.parse(['test'])).toBeUndefined()
expect(fn).toHaveBeenCalled()
})
test('runs main', () => {
const fn = jest.fn()
const command = massarg(opts).main(fn)
expect(command).toBeInstanceOf(MassargCommand)
expect(command.parse([])).toBeUndefined()
expect(fn).toHaveBeenCalledWith({}, command)
})
test('runs main with args', () => {
const fn = jest.fn()
const command = massarg(opts)
.option({ name: 'test', description: 'test', aliases: [] })
.main(fn)
expect(command).toBeInstanceOf(MassargCommand)
expect(command.parse(['--test', 'test'])).toBeUndefined()
expect(fn).toHaveBeenCalledWith({ test: 'test' }, command)
})
// test('throws with unknown option', () => {
// expect(() => massarg(opts).parse(['--test', 'test'])).toThrow('Unknown option "test"')
// })
// test('throws with unknown command', () => {
// expect(() => massarg(opts).parse(['test'])).toThrow('Unknown command "test"')
// })
// test('throws without main command', () => {
// expect(() => massarg(opts).parse([])).toThrow('No main command')
// })
})
describe('main', () => {
test('add', () => {
const fn = jest.fn()
const command = massarg(opts)
expect(command.main).toBeInstanceOf(Function)
expect(command.main(fn)).toBeInstanceOf(MassargCommand)
})
})