diff --git a/CHANGELOG.md b/CHANGELOG.md index def81eab..9c882ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) +* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` 0.4.0 ----- diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index ff1d3f1f..4e4221ab 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -25,8 +25,8 @@ final class ElicitationSchema implements \JsonSerializable { /** - * @param array $properties Property definitions keyed by name - * @param string[] $required Array of required property names + * @param array $properties Property definitions keyed by name + * @param string[] $required Array of required property names */ public function __construct( public readonly array $properties, @@ -67,7 +67,7 @@ public static function fromArray(array $data): self if (!\is_array($propertyData)) { throw new InvalidArgumentException(\sprintf('Property "%s" must be an array.', $name)); } - $properties[$name] = PrimitiveSchemaDefinition::fromArray($propertyData); + $properties[$name] = self::createSchemaDefinition($propertyData); } return new self( @@ -76,6 +76,58 @@ public static function fromArray(array $data): self ); } + /** + * Create a schema definition from array data. + * + * @param array $data + */ + private static function createSchemaDefinition(array $data): AbstractSchemaDefinition + { + if (!isset($data['type']) || !\is_string($data['type'])) { + throw new InvalidArgumentException('Missing or invalid "type" for schema definition.'); + } + + return match ($data['type']) { + 'string' => self::resolveStringType($data), + 'integer', 'number' => NumberSchemaDefinition::fromArray($data), + 'boolean' => BooleanSchemaDefinition::fromArray($data), + 'array' => self::resolveArrayType($data), + default => throw new InvalidArgumentException(\sprintf('Unsupported type "%s". Supported types are: string, integer, number, boolean, array.', $data['type'])), + }; + } + + /** + * @param array $data + */ + private static function resolveStringType(array $data): AbstractSchemaDefinition + { + if (isset($data['oneOf'])) { + return TitledEnumSchemaDefinition::fromArray($data); + } + + if (isset($data['enum'])) { + return EnumSchemaDefinition::fromArray($data); + } + + return StringSchemaDefinition::fromArray($data); + } + + /** + * @param array $data + */ + private static function resolveArrayType(array $data): AbstractSchemaDefinition + { + if (isset($data['items']['anyOf'])) { + return TitledMultiSelectEnumSchemaDefinition::fromArray($data); + } + + if (isset($data['items']['enum'])) { + return MultiSelectEnumSchemaDefinition::fromArray($data); + } + + throw new InvalidArgumentException('Array type must have "items" with either "enum" or "anyOf".'); + } + /** * @return array{ * type: string, diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php new file mode 100644 index 00000000..28046bcf --- /dev/null +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -0,0 +1,127 @@ + $maxItems) { + throw new InvalidArgumentException('minItems cannot be greater than maxItems.'); + } + + if (null !== $default) { + foreach ($default as $value) { + if (!\in_array($value, $enum, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $value)); + } + } + } + } + + /** + * @param array{ + * title: string, + * items: array{type: string, enum: string[]}, + * description?: string, + * default?: string[], + * minItems?: int, + * maxItems?: int, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'multi-select enum'); + + if (!isset($data['items']['enum']) || !\is_array($data['items']['enum'])) { + throw new InvalidArgumentException('Missing or invalid "items.enum" for multi-select enum schema definition.'); + } + + return new self( + title: $data['title'], + enum: $data['items']['enum'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + minItems: isset($data['minItems']) ? (int) $data['minItems'] : null, + maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('array'); + $data['items'] = [ + 'type' => 'string', + 'enum' => $this->enum, + ]; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->minItems) { + $data['minItems'] = $this->minItems; + } + + if (null !== $this->maxItems) { + $data['maxItems'] = $this->maxItems; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/PrimitiveSchemaDefinition.php b/src/Schema/Elicitation/PrimitiveSchemaDefinition.php deleted file mode 100644 index fa77d449..00000000 --- a/src/Schema/Elicitation/PrimitiveSchemaDefinition.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -final class PrimitiveSchemaDefinition -{ - /** - * Create a schema definition from array data. - * - * @param array{ - * type: string, - * title: string, - * description?: string, - * default?: mixed, - * enum?: string[], - * enumNames?: string[], - * format?: string, - * minLength?: int, - * maxLength?: int, - * minimum?: int|float, - * maximum?: int|float, - * } $data - */ - public static function fromArray(array $data): StringSchemaDefinition|NumberSchemaDefinition|BooleanSchemaDefinition|EnumSchemaDefinition - { - if (!isset($data['type']) || !\is_string($data['type'])) { - throw new InvalidArgumentException('Missing or invalid "type" for primitive schema definition.'); - } - - return match ($data['type']) { - 'string' => isset($data['enum']) ? EnumSchemaDefinition::fromArray($data) : StringSchemaDefinition::fromArray($data), - 'integer', 'number' => NumberSchemaDefinition::fromArray($data), - 'boolean' => BooleanSchemaDefinition::fromArray($data), - default => throw new InvalidArgumentException(\sprintf('Unsupported primitive type "%s". Supported types are: string, integer, number, boolean.', $data['type'])), - }; - } -} diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php new file mode 100644 index 00000000..54eb568b --- /dev/null +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -0,0 +1,98 @@ + $oneOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string|null $default Optional default value (must match a const) + */ + public function __construct( + string $title, + public readonly array $oneOf, + ?string $description = null, + public readonly ?string $default = null, + ) { + parent::__construct($title, $description); + + if ([] === $oneOf) { + throw new InvalidArgumentException('oneOf array must not be empty.'); + } + + $consts = []; + foreach ($oneOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "title" property.'); + } + $consts[] = $item['const']; + } + + if (null !== $default && !\in_array($default, $consts, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default)); + } + } + + /** + * @param array{ + * title: string, + * oneOf: list, + * description?: string, + * default?: string, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'titled enum'); + + if (!isset($data['oneOf']) || !\is_array($data['oneOf'])) { + throw new InvalidArgumentException('Missing or invalid "oneOf" for titled enum schema definition.'); + } + + return new self( + title: $data['title'], + oneOf: $data['oneOf'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('string'); + $data['oneOf'] = $this->oneOf; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php new file mode 100644 index 00000000..baab9b0b --- /dev/null +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -0,0 +1,131 @@ + $anyOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) + * @param int|null $minItems Optional minimum number of selections + * @param int|null $maxItems Optional maximum number of selections + */ + public function __construct( + string $title, + public readonly array $anyOf, + ?string $description = null, + public readonly ?array $default = null, + public readonly ?int $minItems = null, + public readonly ?int $maxItems = null, + ) { + parent::__construct($title, $description); + + if ([] === $anyOf) { + throw new InvalidArgumentException('anyOf array must not be empty.'); + } + + $consts = []; + foreach ($anyOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "title" property.'); + } + $consts[] = $item['const']; + } + + if (null !== $minItems && $minItems < 0) { + throw new InvalidArgumentException('minItems must be non-negative.'); + } + + if (null !== $maxItems && $maxItems < 0) { + throw new InvalidArgumentException('maxItems must be non-negative.'); + } + + if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) { + throw new InvalidArgumentException('minItems cannot be greater than maxItems.'); + } + + if (null !== $default) { + foreach ($default as $value) { + if (!\in_array($value, $consts, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the anyOf const values.', $value)); + } + } + } + } + + /** + * @param array{ + * title: string, + * items: array{anyOf: list}, + * description?: string, + * default?: string[], + * minItems?: int, + * maxItems?: int, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'titled multi-select enum'); + + if (!isset($data['items']['anyOf']) || !\is_array($data['items']['anyOf'])) { + throw new InvalidArgumentException('Missing or invalid "items.anyOf" for titled multi-select enum schema definition.'); + } + + return new self( + title: $data['title'], + anyOf: $data['items']['anyOf'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + minItems: isset($data['minItems']) ? (int) $data['minItems'] : null, + maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('array'); + $data['items'] = [ + 'anyOf' => $this->anyOf, + ]; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->minItems) { + $data['minItems'] = $this->minItems; + } + + if (null !== $this->maxItems) { + $data['maxItems'] = $this->maxItems; + } + + return $data; + } +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 8256a37b..6d65fd22 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -16,6 +16,14 @@ use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Elicitation\BooleanSchemaDefinition; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\EnumSchemaDefinition; +use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition; +use Mcp\Schema\Elicitation\NumberSchemaDefinition; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition; +use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition; use Mcp\Schema\Enum\Role; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Protocol; @@ -76,6 +84,65 @@ public function toolWithSampling(RequestContext $context, string $prompt): strin ); } + /** + * @param string $message The message to display to the user + */ + public function toolWithElicitation(RequestContext $context, string $message): string + { + $schema = new ElicitationSchema( + properties: [ + 'username' => new StringSchemaDefinition('Username'), + 'email' => new StringSchemaDefinition('Email'), + ], + ); + + $context->getClientGateway()->elicit($message, $schema); + + return 'ok'; + } + + public function toolWithElicitationDefaults(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'name' => new StringSchemaDefinition('Name', default: 'John Doe'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30), + 'score' => new NumberSchemaDefinition('Score', default: 95.5), + 'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'), + 'verified' => new BooleanSchemaDefinition('Verified', default: true), + ], + ); + + $context->getClientGateway()->elicit('Provide profile information', $schema); + + return 'ok'; + } + + public function toolWithElicitationEnums(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + 'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']), + 'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + ], + ); + + $context->getClientGateway()->elicit('Select options', $schema); + + return 'ok'; + } + public function resourceTemplate(string $id): TextResourceContents { return new TextResourceContents( diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index 2613c0d4..de676e85 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,5 +1,3 @@ server: - - tools-call-elicitation - - elicitation-sep1034-defaults - - elicitation-sep1330-enums - dns-rebinding-protection + diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 1b69b8f0..35855f65 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -49,6 +49,9 @@ ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') + ->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation') + ->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values') + ->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') diff --git a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php index 1f15c7b1..d604558b 100644 --- a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php +++ b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php @@ -17,8 +17,11 @@ use Mcp\Schema\Elicitation\BooleanSchemaDefinition; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\EnumSchemaDefinition; +use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition; +use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition; use PHPUnit\Framework\TestCase; final class ElicitationSchemaTest extends TestCase @@ -161,6 +164,101 @@ public function testFromArrayWithMultipleTypes(): void $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); } + public function testFromArrayWithMissingPropertyType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "type"'); + + /* @phpstan-ignore argument.type */ + ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['title' => 'Name'], + ], + ]); + } + + public function testFromArrayWithUnsupportedPropertyType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported type "object"'); + + ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['type' => 'object', 'title' => 'Name'], + ], + ]); + } + + public function testFromArrayWithArrayTypeMissingItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array type must have "items" with either "enum" or "anyOf"'); + + ElicitationSchema::fromArray([ + 'properties' => [ + 'tags' => ['type' => 'array', 'title' => 'Tags'], + ], + ]); + } + + public function testFromArrayWithNewEnumTypes(): void + { + $schema = ElicitationSchema::fromArray([ + 'properties' => [ + 'titledSingle' => [ + 'type' => 'string', + 'title' => 'Titled Single', + 'oneOf' => [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ], + ], + 'multiSelect' => [ + 'type' => 'array', + 'title' => 'Multi Select', + 'items' => ['type' => 'string', 'enum' => ['x', 'y']], + ], + 'titledMulti' => [ + 'type' => 'array', + 'title' => 'Titled Multi', + 'items' => [ + 'anyOf' => [ + ['const' => 'c', 'title' => 'Option C'], + ['const' => 'd', 'title' => 'Option D'], + ], + ], + ], + ], + ]); + + $this->assertInstanceOf(TitledEnumSchemaDefinition::class, $schema->properties['titledSingle']); + $this->assertInstanceOf(MultiSelectEnumSchemaDefinition::class, $schema->properties['multiSelect']); + $this->assertInstanceOf(TitledMultiSelectEnumSchemaDefinition::class, $schema->properties['titledMulti']); + } + + public function testFromArrayJsonSerializeRoundTripWithAllTypes(): void + { + $schema = new ElicitationSchema( + [ + 'name' => new StringSchemaDefinition('Name'), + 'rating' => new EnumSchemaDefinition('Rating', ['1', '2', '3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled', [ + ['const' => 'a', 'title' => 'A'], + ]), + 'tags' => new MultiSelectEnumSchemaDefinition('Tags', ['x', 'y']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Multi', [ + ['const' => 'c', 'title' => 'C'], + ]), + ], + ['name'], + ); + + $serialized = $schema->jsonSerialize(); + $restored = ElicitationSchema::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } + public function testJsonSerializeWithMinimalParams(): void { $schema = new ElicitationSchema([ diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php new file mode 100644 index 00000000..167ad08e --- /dev/null +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -0,0 +1,218 @@ +assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testConstructorWithAllParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertSame('Select languages', $schema->description); + $this->assertSame(['php'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testConstructorWithEmptyEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('enum array must not be empty'); + + new MultiSelectEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithNonStringEnumValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All enum values must be strings'); + + /* @phpstan-ignore argument.type */ + new MultiSelectEnumSchemaDefinition('Test', ['a', 1, 'b']); + } + + public function testConstructorWithNegativeMinItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems must be non-negative'); + + new MultiSelectEnumSchemaDefinition('Test', ['a'], minItems: -1); + } + + public function testConstructorWithNegativeMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('maxItems must be non-negative'); + + new MultiSelectEnumSchemaDefinition('Test', ['a'], maxItems: -1); + } + + public function testConstructorWithMinItemsGreaterThanMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems cannot be greater than maxItems'); + + new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], minItems: 3, maxItems: 1); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the enum array'); + + new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], default: ['invalid']); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Tags', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ]); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testFromArrayWithAllParams(): void + { + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Tags', + 'description' => 'Select languages', + 'default' => ['php'], + 'minItems' => 1, + 'maxItems' => 3, + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ]); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertSame('Select languages', $schema->description); + $this->assertSame(['php'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + MultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['type' => 'string', 'enum' => ['a']], + ]); + } + + public function testFromArrayWithMissingItemsEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "items.enum"'); + + /* @phpstan-ignore argument.type */ + MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Test', + 'items' => ['type' => 'string'], + ]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition('Tags', ['php', 'js', 'go']); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Tags', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Tags', + 'description' => 'Select languages', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + 'default' => ['php'], + 'minItems' => 1, + 'maxItems' => 3, + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $original = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php', 'go'], + minItems: 1, + maxItems: 3, + ); + + $serialized = $original->jsonSerialize(); + $restored = MultiSelectEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php deleted file mode 100644 index cbe5f06d..00000000 --- a/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php +++ /dev/null @@ -1,115 +0,0 @@ - 'string', - 'title' => 'Name', - ]); - - $this->assertInstanceOf(StringSchemaDefinition::class, $schema); - $this->assertSame('Name', $schema->title); - } - - public function testFromArrayCreatesEnumSchemaForStringWithEnum(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'string', - 'title' => 'Rating', - 'enum' => ['1', '2', '3'], - ]); - - $this->assertInstanceOf(EnumSchemaDefinition::class, $schema); - $this->assertSame('Rating', $schema->title); - $this->assertSame(['1', '2', '3'], $schema->enum); - } - - public function testFromArrayCreatesIntegerSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'integer', - 'title' => 'Age', - ]); - - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); - $this->assertSame('Age', $schema->title); - $this->assertTrue($schema->integerOnly); - } - - public function testFromArrayCreatesNumberSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'number', - 'title' => 'Price', - ]); - - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); - $this->assertSame('Price', $schema->title); - $this->assertFalse($schema->integerOnly); - } - - public function testFromArrayCreatesBooleanSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'boolean', - 'title' => 'Confirm', - ]); - - $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema); - $this->assertSame('Confirm', $schema->title); - } - - public function testFromArrayWithMissingType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "type"'); - - /* @phpstan-ignore argument.type */ - PrimitiveSchemaDefinition::fromArray(['title' => 'Test']); - } - - public function testFromArrayWithUnsupportedType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported primitive type "object"'); - - PrimitiveSchemaDefinition::fromArray([ - 'type' => 'object', - 'title' => 'Test', - ]); - } - - public function testFromArrayWithArrayType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported primitive type "array"'); - - PrimitiveSchemaDefinition::fromArray([ - 'type' => 'array', - 'title' => 'Test', - ]); - } -} diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php new file mode 100644 index 00000000..f8c706f0 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -0,0 +1,200 @@ + 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testConstructorWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertSame('Choose wisely', $schema->description); + $this->assertSame('b', $schema->default); + } + + public function testConstructorWithEmptyOneOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('oneOf array must not be empty'); + + new TitledEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithMissingConst(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each oneOf item must have a string "const" property'); + + /* @phpstan-ignore argument.type */ + new TitledEnumSchemaDefinition('Test', [['title' => 'A']]); + } + + public function testConstructorWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each oneOf item must have a string "title" property'); + + /* @phpstan-ignore argument.type */ + new TitledEnumSchemaDefinition('Test', [['const' => 'a']]); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the oneOf const values'); + + new TitledEnumSchemaDefinition( + title: 'Test', + oneOf: [['const' => 'a', 'title' => 'A']], + default: 'invalid', + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledEnumSchemaDefinition::fromArray([ + 'title' => 'Pick one', + 'oneOf' => $oneOf, + ]); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testFromArrayWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledEnumSchemaDefinition::fromArray([ + 'title' => 'Pick one', + 'oneOf' => $oneOf, + 'description' => 'Choose wisely', + 'default' => 'b', + ]); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertSame('Choose wisely', $schema->description); + $this->assertSame('b', $schema->default); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + } + + public function testFromArrayWithMissingOneOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "oneOf"'); + + /* @phpstan-ignore argument.type */ + TitledEnumSchemaDefinition::fromArray(['title' => 'Test']); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Pick one', + 'oneOf' => $oneOf, + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Pick one', + 'description' => 'Choose wisely', + 'oneOf' => $oneOf, + 'default' => 'b', + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $original = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $serialized = $original->jsonSerialize(); + $restored = TitledEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php new file mode 100644 index 00000000..6c1b776e --- /dev/null +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -0,0 +1,253 @@ + 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testConstructorWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ['const' => 'c', 'title' => 'Option C'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a', 'c'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertSame('Select all that apply', $schema->description); + $this->assertSame(['a', 'c'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testConstructorWithEmptyAnyOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('anyOf array must not be empty'); + + new TitledMultiSelectEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithMissingConst(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each anyOf item must have a string "const" property'); + + /* @phpstan-ignore argument.type */ + new TitledMultiSelectEnumSchemaDefinition('Test', [['title' => 'A']]); + } + + public function testConstructorWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each anyOf item must have a string "title" property'); + + /* @phpstan-ignore argument.type */ + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a']]); + } + + public function testConstructorWithNegativeMinItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems must be non-negative'); + + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], minItems: -1); + } + + public function testConstructorWithNegativeMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('maxItems must be non-negative'); + + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], maxItems: -1); + } + + public function testConstructorWithMinItemsGreaterThanMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems cannot be greater than maxItems'); + + new TitledMultiSelectEnumSchemaDefinition( + 'Test', + [['const' => 'a', 'title' => 'A'], ['const' => 'b', 'title' => 'B']], + minItems: 3, + maxItems: 1, + ); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the anyOf const values'); + + new TitledMultiSelectEnumSchemaDefinition( + 'Test', + [['const' => 'a', 'title' => 'A']], + default: ['invalid'], + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Pick many', + 'items' => ['anyOf' => $anyOf], + ]); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testFromArrayWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Pick many', + 'description' => 'Select all that apply', + 'default' => ['a'], + 'minItems' => 1, + 'maxItems' => 2, + 'items' => ['anyOf' => $anyOf], + ]); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertSame('Select all that apply', $schema->description); + $this->assertSame(['a'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(2, $schema->maxItems); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], + ]); + } + + public function testFromArrayWithMissingItemsAnyOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "items.anyOf"'); + + /* @phpstan-ignore argument.type */ + TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Test', + 'items' => [], + ]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Pick many', + 'items' => ['anyOf' => $anyOf], + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a'], + minItems: 1, + maxItems: 2, + ); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Pick many', + 'description' => 'Select all that apply', + 'items' => ['anyOf' => $anyOf], + 'default' => ['a'], + 'minItems' => 1, + 'maxItems' => 2, + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $original = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a', 'b'], + minItems: 1, + maxItems: 2, + ); + + $serialized = $original->jsonSerialize(); + $restored = TitledMultiSelectEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +}