diff --git a/README.md b/README.md index d0d3e53..667ef8a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ $p->number('port', 'HTTP port')->default(8080); +Declare optional `min`, `max` and `step` to bound the value and enable keyboard adjustment. Up and Down then adjust the value by the step (defaulting to `1`), clamped to the range, and an entry outside the range is rejected inline with a clear message. The bounds are enforced headlessly too - a `--prompts` or environment value outside the range is rejected - and are reflected in the JSON schema as `min`, `max` and `step` on the prompt. With none declared the field stays a plain integer entry with the arrow keys inert. + +```php +$p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080); +``` + ### Textarea Multi-line text input: Enter inserts a newline, Up/Down move between lines keeping the column, Tab accepts. diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index d87c0ac..85f3b34 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -5,8 +5,10 @@ namespace DrevOps\Tui\Builder; use DrevOps\Tui\Condition\ConditionInterface; +use DrevOps\Tui\Config\ConfigException; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\DiscoverInterface; @@ -90,6 +92,21 @@ final class FieldBuilder { */ protected bool $externalEditor = FALSE; + /** + * The number field's inclusive minimum, when declared. + */ + protected ?int $min = NULL; + + /** + * The number field's inclusive maximum, when declared. + */ + protected ?int $max = NULL; + + /** + * The number field's Up/Down increment, when declared. + */ + protected ?int $step = NULL; + /** * Construct a field builder. * @@ -213,6 +230,51 @@ public function externalEditor(bool $enabled = TRUE): self { return $this; } + /** + * Number only: set the inclusive minimum accepted value. + * + * @param int $min + * The minimum. + * + * @return $this + * The builder. + */ + public function min(int $min): self { + $this->min = $min; + + return $this; + } + + /** + * Number only: set the inclusive maximum accepted value. + * + * @param int $max + * The maximum. + * + * @return $this + * The builder. + */ + public function max(int $max): self { + $this->max = $max; + + return $this; + } + + /** + * Number only: set the Up/Down increment. + * + * @param int $step + * The step; must be positive. + * + * @return $this + * The builder. + */ + public function step(int $step): self { + $this->step = $step; + + return $this; + } + /** * Set the conditional-visibility rule. * @@ -351,6 +413,7 @@ public function build(): Field { $this->revealable, $this->confirm, $this->externalEditor, + $this->buildBounds(), ); } @@ -376,6 +439,31 @@ protected function resolveDefault(): mixed { return $this->defaultFor($this->fieldType); } + /** + * Assemble the number bounds from the declared min/max/step, if any. + * + * @return \DrevOps\Tui\Config\NumberBounds|null + * The bounds, or NULL when none were declared. + * + * @throws \DrevOps\Tui\Config\ConfigException + * When min exceeds max, or the step is not positive. + */ + protected function buildBounds(): ?NumberBounds { + if ($this->min === NULL && $this->max === NULL && $this->step === NULL) { + return NULL; + } + + if ($this->min !== NULL && $this->max !== NULL && $this->min > $this->max) { + throw new ConfigException(sprintf('Field "%s" declares min %d greater than max %d.', $this->id, $this->min, $this->max)); + } + + if ($this->step !== NULL && $this->step < 1) { + throw new ConfigException(sprintf('Field "%s" declares a non-positive step %d.', $this->id, $this->step)); + } + + return new NumberBounds($this->min, $this->max, $this->step); + } + /** * The engine default for a field type when none is declared. * diff --git a/src/Config/Field.php b/src/Config/Field.php index a2f4308..e298653 100644 --- a/src/Config/Field.php +++ b/src/Config/Field.php @@ -58,6 +58,9 @@ * @param bool $externalEditor * Whether the field may hand off to the user's $EDITOR for composing its * value. Honoured by the textarea widget; ignored by other types. + * @param \DrevOps\Tui\Config\NumberBounds|null $bounds + * Number only: optional min/max/step bounds; NULL for a plain integer + * entry with no range or keyboard stepping. */ public function __construct( public string $id, @@ -76,6 +79,7 @@ public function __construct( public bool $revealable = FALSE, public bool $confirm = FALSE, public bool $externalEditor = FALSE, + public ?NumberBounds $bounds = NULL, ) { } diff --git a/src/Config/NumberBounds.php b/src/Config/NumberBounds.php new file mode 100644 index 0000000..f80dca5 --- /dev/null +++ b/src/Config/NumberBounds.php @@ -0,0 +1,132 @@ +min !== NULL && $value < $this->min) { + return FALSE; + } + + return $this->max === NULL || $value <= $this->max; + } + + /** + * The range phrase for a value that violates the bounds, else NULL. + * + * The shared primitive behind every enforcement surface: it narrows the value + * to a number (a non-numeric value is not this object's concern and passes), + * then returns the human range phrase when the value falls outside the range. + * + * @param mixed $value + * The value to test. + * + * @return string|null + * The range phrase (e.g. "between 1 and 10") when out of range, else NULL. + */ + public function violation(mixed $value): ?string { + if (!is_int($value) && !is_float($value)) { + return NULL; + } + + return $this->contains($value) ? NULL : $this->describe(); + } + + /** + * The human range phrase, e.g. "between 1 and 10" or "at least 1". + * + * @return string + * The phrase, or an empty string when neither bound is declared. + */ + public function describe(): string { + if ($this->min !== NULL && $this->max !== NULL) { + return sprintf('between %d and %d', $this->min, $this->max); + } + + if ($this->min !== NULL) { + return sprintf('at least %d', $this->min); + } + + if ($this->max !== NULL) { + return sprintf('at most %d', $this->max); + } + + return ''; + } + + /** + * Clamp a value into the declared bounds. + * + * @param int $value + * The value. + * + * @return int + * The value, moved onto the nearest bound it exceeds. + */ + public function clamp(int $value): int { + if ($this->min !== NULL && $value < $this->min) { + return $this->min; + } + + if ($this->max !== NULL && $value > $this->max) { + return $this->max; + } + + return $value; + } + + /** + * Step a value by the step in a direction, clamped to the bounds. + * + * @param int $value + * The current value. + * @param int $direction + * Either 1 to increment or -1 to decrement. + * + * @return int + * The stepped, clamped value. + */ + public function step(int $value, int $direction): int { + return $this->clamp($value + $direction * ($this->step ?? 1)); + } + +} diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 99102d3..71c7979 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -188,6 +188,11 @@ protected function resolveInitial(Field $field, array $inputs, Context $context) * An error message, or NULL when the value is valid. */ protected function validateValue(Field $field, mixed $value): ?string { + $violation = $field->bounds?->violation($value); + if ($violation !== NULL) { + return sprintf('must be %s.', $violation); + } + $validator = $field->validate ?? $this->handlers->validator($field->id); if (!$validator instanceof \Closure) { return NULL; diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php index 1e9b021..6294fcc 100644 --- a/src/Schema/AgentHelp.php +++ b/src/Schema/AgentHelp.php @@ -5,6 +5,8 @@ namespace DrevOps\Tui\Schema; use DrevOps\Tui\Config\Config; +use DrevOps\Tui\Config\Field; +use DrevOps\Tui\Config\NumberBounds; /** * Produces instructions for driving the form non-interactively. @@ -48,10 +50,38 @@ public function generate(): string { foreach ($this->config->fields() as $field) { $required = $field->required ? ' (required)' : ''; - $lines[] = sprintf(' %s [%s]%s - %s', $field->id, $field->type->value, $required, $field->label); + $lines[] = sprintf(' %s [%s]%s - %s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field)); } return implode("\n", $lines); } + /** + * A compact range annotation for a bounded number field. + * + * @param \DrevOps\Tui\Config\Field $field + * The field. + * + * @return string + * The annotation (e.g. " (between 1 and 10, step 2)"), or an empty string. + */ + protected function rangeNote(Field $field): string { + if (!$field->bounds instanceof NumberBounds) { + return ''; + } + + $parts = []; + + $described = $field->bounds->describe(); + if ($described !== '') { + $parts[] = $described; + } + + if ($field->bounds->step !== NULL) { + $parts[] = sprintf('step %d', $field->bounds->step); + } + + return sprintf(' (%s)', implode(', ', $parts)); + } + } diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php index cfb3acd..ed3f3ca 100644 --- a/src/Schema/SchemaGenerator.php +++ b/src/Schema/SchemaGenerator.php @@ -47,6 +47,9 @@ public function generate(): array { 'options' => $this->options($field), 'default' => $field->default instanceof \Closure ? NULL : $field->default, 'required' => $field->required, + 'min' => $field->bounds?->min, + 'max' => $field->bounds?->max, + 'step' => $field->bounds?->step, 'when' => $field->when?->toArray(), 'derive' => $field->derive?->toArray(), 'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL, diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php index 67725d7..9aff913 100644 --- a/src/Schema/SchemaValidator.php +++ b/src/Schema/SchemaValidator.php @@ -89,9 +89,31 @@ protected function validateValue(Field $field, mixed $value): ?string { return sprintf('Question "%s" is required.', $field->id); } + $bounds_error = $this->checkBounds($field, $value); + if ($bounds_error !== NULL) { + return $bounds_error; + } + return $this->checkOptions($field, $value); } + /** + * Check a number value against its declared bounds. + * + * @param \DrevOps\Tui\Config\Field $field + * The field. + * @param mixed $value + * The value. + * + * @return string|null + * An error, or NULL when in range (or when the field declares no bounds). + */ + protected function checkBounds(Field $field, mixed $value): ?string { + $violation = $field->bounds?->violation($value); + + return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation); + } + /** * Whether the value matches the field type. * diff --git a/src/Widget/NumberWidget.php b/src/Widget/NumberWidget.php index 50ae8ec..d2dcc12 100644 --- a/src/Widget/NumberWidget.php +++ b/src/Widget/NumberWidget.php @@ -4,13 +4,60 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\NumberBounds; +use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Theme\ThemeInterface; + /** * Integer input: digits with an optional leading minus, accepted as an int. * + * With bounds supplied, Up/Down adjust the value by the step clamped to the + * range and an out-of-range entry is rejected inline; without bounds the widget + * is a plain integer text entry with the arrow keys inert. + * * @package DrevOps\Tui\Widget */ class NumberWidget extends TextWidget { + /** + * Construct a number widget. + * + * @param string $buffer + * The initial value (and live input buffer). + * @param \Closure|null $validate + * Optional validator (see AbstractWidget). + * @param \Closure|null $transform + * Optional transformer (see AbstractWidget). + * @param \DrevOps\Tui\Config\NumberBounds|null $bounds + * Optional bounds and step; NULL for a plain integer entry. + */ + public function __construct(string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, protected ?NumberBounds $bounds = NULL) { + parent::__construct($buffer, $validate, $transform); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function handle(Key $key): void { + $bounds = $this->bounds; + + if ($bounds instanceof NumberBounds && $key->is(KeyName::Up)) { + $this->adjust($bounds, 1); + + return; + } + + if ($bounds instanceof NumberBounds && $key->is(KeyName::Down)) { + $this->adjust($bounds, -1); + + return; + } + + parent::handle($key); + } + /** * {@inheritdoc} */ @@ -36,4 +83,81 @@ protected function liveValue(): mixed { return (int) $this->buffer; } + /** + * {@inheritdoc} + */ + #[\Override] + protected function accept(mixed $value): bool { + $violation = $this->bounds?->violation($value); + if ($violation !== NULL) { + $this->error = sprintf('Enter a number %s.', $violation); + + return FALSE; + } + + return parent::accept($value); + } + + /** + * Step the value in a direction, clamped to the bounds, and show the result. + * + * @param \DrevOps\Tui\Config\NumberBounds $bounds + * The bounds driving the step and clamp. + * @param int $direction + * Either 1 to increment or -1 to decrement. + */ + protected function adjust(NumberBounds $bounds, int $direction): void { + $this->buffer = (string) $bounds->step((int) $this->buffer, $direction); + $this->cursor = strlen($this->buffer); + $this->error = NULL; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function view(ThemeInterface $theme): string { + if (!$this->bounds instanceof NumberBounds) { + return parent::view($theme); + } + + $rows = [$this->caretLine($theme), $this->hint($theme)]; + + if ($this->error !== NULL) { + $rows[] = $theme->error($this->error); + } + + return implode("\n", $rows); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function rendersHint(): bool { + return $this->bounds instanceof NumberBounds; + } + + /** + * Build the key-hint line shown beneath a bounded number entry. + * + * The arrow keys are the non-obvious binding here - nothing else signals that + * they adjust the value - so they lead, followed by the remaining bindings. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme. + * + * @return string + * The themed, dot-joined hint line. + */ + protected function hint(ThemeInterface $theme): string { + $fragments = [ + $theme->arrowUp() . '/' . $theme->arrowDown() . ' adjust', + $theme->enter() . ' accept', + 'esc cancel', + ]; + + return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); + } + } diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php index 4304f99..6401b3c 100644 --- a/src/Widget/TextWidget.php +++ b/src/Widget/TextWidget.php @@ -110,9 +110,22 @@ protected function liveValue(): mixed { * {@inheritdoc} */ public function view(ThemeInterface $theme): string { - $line = substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor); + $line = $this->caretLine($theme); return $this->error === NULL ? $line : $line . "\n" . $theme->error($this->error); } + /** + * Render the input line with the caret at the cursor position. + * + * @param \DrevOps\Tui\Theme\ThemeInterface $theme + * The theme supplying the caret glyph. + * + * @return string + * The input line. + */ + protected function caretLine(ThemeInterface $theme): string { + return substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor); + } + } diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php index e828eda..cee322b 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -46,7 +46,7 @@ public function create(Field $field, mixed $current): WidgetInterface { FieldType::MultiSearch => new MultiSearchWidget($labels, $this->toList($current)), FieldType::Suggest => new SuggestWidget(array_keys($labels), is_string($current) ? $current : ''), FieldType::Search => new SearchWidget($labels, is_string($current) ? $current : ''), - FieldType::Number => new NumberWidget(is_int($current) || is_float($current) ? (string) (int) $current : ''), + FieldType::Number => new NumberWidget(is_int($current) || is_float($current) ? (string) (int) $current : '', bounds: $field->bounds), FieldType::Textarea => new TextareaWidget(is_string($current) ? $current : '', externalEdit: $field->externalEditor && $this->externalEditorAvailable), FieldType::Password => new PasswordWidget(is_string($current) ? $current : '', revealable: $field->revealable, confirm: $field->confirm), FieldType::Pause => new PauseWidget(), diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 5c0ae66..533b547 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -12,6 +12,7 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Fixup; +use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\Dotenv; use PHPUnit\Framework\Attributes\CoversClass; @@ -187,6 +188,59 @@ public function testExternalEditorFlag(): void { $this->assertFalse($config->field('plain')?->externalEditor); } + public function testValidateAndTransformStored(): void { + $validator = fn (mixed $v): ?string => NULL; + $transformer = fn (mixed $v): mixed => $v; + + $config = Form::create('T') + ->panel('p', 'P', function (PanelBuilder $panel) use ($validator, $transformer): void { + $panel->text('x')->validate($validator)->transform($transformer); + }) + ->build(); + + $field = $config->field('x'); + $this->assertInstanceOf(Field::class, $field); + $this->assertSame($validator, $field->validate); + $this->assertSame($transformer, $field->transform); + } + + public function testNumberBoundsAssembled(): void { + $config = Form::create('T') + ->panel('p', 'P', function (PanelBuilder $panel): void { + $panel->number('port', 'Port')->min(1)->max(65535)->step(5); + $panel->number('plain', 'Plain'); + }) + ->build(); + + $port = $config->field('port'); + $this->assertInstanceOf(Field::class, $port); + $this->assertInstanceOf(NumberBounds::class, $port->bounds); + $this->assertSame(1, $port->bounds->min); + $this->assertSame(65535, $port->bounds->max); + $this->assertSame(5, $port->bounds->step); + + // A number with nothing declared carries no bounds - behaviour unchanged. + $this->assertNotInstanceOf(NumberBounds::class, $config->field('plain')?->bounds); + } + + public function testNumberMinGreaterThanMaxThrows(): void { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Field "n" declares min 10 greater than max 1.'); + + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->min(10)->max(1)) + ->build(); + } + + public function testNumberNonPositiveStepThrows(): void { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Field "n" declares a non-positive step 0.'); + + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->step(0)) + ->build(); + } + public function testDuplicateFieldIdThrows(): void { $this->expectException(ConfigException::class); $this->expectExceptionMessage('Duplicate field id "x".'); diff --git a/tests/phpunit/Unit/Config/NumberBoundsTest.php b/tests/phpunit/Unit/Config/NumberBoundsTest.php new file mode 100644 index 0000000..42743aa --- /dev/null +++ b/tests/phpunit/Unit/Config/NumberBoundsTest.php @@ -0,0 +1,98 @@ +assertSame($expected, (new NumberBounds($min, $max))->contains($value)); + } + + public static function dataProviderContains(): \Iterator { + yield 'within both' => [1, 10, 5, TRUE]; + yield 'on lower bound' => [1, 10, 1, TRUE]; + yield 'on upper bound' => [1, 10, 10, TRUE]; + yield 'below both' => [1, 10, 0, FALSE]; + yield 'above both' => [1, 10, 11, FALSE]; + yield 'below open max' => [1, NULL, 0, FALSE]; + yield 'above open max' => [1, NULL, 99, TRUE]; + yield 'above open min' => [NULL, 10, 11, FALSE]; + yield 'below open min' => [NULL, 10, -5, TRUE]; + yield 'unbounded' => [NULL, NULL, 999, TRUE]; + yield 'float within' => [1, 10, 5.5, TRUE]; + yield 'float above' => [1, 10, 10.5, FALSE]; + yield 'float below' => [1, 10, 0.5, FALSE]; + } + + #[DataProvider('dataProviderViolation')] + public function testViolation(?int $min, ?int $max, mixed $value, ?string $expected): void { + $this->assertSame($expected, (new NumberBounds($min, $max))->violation($value)); + } + + public static function dataProviderViolation(): \Iterator { + yield 'int in range' => [1, 10, 5, NULL]; + yield 'int out of range' => [1, 10, 50, 'between 1 and 10']; + yield 'float in range' => [1, 10, 5.5, NULL]; + yield 'float out of range' => [1, 10, 50.5, 'between 1 and 10']; + yield 'min only violated' => [5, NULL, 1, 'at least 5']; + yield 'max only violated' => [NULL, 5, 9, 'at most 5']; + yield 'non-numeric string ignored' => [1, 10, 'oops', NULL]; + yield 'non-numeric bool ignored' => [1, 10, TRUE, NULL]; + } + + #[DataProvider('dataProviderDescribe')] + public function testDescribe(?int $min, ?int $max, string $expected): void { + $this->assertSame($expected, (new NumberBounds($min, $max))->describe()); + } + + public static function dataProviderDescribe(): \Iterator { + yield 'both' => [1, 10, 'between 1 and 10']; + yield 'min only' => [1, NULL, 'at least 1']; + yield 'max only' => [NULL, 10, 'at most 10']; + yield 'neither' => [NULL, NULL, '']; + } + + #[DataProvider('dataProviderClamp')] + public function testClamp(?int $min, ?int $max, int $value, int $expected): void { + $this->assertSame($expected, (new NumberBounds($min, $max))->clamp($value)); + } + + public static function dataProviderClamp(): \Iterator { + yield 'within' => [1, 10, 5, 5]; + yield 'below min' => [1, 10, -3, 1]; + yield 'above max' => [1, 10, 42, 10]; + yield 'open min below max' => [NULL, 10, 42, 10]; + yield 'open max above min' => [1, NULL, -3, 1]; + yield 'unbounded' => [NULL, NULL, 42, 42]; + } + + #[DataProvider('dataProviderStep')] + public function testStep(?int $min, ?int $max, ?int $step, int $value, int $direction, int $expected): void { + $this->assertSame($expected, (new NumberBounds($min, $max, $step))->step($value, $direction)); + } + + public static function dataProviderStep(): \Iterator { + yield 'default step up' => [0, 10, NULL, 5, 1, 6]; + yield 'default step down' => [0, 10, NULL, 5, -1, 4]; + yield 'custom step up' => [0, 10, 3, 5, 1, 8]; + yield 'custom step up clamps to max' => [0, 10, 3, 9, 1, 10]; + yield 'custom step down clamps to min' => [0, 10, 3, 1, -1, 0]; + yield 'unbounded step up' => [NULL, NULL, 5, 100, 1, 105]; + yield 'snaps into range from below' => [5, 10, NULL, 0, 1, 5]; + } + +} diff --git a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php index 0a3fa73..e1e988b 100644 --- a/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php +++ b/tests/phpunit/Unit/Engine/EngineDeclaredBehaviourTest.php @@ -59,6 +59,29 @@ public function testDeclaredValidateRejects(): void { $engine->collect(['name' => 'nope'], new Context()); } + public function testNumberBoundsRejectOutOfRange(): void { + $engine = $this->engine(function (PanelBuilder $p): void { + $p->number('port')->min(1)->max(10); + }); + + $this->assertSame(['port' => 5], $engine->collect(['port' => 5], new Context())); + + $this->expectException(EngineException::class); + $this->expectExceptionMessage('Invalid value for field "port": must be between 1 and 10.'); + $engine->collect(['port' => 50], new Context()); + } + + public function testNumberBoundsRejectOutOfRangeFloat(): void { + $engine = $this->engine(function (PanelBuilder $p): void { + $p->number('port')->min(1)->max(10); + }); + + // A float outside the range is rejected too - bounds are not integer-gated. + $this->expectException(EngineException::class); + $this->expectExceptionMessage('Invalid value for field "port": must be between 1 and 10.'); + $engine->collect(['port' => 50.5], new Context()); + } + public function testDeclaredTransformApplies(): void { $engine = $this->engine(function (PanelBuilder $p): void { $p->text('name')->transform(fn (mixed $v): mixed => is_string($v) ? trim($v) : $v); diff --git a/tests/phpunit/Unit/Schema/AgentHelpTest.php b/tests/phpunit/Unit/Schema/AgentHelpTest.php index 2d56be5..f43cfdc 100644 --- a/tests/phpunit/Unit/Schema/AgentHelpTest.php +++ b/tests/phpunit/Unit/Schema/AgentHelpTest.php @@ -36,6 +36,24 @@ public function testGenerate(): void { $this->assertStringContainsString('agree [confirm] - Agree', $help); } + public function testNumberRangeAnnotation(): void { + $config = Form::create('T') + ->panel('p', 'p', function (PanelBuilder $p): void { + $p->number('port', 'HTTP port')->min(1)->max(65535)->step(5); + $p->number('count', 'Count')->min(1); + $p->number('tick', 'Tick')->step(2); + $p->number('plain', 'Plain'); + }) + ->build(); + + $help = (new AgentHelp($config))->generate(); + + $this->assertStringContainsString('port [number] - HTTP port (between 1 and 65535, step 5)', $help); + $this->assertStringContainsString('count [number] - Count (at least 1)', $help); + $this->assertStringContainsString('tick [number] - Tick (step 2)', $help); + $this->assertStringContainsString('plain [number] - Plain', $help); + } + public function testNoEnvPrefixOmitsEnvLine(): void { $config = Form::create('T') ->panel('p', 'p', function (PanelBuilder $p): void { diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index 8ee06f4..2dd95ce 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -26,6 +26,7 @@ public function testGenerate(): void { $profile = $p->select('profile', 'Profile')->description('The profile')->default('standard')->required(); $profile->option('standard', 'Standard', 'Std')->option('minimal', 'Minimal'); $p->text('theme')->derive(new Derive('{{profile}}'))->when(new Condition('profile', eq: 'standard')); + $p->number('port', 'Port')->min(1)->max(65535)->step(5); }) ->build(); @@ -42,6 +43,9 @@ public function testGenerate(): void { ], 'default' => 'standard', 'required' => TRUE, + 'min' => NULL, + 'max' => NULL, + 'step' => NULL, 'when' => NULL, 'derive' => NULL, 'discover' => NULL, @@ -55,11 +59,30 @@ public function testGenerate(): void { 'options' => [], 'default' => '', 'required' => FALSE, + 'min' => NULL, + 'max' => NULL, + 'step' => NULL, 'when' => ['field' => 'profile', 'eq' => 'standard'], 'derive' => ['template' => '{{profile}}'], 'discover' => NULL, 'depends_on' => ['profile'], ], + [ + 'id' => 'port', + 'type' => 'number', + 'label' => 'Port', + 'description' => '', + 'options' => [], + 'default' => 0, + 'required' => FALSE, + 'min' => 1, + 'max' => 65535, + 'step' => 5, + 'when' => NULL, + 'derive' => NULL, + 'discover' => NULL, + 'depends_on' => [], + ], ], ]; diff --git a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php index e0a86be..2b42832 100644 --- a/tests/phpunit/Unit/Schema/SchemaValidatorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaValidatorTest.php @@ -88,6 +88,13 @@ public function testNumberAcceptsIntRejectsString(): void { $this->assertContains('Question "port" must be a number.', $validator->validate(['name' => 'Acme', 'port' => '8080'])); } + public function testNumberBoundsRejectOutOfRange(): void { + $validator = new SchemaValidator($this->config()); + + $this->assertSame([], $validator->validate(['name' => 'Acme', 'port' => 8080])); + $this->assertContains('Question "port" must be between 1 and 65535.', $validator->validate(['name' => 'Acme', 'port' => 99999])); + } + public function testPauseAcceptsBoolRejectsString(): void { $validator = new SchemaValidator($this->config()); @@ -138,7 +145,7 @@ protected function config(): Config { $p->confirm('agree'); $p->multiselect('mods')->option('a')->option('b'); $p->text('custom')->required()->when(new Condition('profile', eq: 'custom')); - $p->number('port'); + $p->number('port')->min(1)->max(65535); $p->pause('ack'); $p->search('engine')->option('solr')->option('none'); $p->multisearch('tags')->option('a')->option('b'); diff --git a/tests/phpunit/Unit/Widget/NumberWidgetTest.php b/tests/phpunit/Unit/Widget/NumberWidgetTest.php index 92ae484..311fc2d 100644 --- a/tests/phpunit/Unit/Widget/NumberWidgetTest.php +++ b/tests/phpunit/Unit/Widget/NumberWidgetTest.php @@ -4,6 +4,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; +use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Input\ArrayKeyStream; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; @@ -77,4 +78,87 @@ public function testSeededFromCurrentAndRendersCaret(): void { $this->assertStringContainsString('█', $widget->view(new DefaultTheme())); } + public function testArrowsInertAndUnhintedWithoutBounds(): void { + $widget = new NumberWidget('5'); + + // With no bounds the arrows fall through to the inert text handling. + $widget->handle(Key::named(KeyName::Up)); + $widget->handle(Key::named(KeyName::Down)); + + $this->assertSame(5, $widget->value()); + $this->assertFalse($widget->rendersHint()); + $this->assertStringNotContainsString('adjust', $widget->view(new DefaultTheme())); + } + + public function testUpDownStepByOneWithinBounds(): void { + $widget = new NumberWidget('5', bounds: new NumberBounds(0, 10)); + + $widget->handle(Key::named(KeyName::Up)); + $this->assertSame(6, $widget->value()); + + $widget->handle(Key::named(KeyName::Down)); + $this->assertSame(5, $widget->value()); + } + + public function testStepClampsToMax(): void { + $widget = new NumberWidget('9', bounds: new NumberBounds(0, 10, 3)); + + $widget->handle(Key::named(KeyName::Up)); + + $this->assertSame(10, $widget->value()); + } + + public function testStepClampsToMin(): void { + $widget = new NumberWidget('1', bounds: new NumberBounds(0, 10, 3)); + + $widget->handle(Key::named(KeyName::Down)); + + $this->assertSame(0, $widget->value()); + } + + public function testAcceptsInRangeValue(): void { + $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); + + $value = WidgetRunner::run($widget, ArrayKeyStream::of('5', Key::named(KeyName::Enter))); + + $this->assertSame(5, $value); + $this->assertTrue($widget->isComplete()); + } + + public function testRejectsOutOfRangeInline(): void { + $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); + + $widget->handle(Key::char('5')); + $widget->handle(Key::char('0')); + $widget->handle(Key::named(KeyName::Enter)); + + $this->assertFalse($widget->isComplete()); + $this->assertStringContainsString('Enter a number between 1 and 10.', $widget->view(new DefaultTheme())); + } + + public function testSteppingClearsStaleError(): void { + $widget = new NumberWidget('', bounds: new NumberBounds(1, 10)); + + $widget->handle(Key::char('5')); + $widget->handle(Key::char('0')); + $widget->handle(Key::named(KeyName::Enter)); + $this->assertStringContainsString('Enter a number', $widget->view(new DefaultTheme())); + + // Stepping produces a clamped, in-range value, so the error clears. + $widget->handle(Key::named(KeyName::Up)); + + $this->assertSame(10, $widget->value()); + $this->assertStringNotContainsString('Enter a number', $widget->view(new DefaultTheme())); + } + + public function testRendersOwnHintWhenBounded(): void { + $widget = new NumberWidget('5', bounds: new NumberBounds(0, 10)); + + $view = $widget->view(new DefaultTheme()); + + $this->assertTrue($widget->rendersHint()); + $this->assertStringContainsString('adjust', $view); + $this->assertStringContainsString('accept', $view); + } + } diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 9f4e63b..5e54222 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; @@ -75,6 +76,17 @@ public function testNumberWithNonNumericCurrentIsEmpty(): void { $this->assertSame(0, $widget->value()); } + public function testNumberBoundsPassedThrough(): void { + $field = new Field('f', 'F', '', FieldType::Number, 0, bounds: new NumberBounds(0, 10)); + + $widget = (new WidgetFactory())->create($field, 5); + + // Bounds show through the widget owning its own hint line and stepping. + $this->assertTrue($widget->rendersHint()); + $widget->handle(Key::named(KeyName::Up)); + $this->assertSame(6, $widget->value()); + } + public function testSeedsCurrentValue(): void { $widget = (new WidgetFactory())->create($this->field(FieldType::Text), 'Acme');