From fe406925cdfc23ca45ad30f23b6b2c1067b75b78 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:24:11 +1000 Subject: [PATCH 01/10] [#140] Gathered the builder's rejections into one data provider. --- tests/phpunit/Unit/Builder/FormTest.php | 338 ++++++++++++------------ 1 file changed, 176 insertions(+), 162 deletions(-) diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index eb424155..211d1128 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -441,17 +441,6 @@ public function testHintAndPlaceholderDefaultToEmpty(): void { $this->assertSame('', $crop->placeholder); } - public function testTemplateWithoutPatternThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "crate" is a template field but declares no pattern'); - - Form::create('T') - ->panel('p', 'P', function (PanelBuilder $panel): void { - $panel->template('crate', 'Crate'); - }) - ->build(); - } - public function testPatternIgnoredOnNonTemplateField(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { @@ -522,15 +511,6 @@ public function testRatingCaptionsAssembled(): void { $this->assertSame([1 => 'Poor', 5 => 'Excellent'], $form->field('taste')?->ratingCaptions); } - public function testRatingStepThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "r" declares a step of 2 on a scale whose points are its steps'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('r')->step(2)) - ->build(); - } - #[DataProvider('dataProviderRatingCollapsedScaleThrows')] public function testRatingCollapsedScaleThrows(int $min, int $max): void { $this->expectException(FormException::class); @@ -576,24 +556,6 @@ public function testDateBoundsAssembled(): void { $this->assertSame(Weekday::Monday, $plain->dateBounds->weekStart); } - public function testDateInvalidBoundThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "d" declares an invalid date "2026-13-01".'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->calendar('d')->minDate('2026-13-01')) - ->build(); - } - - public function testDateMinAfterMaxThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "d" declares min date 2026-12-31 after max date 2026-01-01.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->calendar('d')->minDate('2026-12-31')->maxDate('2026-01-01')) - ->build(); - } - public function testDateBoundsIgnoredOnNonDateField(): void { $form = Form::create('T') ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->minDate('2020-01-01')->weekStart(Weekday::Sunday)) @@ -603,33 +565,6 @@ public function testDateBoundsIgnoredOnNonDateField(): void { $this->assertNotInstanceOf(DateBounds::class, $form->field('t')?->dateBounds); } - public function testNumberMinGreaterThanMaxThrows(): void { - $this->expectException(FormException::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(FormException::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 testFilePickerNonPositiveMaxSizeThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "f" declares a maximum file size of 0 below one byte.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->filePicker('f')->maxSize(0)) - ->build(); - } - public function testPageSizeAssembled(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { @@ -644,24 +579,6 @@ public function testPageSizeAssembled(): void { $this->assertNull($form->field('plain')?->pageSize); } - public function testNonPositivePageSizeThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "n" declares a non-positive page size 0.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->search('n')->pageSize(0)) - ->build(); - } - - public function testMultipleOnUnsupportedTypeThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "t" of type "text" does not collect several values'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->multiple()) - ->build(); - } - public function testSelectionBoundsAssembled(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { @@ -695,33 +612,6 @@ public function testSelectionBoundsAssembled(): void { $this->assertNotInstanceOf(SelectionBounds::class, $form->field('plain')?->selectionBounds); } - public function testSelectionLimitOnNonMultipleThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "s" declares selection limits but is not multiple'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->select('s')->minSelections(2)->option('a')) - ->build(); - } - - public function testSelectionMinGreaterThanMaxThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Field "s" declares min 5 selections greater than max 2.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->select('s')->multiple()->minSelections(5)->maxSelections(2)) - ->build(); - } - - public function testSelectionMinBelowOneThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Selection bounds declare a minimum of 0 below one.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->select('s')->multiple()->minSelections(0)) - ->build(); - } - public function testFilePickerOptions(): void { $form = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { @@ -788,25 +678,6 @@ public function testRepeatedOptionValueOverridesInPlace(): void { $this->assertSame(['a'], $field->selectableValues()); } - public function testDuplicateFieldIdThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Duplicate field id "x".'); - - Form::create('T') - ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) - ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) - ->build(); - } - - public function testToggleWithoutTwoOptionsThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Toggle field "t" must have exactly two options, 1 given.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('only')) - ->build(); - } - #[DataProvider('dataProviderToggleInvalidDefaultThrows')] public function testToggleInvalidDefaultThrows(mixed $default): void { $this->expectException(FormException::class); @@ -854,24 +725,6 @@ public function testReorderToleratesDirtyDefault(): void { $this->assertSame(['b', 'a'], $form->field('rk2')?->default); } - public function testReorderWithFewerThanTwoOptionsThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Reorder field "r" must have at least two options, 1 given.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->reorder('r')->option('only')) - ->build(); - } - - public function testReorderWithStructuralOptionThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Reorder field "r" allows only plain options - no headings, separators or disabled rows.'); - - Form::create('T') - ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->reorder('r')->option('a')->separator()->option('b')) - ->build(); - } - public function testModalPanelBuildsWithConfiguredButtons(): void { $form = Form::create('T') ->panel('root', 'Root', function (PanelBuilder $p): void { @@ -905,18 +758,6 @@ public function testModalDefaultsButtonLabels(): void { $this->assertSame('Cancel', $config->buttons->cancelLabel); } - public function testModalPanelWithSubPanelThrows(): void { - $this->expectException(FormException::class); - $this->expectExceptionMessage('Modal panel "confirm" cannot contain sub-panels.'); - - Form::create('T') - ->panel('confirm', 'Confirm', function (PanelBuilder $m): void { - $m->modal(); - $m->panel('nested', 'Nested', fn(PanelBuilder $n): FieldBuilder => $n->text('x')); - }) - ->build(); - } - public function testLayoutFlowsToTheDefinitionAndPanels(): void { $form = Form::create('Demo') ->layout(1, 2) @@ -935,15 +776,188 @@ public function testLayoutFlowsToTheDefinitionAndPanels(): void { $this->assertSame([], $form->panels[1]->layout); } - #[DataProvider('dataProviderLayoutMismatchThrows')] - public function testLayoutMismatchThrows(\Closure $declare, string $message): void { + #[DataProvider('dataProviderBuildThrows')] + public function testBuildThrows(\Closure $declare, string $message): void { $this->expectException(FormException::class); $this->expectExceptionMessage($message); $declare(); } - public static function dataProviderLayoutMismatchThrows(): \Iterator { + /** + * Data provider for testBuildThrows(). + * + * @return \Iterator + * A declaration the builder refuses, and the message it refuses it with. + */ + public static function dataProviderBuildThrows(): \Iterator { + yield 'template without a pattern' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->template('crate', 'Crate')) + ->build(); + }, + 'Field "crate" is a template field but declares no pattern', + ]; + + yield 'rating with a step' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->rating('r')->step(2)) + ->build(); + }, + 'Field "r" declares a step of 2 on a scale whose points are its steps', + ]; + + yield 'unparseable date bound' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->calendar('d')->minDate('2026-13-01')) + ->build(); + }, + 'Field "d" declares an invalid date "2026-13-01".', + ]; + + yield 'min date after max date' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', function (PanelBuilder $p): void { + $p->calendar('d')->minDate('2026-12-31')->maxDate('2026-01-01'); + }) + ->build(); + }, + 'Field "d" declares min date 2026-12-31 after max date 2026-01-01.', + ]; + + yield 'number min above max' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->min(10)->max(1)) + ->build(); + }, + 'Field "n" declares min 10 greater than max 1.', + ]; + + yield 'non-positive number step' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->number('n')->step(0)) + ->build(); + }, + 'Field "n" declares a non-positive step 0.', + ]; + + yield 'non-positive file size' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->filePicker('f')->maxSize(0)) + ->build(); + }, + 'Field "f" declares a maximum file size of 0 below one byte.', + ]; + + yield 'non-positive page size' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->search('n')->pageSize(0)) + ->build(); + }, + 'Field "n" declares a non-positive page size 0.', + ]; + + yield 'multiple on a single-value type' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->text('t')->multiple()) + ->build(); + }, + 'Field "t" of type "text" does not collect several values', + ]; + + yield 'selection limits without multiple' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', function (PanelBuilder $p): void { + $p->select('s')->minSelections(2)->option('a'); + }) + ->build(); + }, + 'Field "s" declares selection limits but is not multiple', + ]; + + yield 'selection min above max' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', function (PanelBuilder $p): void { + $p->select('s')->multiple()->minSelections(5)->maxSelections(2); + }) + ->build(); + }, + 'Field "s" declares min 5 selections greater than max 2.', + ]; + + yield 'selection min below one' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', function (PanelBuilder $p): void { + $p->select('s')->multiple()->minSelections(0); + }) + ->build(); + }, + 'Selection bounds declare a minimum of 0 below one.', + ]; + + yield 'field id used twice' => [ + static function (): void { + Form::create('T') + ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) + ->panel('b', 'B', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) + ->build(); + }, + 'Duplicate field id "x".', + ]; + + yield 'toggle without two options' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->toggle('t')->option('only')) + ->build(); + }, + 'Toggle field "t" must have exactly two options, 1 given.', + ]; + + yield 'reorder with a single option' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->reorder('r')->option('only')) + ->build(); + }, + 'Reorder field "r" must have at least two options, 1 given.', + ]; + + yield 'reorder with a structural option' => [ + static function (): void { + Form::create('T') + ->panel('p', 'P', function (PanelBuilder $p): void { + $p->reorder('r')->option('a')->separator()->option('b'); + }) + ->build(); + }, + 'Reorder field "r" allows only plain options - no headings, separators or disabled rows.', + ]; + + yield 'modal panel holding a sub-panel' => [ + static function (): void { + Form::create('T') + ->panel('confirm', 'Confirm', function (PanelBuilder $m): void { + $m->modal(); + $m->panel('nested', 'Nested', fn(PanelBuilder $n): FieldBuilder => $n->text('x')); + }) + ->build(); + }, + 'Modal panel "confirm" cannot contain sub-panels.', + ]; + yield 'form slots below the panels' => [ static function (): void { Form::create('Demo') From b78a7bf25c228f896e84467074ce7591057b93bf Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:25:57 +1000 Subject: [PATCH 02/10] [#140] Tabulated the resolver's coercion and env-name cases. --- .../Unit/Resolver/InputResolverTest.php | 211 +++++++----------- 1 file changed, 79 insertions(+), 132 deletions(-) diff --git a/tests/phpunit/Unit/Resolver/InputResolverTest.php b/tests/phpunit/Unit/Resolver/InputResolverTest.php index 6c310d8b..34a2e7e6 100644 --- a/tests/phpunit/Unit/Resolver/InputResolverTest.php +++ b/tests/phpunit/Unit/Resolver/InputResolverTest.php @@ -10,6 +10,7 @@ use DrevOps\Tui\Resolver\InputResolver; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -20,34 +21,93 @@ #[Group('resolver')] final class InputResolverTest extends TestCase { - public function testEnvCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', [ - 'APP_NAME' => 'Acme', - 'APP_AGREE' => 'yes', - 'APP_MODS' => 'a, b ,c', - ]); + #[DataProvider('dataProviderEnvValueCoercion')] + public function testEnvValueCoercion(string $variable, string $raw, string $field, mixed $expected): void { + $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', [$variable => $raw]); - $this->assertSame('Acme', $inputs['name']); - $this->assertTrue($inputs['agree']); - $this->assertSame(['a', 'b', 'c'], $inputs['mods']); + $this->assertSame($expected, $inputs[$field]); } - public function testConfirmFalsey(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_AGREE' => 'no']); + /** + * Data provider for testEnvValueCoercion(). + * + * @return \Iterator + * The variable, the raw value it carries, the field it answers and the + * value that field settles on. + */ + public static function dataProviderEnvValueCoercion(): \Iterator { + yield 'text passes through' => ['APP_NAME', 'Acme', 'name', 'Acme']; + yield 'truthy confirm' => ['APP_AGREE', 'yes', 'agree', TRUE]; + yield 'falsey confirm' => ['APP_AGREE', 'no', 'agree', FALSE]; + yield 'pause reads like a confirm' => ['APP_ACK', 'yes', 'ack', TRUE]; + yield 'toggle passes through' => ['APP_VIS', 'private', 'vis', 'private']; + yield 'date passes through' => ['APP_DUE', '2026-07-15', 'due', '2026-07-15']; + yield 'number trims and casts' => ['APP_PORT', ' 8080 ', 'port', 8080]; + yield 'rating trims and casts' => ['APP_TASTE', ' 4 ', 'taste', 4]; + // Left as typed so the engine rejects it instead of it becoming a 0. + yield 'non-integral rating stays a string' => ['APP_TASTE', 'great', 'taste', 'great']; + yield 'multiple select splits a comma list' => ['APP_MODS', 'a, b ,c', 'mods', ['a', 'b', 'c']]; + yield 'empty multiple select' => ['APP_MODS', '', 'mods', []]; + yield 'multiple search splits a comma list' => ['APP_TAGS', 'a, b', 'tags', ['a', 'b']]; + yield 'reorder splits a comma list' => ['APP_RANK', 'c, a, b', 'rank', ['c', 'a', 'b']]; + yield 'multiple picker splits a comma list' => ['APP_PATHS', 'a/b, c/d', 'paths', ['a/b', 'c/d']]; + yield 'single picker stays a string' => ['APP_CFG', '/etc/app.yml', 'cfg', '/etc/app.yml']; + } - $this->assertFalse($inputs['agree']); + #[DataProvider('dataProviderEnvNameResolution')] + public function testEnvNameResolution(Field $field, array $env, array $expected): void { + $this->assertSame($expected, (new InputResolver('APP_'))->resolve([$field], '', $env)); } - public function testToggleCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_VIS' => 'private']); + /** + * Data provider for testEnvNameResolution(). + * + * @return \Iterator, array}> + * The field, the environment it is resolved against and the inputs it + * contributes. + */ + public static function dataProviderEnvNameResolution(): \Iterator { + yield 'mechanical name is the prefixed field id' => [ + new Field('machine_name', 'Machine', '', FieldType::Text, ''), + ['APP_MACHINE_NAME' => 'x'], + ['machine_name' => 'x'], + ]; - $this->assertSame('private', $inputs['vis']); - } + yield 'declared name replaces the mechanical one' => [ + new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE'), + ['LEGACY_CRATE' => 'large', 'APP_CRATE_SIZE' => 'small'], + ['crate_size' => 'large'], + ]; - public function testEmptyMultiselect(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_MODS' => '']); + yield 'mechanical name is not read once replaced' => [ + new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE'), + ['APP_CRATE_SIZE' => 'small'], + [], + ]; - $this->assertSame([], $inputs['mods']); + yield 'alias answers when the canonical name is unset' => [ + new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE']), + ['OLD_CRATE' => 'large'], + ['crate_size' => 'large'], + ]; + + yield 'canonical name wins over an alias' => [ + new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE']), + ['OLD_CRATE' => 'large', 'APP_CRATE_SIZE' => 'small'], + ['crate_size' => 'small'], + ]; + + yield 'earlier alias wins over a later one' => [ + new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE', 'OLDER_CRATE']), + ['OLDER_CRATE' => 'small', 'OLD_CRATE' => 'large'], + ['crate_size' => 'large'], + ]; + + yield 'alias value is coerced like the canonical one' => [ + new Field('organic', 'Organic', '', FieldType::Confirm, FALSE, envAliases: ['OLD_ORGANIC']), + ['OLD_ORGANIC' => 'yes'], + ['organic' => TRUE], + ]; } public function testPromptsJsonWinsOverEnv(): void { @@ -79,119 +139,6 @@ public function testPromptsFromFile(): void { $this->assertSame('FromFile', $inputs['name']); } - public function testDateCoercionPassesThroughString(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_DUE' => '2026-07-15']); - - $this->assertSame('2026-07-15', $inputs['due']); - } - - public function testEnvNameUppercasesTheFieldId(): void { - $fields = [new Field('machine_name', 'Machine', '', FieldType::Text, '')]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', ['APP_MACHINE_NAME' => 'x']); - - $this->assertSame(['machine_name' => 'x'], $inputs); - } - - public function testDeclaredEnvNameReplacesTheMechanicalOne(): void { - $fields = [new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE')]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', [ - 'LEGACY_CRATE' => 'large', - 'APP_CRATE_SIZE' => 'small', - ]); - - $this->assertSame(['crate_size' => 'large'], $inputs); - } - - public function testMechanicalNameIsNotReadOnceReplaced(): void { - $fields = [new Field('crate_size', 'Crate size', '', FieldType::Text, '', envName: 'LEGACY_CRATE')]; - - $this->assertSame([], (new InputResolver('APP_'))->resolve($fields, '', ['APP_CRATE_SIZE' => 'small'])); - } - - public function testAliasAnswersWhenTheCanonicalNameIsUnset(): void { - $fields = [new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE'])]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', ['OLD_CRATE' => 'large']); - - $this->assertSame(['crate_size' => 'large'], $inputs); - } - - public function testCanonicalNameWinsOverAnAlias(): void { - $fields = [new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE'])]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', [ - 'OLD_CRATE' => 'large', - 'APP_CRATE_SIZE' => 'small', - ]); - - $this->assertSame(['crate_size' => 'small'], $inputs); - } - - public function testEarlierAliasWinsOverLaterOne(): void { - $fields = [new Field('crate_size', 'Crate size', '', FieldType::Text, '', envAliases: ['OLD_CRATE', 'OLDER_CRATE'])]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', [ - 'OLDER_CRATE' => 'small', - 'OLD_CRATE' => 'large', - ]); - - $this->assertSame(['crate_size' => 'large'], $inputs); - } - - public function testAliasValueIsCoercedLikeTheCanonicalOne(): void { - $fields = [new Field('organic', 'Organic', '', FieldType::Confirm, FALSE, envAliases: ['OLD_ORGANIC'])]; - - $inputs = (new InputResolver('APP_'))->resolve($fields, '', ['OLD_ORGANIC' => 'yes']); - - $this->assertTrue($inputs['organic']); - } - - public function testFilePickerCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', [ - 'APP_PATHS' => 'a/b, c/d', - 'APP_CFG' => '/etc/app.yml', - ]); - - // A multiple picker splits a comma list; a single picker stays a string. - $this->assertSame(['a/b', 'c/d'], $inputs['paths']); - $this->assertSame('/etc/app.yml', $inputs['cfg']); - } - - public function testNumberPauseAndMultisearchCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', [ - 'APP_PORT' => ' 8080 ', - 'APP_ACK' => 'yes', - 'APP_TAGS' => 'a, b', - ]); - - $this->assertSame(8080, $inputs['port']); - $this->assertTrue($inputs['ack']); - $this->assertSame(['a', 'b'], $inputs['tags']); - } - - public function testRatingCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', [ - 'APP_TASTE' => ' 4 ', - ]); - - $this->assertSame(4, $inputs['taste']); - } - - public function testRatingNonIntegralValueStaysString(): void { - // Left as typed so the engine rejects it instead of it becoming a 0. - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_TASTE' => 'great']); - - $this->assertSame('great', $inputs['taste']); - } - - public function testReorderCoercion(): void { - $inputs = (new InputResolver('APP_'))->resolve($this->fields(), '', ['APP_RANK' => 'c, a, b']); - - $this->assertSame(['c', 'a', 'b'], $inputs['rank']); - } - /** * Build one field of each coercible type for resolution. * From 2ac5d5fe9bfc29e41e3f18c3b137285a3418dcae Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:28:18 +1000 Subject: [PATCH 03/10] [#140] Tabulated the widget factory's type, seeding and handoff cases. --- .../phpunit/Unit/Widget/WidgetFactoryTest.php | 216 ++++++++---------- 1 file changed, 96 insertions(+), 120 deletions(-) diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index b52f3a16..bb4ed4c3 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -46,38 +46,84 @@ #[Group('widget')] final class WidgetFactoryTest extends TestCase { - public function testCreatesByType(): void { - $factory = new WidgetFactory(); - - $this->assertInstanceOf(TextWidget::class, $factory->create($this->field(FieldType::Text), 'x')); - $this->assertInstanceOf(ConfirmWidget::class, $factory->create($this->field(FieldType::Confirm), TRUE)); - $this->assertInstanceOf(ToggleWidget::class, $factory->create($this->fieldWithOptions(FieldType::Toggle), 'a')); - $this->assertInstanceOf(SelectWidget::class, $factory->create($this->fieldWithOptions(FieldType::Select), 'a')); - $this->assertInstanceOf(SelectWidget::class, $factory->create($this->multiFieldWithOptions(FieldType::Select), ['a'])); - $this->assertInstanceOf(SuggestWidget::class, $factory->create($this->fieldWithOptions(FieldType::Suggest), 'a')); - $this->assertInstanceOf(NumberWidget::class, $factory->create($this->field(FieldType::Number), 42)); - $this->assertInstanceOf(RatingWidget::class, $factory->create($this->ratingField(), 3)); - $this->assertInstanceOf(CalendarWidget::class, $factory->create($this->field(FieldType::Calendar), '2026-07-15')); - $this->assertInstanceOf(TextareaWidget::class, $factory->create($this->field(FieldType::Textarea), 'x')); - $this->assertInstanceOf(PasswordWidget::class, $factory->create($this->field(FieldType::Password), 'x')); - $this->assertInstanceOf(SearchWidget::class, $factory->create($this->fieldWithOptions(FieldType::Search), 'a')); - $this->assertInstanceOf(SearchWidget::class, $factory->create($this->multiFieldWithOptions(FieldType::Search), ['a'])); - $this->assertInstanceOf(ReorderWidget::class, $factory->create($this->fieldWithOptions(FieldType::Reorder), ['a'])); - $this->assertInstanceOf(FilePickerWidget::class, $factory->create($this->field(FieldType::FilePicker), '/tmp')); - $this->assertInstanceOf(PauseWidget::class, $factory->create($this->field(FieldType::Pause), TRUE)); - $this->assertInstanceOf(TemplateWidget::class, $factory->create($this->templateField(), 'one-two')); + #[DataProvider('dataProviderCreatesByType')] + public function testCreatesByType(Field $field, mixed $current, string $expected): void { + $this->assertInstanceOf($expected, (new WidgetFactory())->create($field, $current)); } - public function testTemplateSeededFromTheAssembledValue(): void { - $widget = (new WidgetFactory())->create($this->templateField(), 'one-two'); + /** + * Data provider for testCreatesByType(). + * + * @return \Iterator + * The field, the value it opens on and the widget class it builds. + */ + public static function dataProviderCreatesByType(): \Iterator { + yield 'text' => [self::field(FieldType::Text), 'x', TextWidget::class]; + yield 'confirm' => [self::field(FieldType::Confirm), TRUE, ConfirmWidget::class]; + yield 'toggle' => [self::fieldWithOptions(FieldType::Toggle), 'a', ToggleWidget::class]; + yield 'select' => [self::fieldWithOptions(FieldType::Select), 'a', SelectWidget::class]; + yield 'multiple select' => [self::multiFieldWithOptions(FieldType::Select), ['a'], SelectWidget::class]; + yield 'suggest' => [self::fieldWithOptions(FieldType::Suggest), 'a', SuggestWidget::class]; + yield 'number' => [self::field(FieldType::Number), 42, NumberWidget::class]; + yield 'rating' => [self::ratingField(), 3, RatingWidget::class]; + yield 'calendar' => [self::field(FieldType::Calendar), '2026-07-15', CalendarWidget::class]; + yield 'textarea' => [self::field(FieldType::Textarea), 'x', TextareaWidget::class]; + yield 'password' => [self::field(FieldType::Password), 'x', PasswordWidget::class]; + yield 'search' => [self::fieldWithOptions(FieldType::Search), 'a', SearchWidget::class]; + yield 'multiple search' => [self::multiFieldWithOptions(FieldType::Search), ['a'], SearchWidget::class]; + yield 'reorder' => [self::fieldWithOptions(FieldType::Reorder), ['a'], ReorderWidget::class]; + yield 'file picker' => [self::field(FieldType::FilePicker), '/nonexistent', FilePickerWidget::class]; + yield 'pause' => [self::field(FieldType::Pause), TRUE, PauseWidget::class]; + yield 'template' => [self::templateField(), 'one-two', TemplateWidget::class]; + } + + #[DataProvider('dataProviderSeedsValueFromCurrent')] + public function testSeedsValueFromCurrent(Field $field, mixed $current, mixed $expected): void { + $this->assertSame($expected, (new WidgetFactory())->create($field, $current)->value()); + } - $this->assertSame('one-two', $widget->value()); + /** + * Data provider for testSeedsValueFromCurrent(). + * + * @return \Iterator + * The field, the value it is handed and the value the widget opens on. + */ + public static function dataProviderSeedsValueFromCurrent(): \Iterator { + yield 'text' => [self::field(FieldType::Text), 'Acme', 'Acme']; + yield 'number from an integer' => [self::field(FieldType::Number), 8080, 8080]; + yield 'number from a non-numeric' => [self::field(FieldType::Number), 'oops', 0]; + yield 'rating from a non-numeric' => [self::ratingField(), 'oops', 1]; + yield 'template from the assembled value' => [self::templateField(), 'one-two', 'one-two']; + yield 'template from a non-string' => [self::templateField(), 42, '-']; + // The seed order flows through: the given value first, the remaining + // option appended to complete the ranking. + yield 'reorder completes the ranking' => [self::fieldWithOptions(FieldType::Reorder), ['b'], ['b', 'a']]; + yield 'multiple from a non-list' => [self::multiFieldWithOptions(FieldType::Select), 'notalist', []]; + // A multiple choice field seeds from the list value, proving the multiple + // flag reaches the select and search widgets. + yield 'multiple select' => [self::multiFieldWithOptions(FieldType::Select), ['a', 'b'], ['a', 'b']]; + yield 'multiple search' => [self::multiFieldWithOptions(FieldType::Search), ['a'], ['a']]; + // A current path outside the start is ignored and the missing directory + // lists nothing, so a single picker opens empty. + yield 'single picker outside its start' => [ + new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: '/nonexistent'), + 'x', + '', + ]; + + yield 'multiple picker keeps its paths' => [ + new Field('g', 'G', '', FieldType::FilePicker, [], pickerStart: '/nonexistent', multiple: TRUE), + ['/a', '/b'], + ['/a', '/b'], + ]; } - public function testTemplateWithNonStringCurrentStartsEmpty(): void { - $widget = (new WidgetFactory())->create($this->templateField(), 42); + public function testDateWithNonStringCurrentOpensOnToday(): void { + // Kept out of the seeding provider: a static provider would fix "today" at + // collection time, so a run spanning midnight would fail. + $widget = (new WidgetFactory())->create(self::field(FieldType::Calendar), 42); - $this->assertSame('-', $widget->value()); + $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); } public function testNoteHasNoEditorWidget(): void { @@ -86,29 +132,7 @@ public function testNoteHasNoEditorWidget(): void { $this->expectException(\LogicException::class); $this->expectExceptionMessage('Note fields are presentational and have no editor widget.'); - (new WidgetFactory())->create($this->field(FieldType::Note), NULL); - } - - public function testFilePickerFlagsPassedThrough(): void { - $single = new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: '/nonexistent'); - $multi = new Field('g', 'G', '', FieldType::FilePicker, [], pickerStart: '/nonexistent', multiple: TRUE); - - // The single picker yields a string; a current path outside the start is - // ignored and the missing directory lists nothing, so the value is empty. - $this->assertSame('', (new WidgetFactory())->create($single, 'x')->value()); - - // The multiple picker yields a list seeded from the current value, proving - // the multiple flag is threaded through. - $this->assertSame(['/a', '/b'], (new WidgetFactory())->create($multi, ['/a', '/b'])->value()); - } - - public function testMultipleChoiceThreadsTheFlag(): void { - $factory = new WidgetFactory(); - - // A multiple choice field yields a widget seeded from the list value, - // proving the multiple flag reaches the select and search widgets. - $this->assertSame(['a', 'b'], $factory->create($this->multiFieldWithOptions(FieldType::Select), ['a', 'b'])->value()); - $this->assertSame(['a'], $factory->create($this->multiFieldWithOptions(FieldType::Search), ['a'])->value()); + (new WidgetFactory())->create(self::field(FieldType::Note), NULL); } public function testPasswordFlagsPassedThrough(): void { @@ -125,26 +149,6 @@ public function testPasswordFlagsPassedThrough(): void { $this->assertFalse($widget->isComplete()); } - public function testReorderSeededFromCurrentValue(): void { - $widget = (new WidgetFactory())->create($this->fieldWithOptions(FieldType::Reorder), ['b']); - - // The seed order flows through the factory: the given value first, the - // remaining option appended to complete the ranking. - $this->assertSame(['b', 'a'], $widget->value()); - } - - public function testNumberSeededFromIntCurrent(): void { - $widget = (new WidgetFactory())->create($this->field(FieldType::Number), 8080); - - $this->assertSame(8080, $widget->value()); - } - - public function testNumberWithNonNumericCurrentIsEmpty(): void { - $widget = (new WidgetFactory())->create($this->field(FieldType::Number), 'oops'); - - $this->assertSame(0, $widget->value()); - } - public function testNumberBoundsPassedThrough(): void { $field = new Field('f', 'F', '', FieldType::Number, 0, bounds: new NumberBounds(0, 10)); @@ -158,17 +162,11 @@ public function testNumberBoundsPassedThrough(): void { } public function testRatingScaleAndCaptionsPassedThrough(): void { - $widget = (new WidgetFactory())->create($this->ratingField(), 3); + $widget = (new WidgetFactory())->create(self::ratingField(), 3); $this->assertStringContainsString('●●●○○ 3/5 Fair', Ansi::strip($widget->view(new DefaultTheme()))); } - public function testRatingWithNonNumericCurrentStartsAtTheLowestPoint(): void { - $widget = (new WidgetFactory())->create($this->ratingField(), 'oops'); - - $this->assertSame(1, $widget->value()); - } - public function testDateBoundsPassedThrough(): void { $field = new Field('f', 'F', '', FieldType::Calendar, '', dateBounds: new DateBounds(new \DateTimeImmutable('2026-07-10'), new \DateTimeImmutable('2026-07-20'))); @@ -178,18 +176,6 @@ public function testDateBoundsPassedThrough(): void { $this->assertSame('2026-07-10', $widget->value()); } - public function testDateWithNonStringCurrentOpensOnToday(): void { - $widget = (new WidgetFactory())->create($this->field(FieldType::Calendar), 42); - - $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); - } - - public function testSeedsCurrentValue(): void { - $widget = (new WidgetFactory())->create($this->field(FieldType::Text), 'Acme'); - - $this->assertSame('Acme', $widget->value()); - } - public function testCreatingProgressWidgetThrows(): void { // A progress row runs its work on activation; it has no editor to build. $field = new Field('p', 'P', '', FieldType::Progress, NULL); @@ -199,44 +185,34 @@ public function testCreatingProgressWidgetThrows(): void { (new WidgetFactory())->create($field, NULL); } - public function testMultipleWithNonArrayValueHasNoDefaults(): void { - $widget = (new WidgetFactory())->create($this->multiFieldWithOptions(FieldType::Select), 'notalist'); - - $this->assertSame([], $widget->value()); - } - - public function testTextareaExternalEditorOfferedWhenOptedInAndAvailable(): void { - $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: TRUE); - - $widget = (new WidgetFactory(externalEditorAvailable: TRUE))->create($field, 'x'); - $this->assertInstanceOf(TextareaWidget::class, $widget); - - $widget->handle(Key::char("\x05")); - $this->assertTrue($widget->wantsExternalEdit()); - } - - public function testTextareaExternalEditorNotOfferedWhenUnavailable(): void { - $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: TRUE); + #[DataProvider('dataProviderTextareaExternalEditorHandoff')] + public function testTextareaExternalEditorHandoff(bool $opted_in, bool $available, bool $expected): void { + $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: $opted_in); - $widget = (new WidgetFactory(externalEditorAvailable: FALSE))->create($field, 'x'); + $widget = (new WidgetFactory(externalEditorAvailable: $available))->create($field, 'x'); $this->assertInstanceOf(TextareaWidget::class, $widget); $widget->handle(Key::char("\x05")); - $this->assertFalse($widget->wantsExternalEdit()); + $this->assertSame($expected, $widget->wantsExternalEdit()); } - public function testTextareaExternalEditorNotOfferedWhenNotOptedIn(): void { - $widget = (new WidgetFactory(externalEditorAvailable: TRUE))->create($this->field(FieldType::Textarea), 'x'); - $this->assertInstanceOf(TextareaWidget::class, $widget); - - $widget->handle(Key::char("\x05")); - $this->assertFalse($widget->wantsExternalEdit()); + /** + * Data provider for testTextareaExternalEditorHandoff(). + * + * @return \Iterator + * Whether the field opted in, whether an editor is available, and whether + * the handoff is offered. + */ + public static function dataProviderTextareaExternalEditorHandoff(): \Iterator { + yield 'opted in and available' => [TRUE, TRUE, TRUE]; + yield 'opted in but unavailable' => [TRUE, FALSE, FALSE]; + yield 'available but not opted in' => [FALSE, TRUE, FALSE]; } public function testInjectsScopedKeymapIntoWidget(): void { // The vim preset binds j to move-down in the select scope, so the injected // widget responds to j where a default-preset widget would not. - $widget = (new WidgetFactory(KeyMapManager::create('vim')))->create($this->fieldWithOptions(FieldType::Select), 'a'); + $widget = (new WidgetFactory(KeyMapManager::create('vim')))->create(self::fieldWithOptions(FieldType::Select), 'a'); $widget->handle(Key::char('j')); @@ -477,7 +453,7 @@ public function testDeclaredClosuresWinOverHandlerBehaviour(): void { * @param \DrevOps\Tui\Model\FieldType $type * The field type. */ - protected function field(FieldType $type): Field { + protected static function field(FieldType $type): Field { return new Field('f', 'F', '', $type, ''); } @@ -487,7 +463,7 @@ protected function field(FieldType $type): Field { * @return \DrevOps\Tui\Model\Field * The field. */ - protected function templateField(): Field { + protected static function templateField(): Field { return new Field('f', 'F', '', FieldType::Template, '', template: new Template('{{a}}-{{b}}')); } @@ -497,7 +473,7 @@ protected function templateField(): Field { * @return \DrevOps\Tui\Model\Field * The field. */ - protected function ratingField(): Field { + protected static function ratingField(): Field { return new Field('f', 'F', '', FieldType::Rating, 1, bounds: new NumberBounds(1, 5), ratingCaptions: [3 => 'Fair']); } @@ -507,7 +483,7 @@ protected function ratingField(): Field { * @param \DrevOps\Tui\Model\FieldType $type * The field type. */ - protected function fieldWithOptions(FieldType $type): Field { + protected static function fieldWithOptions(FieldType $type): Field { return new Field('f', 'F', '', $type, '', ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B')]); } @@ -517,7 +493,7 @@ protected function fieldWithOptions(FieldType $type): Field { * @param \DrevOps\Tui\Model\FieldType $type * The field type. */ - protected function multiFieldWithOptions(FieldType $type): Field { + protected static function multiFieldWithOptions(FieldType $type): Field { return new Field('f', 'F', '', $type, [], ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B')], multiple: TRUE); } From 6dc765c070c87cd5470eb77f1e72fb7a8795ebb5 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:30:10 +1000 Subject: [PATCH 04/10] [#140] Tabulated the schema generator and folded its repeated prompt skeleton into a helper. --- .../Unit/Schema/SchemaGeneratorTest.php | 366 +++++++++--------- 1 file changed, 180 insertions(+), 186 deletions(-) diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index e9f03c5a..f511d27b 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -12,6 +12,7 @@ use DrevOps\Tui\Model\Weekday; use DrevOps\Tui\Schema\SchemaGenerator; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -33,6 +34,8 @@ public function testGenerate(): void { }) ->build(); + // Spelled out in full rather than through prompt(): this is the one place + // that documents the complete shape of a generated prompt. $expected = [ 'prompts' => [ [ @@ -167,34 +170,14 @@ public function testDescribesTemplateShape(): void { // external tooling can drive the field rather than guess at its shape. $expected = [ 'prompts' => [ - [ + self::prompt([ 'id' => 'crate', 'type' => 'template', 'label' => 'Crate label', - 'description' => '', - 'hint' => '', - 'placeholder' => '', - 'options' => [], - 'options_dynamic' => FALSE, 'default' => 'valley-a', - 'required' => FALSE, - 'env' => NULL, - 'env_aliases' => [], - 'min' => NULL, - 'max' => NULL, - 'step' => NULL, - 'min_selections' => NULL, - 'max_selections' => NULL, - 'min_date' => NULL, - 'max_date' => NULL, - 'week_start' => NULL, 'template' => '{{orchard}}-{{grade}}', 'placeholders' => ['orchard', 'grade'], - 'when' => NULL, - 'derive' => NULL, - 'discover' => NULL, - 'depends_on' => [], - ], + ]), ], ]; @@ -214,221 +197,189 @@ public function testExcludesNonSelectableOptions(): void { $expected = [ 'prompts' => [ - [ + self::prompt([ 'id' => 'profile', 'type' => 'select', 'label' => 'Profile', - 'description' => '', - 'hint' => '', - 'placeholder' => '', 'options' => [ ['value' => 'standard', 'label' => 'Standard', 'description' => ''], ], - 'options_dynamic' => FALSE, - 'default' => '', - 'required' => FALSE, - 'env' => NULL, - 'env_aliases' => [], - 'min' => NULL, - 'max' => NULL, - 'step' => NULL, - 'min_selections' => NULL, - 'max_selections' => NULL, - 'min_date' => NULL, - 'max_date' => NULL, - 'week_start' => NULL, - 'template' => NULL, - 'placeholders' => [], - 'when' => NULL, - 'derive' => NULL, - 'discover' => NULL, - 'depends_on' => [], - ], + ]), ], ]; $this->assertSame($expected, (new SchemaGenerator($form))->generate()); } - public function testSelectionBounds(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->select('tags', 'Tags')->multiple()->minSelections(2)->maxSelections(5)->option('a')->option('b'); - }) - ->build(); + #[DataProvider('dataProviderDescribesFieldInJson')] + public function testDescribesFieldInJson(\Closure $declare, array $fragments): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); $json = (string) json_encode((new SchemaGenerator($form))->generate()); - $this->assertStringContainsString('"min_selections":2', $json); - $this->assertStringContainsString('"max_selections":5', $json); + foreach ($fragments as $fragment) { + $this->assertStringContainsString($fragment, $json); + } } - public function testDependsOnCollectsNestedFieldRefs(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + /** + * Data provider for testDescribesFieldInJson(). + * + * @return \Iterator + * A panel declaration and the JSON fragments its schema must carry. + */ + public static function dataProviderDescribesFieldInJson(): \Iterator { + yield 'selection bounds' => [ + static function (PanelBuilder $p): void { + $p->select('tags', 'Tags')->multiple()->minSelections(2)->maxSelections(5)->option('a')->option('b'); + }, + ['"min_selections":2', '"max_selections":5'], + ]; + + yield 'nested condition field refs' => [ + static function (PanelBuilder $p): void { $p->text('a'); $p->text('b'); $p->text('c')->when(Condition::all(new Condition('a', eq: 'x'), new Condition('b', eq: 'y'))); - }) - ->build(); - - $json = (string) json_encode((new SchemaGenerator($form))->generate()); - - $this->assertStringContainsString('"depends_on":["a","b"]', $json); - } + }, + ['"depends_on":["a","b"]'], + ]; - public function testToggleDescribesBothValues(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'toggle carries both values' => [ + static function (PanelBuilder $p): void { $p->toggle('visibility', 'Visibility')->options(['public' => 'Public', 'private' => 'Private'])->default('public'); - }) - ->build(); - - $json = (string) json_encode((new SchemaGenerator($form))->generate()); - - $this->assertStringContainsString('"type":"toggle"', $json); - $this->assertStringContainsString('"value":"public"', $json); - $this->assertStringContainsString('"value":"private"', $json); - } - - public function testRatingDescribesItsScale(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->rating('taste', 'Taste')->min(0)->max(10); - }) - ->build(); - - $json = (string) json_encode((new SchemaGenerator($form))->generate()); + }, + ['"type":"toggle"', '"value":"public"', '"value":"private"'], + ]; - $this->assertStringContainsString('"type":"rating"', $json); - $this->assertStringContainsString('"min":0', $json); - $this->assertStringContainsString('"max":10', $json); // The points are the steps, so a rating never advertises an increment. - $this->assertStringContainsString('"step":null', $json); - } - - public function testDescribesReorderField(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->reorder('ranking', 'Ranking')->options(['a' => 'A', 'b' => 'B', 'c' => 'C'])->default(['c']); - }) - ->build(); - - $json = (string) json_encode((new SchemaGenerator($form))->generate()); + yield 'rating carries its scale' => [ + static function (PanelBuilder $p): void { + $p->rating('taste', 'Taste')->min(0)->max(10); + }, + ['"type":"rating"', '"min":0', '"max":10', '"step":null'], + ]; - $this->assertStringContainsString('"type":"reorder"', $json); // The partial default is completed to a full ranking in the schema. - $this->assertStringContainsString('"default":["c","a","b"]', $json); - $this->assertStringContainsString('"value":"a"', $json); + yield 'reorder completes its ranking' => [ + static function (PanelBuilder $p): void { + $p->reorder('ranking', 'Ranking')->options(['a' => 'A', 'b' => 'B', 'c' => 'C'])->default(['c']); + }, + ['"type":"reorder"', '"default":["c","a","b"]', '"value":"a"'], + ]; } - public function testExcludesPresentationalNote(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->note('intro', 'Intro')->description('Welcome.'); - $p->text('name', 'Name'); - }) - ->build(); - - $schema = (new SchemaGenerator($form))->generate(); + #[DataProvider('dataProviderResolvesDefault')] + public function testResolvesDefault(\Closure $declare, Context $context, mixed $expected): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); - // A note collects no answer, so it is not a prompt in the machine schema. - $prompts = $schema['prompts']; + $prompts = (new SchemaGenerator($form, $context))->generate()['prompts']; $this->assertIsArray($prompts); - $ids = array_column($prompts, 'id'); - $this->assertSame(['name'], $ids); + $first = $prompts[0]; + $this->assertIsArray($first); + $this->assertSame($expected, $first['default']); } - public function testDescribesTheEnvironmentVariablesAnsweringField(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('crate_size', 'Crate size'); - $p->text('grade', 'Grade')->env('LEGACY_GRADE')->envAliases(['OLD_GRADE']); - }) - ->build(); + /** + * Data provider for testResolvesDefault(). + * + * @return \Iterator + * A panel declaration, the context it resolves against and the default the + * schema advertises. + */ + public static function dataProviderResolvesDefault(): \Iterator { + yield 'closure resolved' => [ + static function (PanelBuilder $p): void { + $p->text('name', 'Name')->default(fn (Context $context): string => 'computed'); + }, + new Context(), + 'computed', + ]; - $prompts = (new SchemaGenerator($form, new Context(), 'APP_'))->generate()['prompts']; - $this->assertIsArray($prompts); + yield 'closure reads the provided context' => [ + static function (PanelBuilder $p): void { + $p->text('version', 'Version')->default(fn (Context $context): string => $context->version); + }, + new Context(version: '9.9.9'), + '9.9.9', + ]; - $mechanical = $prompts[0]; - $this->assertIsArray($mechanical); - $this->assertSame('APP_CRATE_SIZE', $mechanical['env']); - $this->assertSame([], $mechanical['env_aliases']); + yield 'declared schema default stands in' => [ + static function (PanelBuilder $p): void { + $p->text('name', 'Name')->default(fn (Context $context): string => 'live')->schemaDefault('static'); + }, + new Context(), + 'static', + ]; - $declared = $prompts[1]; - $this->assertIsArray($declared); - $this->assertSame('LEGACY_GRADE', $declared['env']); - $this->assertSame(['OLD_GRADE'], $declared['env_aliases']); + // Unlike the agent help, which omits the key entirely, the machine schema + // keeps every key and advertises the unresolvable default as null. + yield 'unresolvable closure is null' => [ + static function (PanelBuilder $p): void { + $p->text('name', 'Name')->default(fn (Context $context): string => throw new \RuntimeException('needs answers')); + }, + new Context(), + NULL, + ]; } - public function testDescribesNoVariableForBareMechanicalName(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('crate_size', 'Crate size'); - }) - ->build(); + #[DataProvider('dataProviderDescribesEnvironmentVariables')] + public function testDescribesEnvironmentVariables(\Closure $declare, string $prefix, int $index, ?string $env, array $aliases): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); - $prompts = (new SchemaGenerator($form))->generate()['prompts']; + $prompts = (new SchemaGenerator($form, new Context(), $prefix))->generate()['prompts']; $this->assertIsArray($prompts); - $first = $prompts[0]; - $this->assertIsArray($first); - $this->assertNull($first['env']); - } - public function testResolvesClosureDefault(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name', 'Name')->default(fn (Context $context): string => 'computed'); - }) - ->build(); - - $prompts = (new SchemaGenerator($form))->generate()['prompts']; - $this->assertIsArray($prompts); - $first = $prompts[0]; - $this->assertIsArray($first); - $this->assertSame('computed', $first['default']); + $prompt = $prompts[$index]; + $this->assertIsArray($prompt); + $this->assertSame($env, $prompt['env']); + $this->assertSame($aliases, $prompt['env_aliases']); } - public function testClosureDefaultUsesProvidedContext(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('version', 'Version')->default(fn (Context $context): string => $context->version); - }) - ->build(); - - $prompts = (new SchemaGenerator($form, new Context(version: '9.9.9')))->generate()['prompts']; - $this->assertIsArray($prompts); - $first = $prompts[0]; - $this->assertIsArray($first); - $this->assertSame('9.9.9', $first['default']); + /** + * Data provider for testDescribesEnvironmentVariables(). + * + * @return \Iterator + * A panel declaration, the prefix in force, the prompt to read, and the + * variable and aliases that prompt advertises. + */ + public static function dataProviderDescribesEnvironmentVariables(): \Iterator { + $named = static function (PanelBuilder $p): void { + $p->text('crate_size', 'Crate size'); + $p->text('grade', 'Grade')->env('LEGACY_GRADE')->envAliases(['OLD_GRADE']); + }; + + yield 'mechanical name takes the prefix' => [$named, 'APP_', 0, 'APP_CRATE_SIZE', []]; + yield 'declared name is advertised as given' => [$named, 'APP_', 1, 'LEGACY_GRADE', ['OLD_GRADE']]; + + // Without a prefix the mechanical name is not a real variable, so nothing + // is advertised for it. + yield 'bare mechanical name is not advertised' => [ + static function (PanelBuilder $p): void { + $p->text('crate_size', 'Crate size'); + }, + '', + 0, + NULL, + [], + ]; } - public function testDeclaredSchemaDefaultStandsInForClosure(): void { + public function testExcludesPresentationalNote(): void { $form = Form::create('T') ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name', 'Name')->default(fn (Context $context): string => 'live')->schemaDefault('static'); + $p->note('intro', 'Intro')->description('Welcome.'); + $p->text('name', 'Name'); }) ->build(); - $prompts = (new SchemaGenerator($form))->generate()['prompts']; - $this->assertIsArray($prompts); - $first = $prompts[0]; - $this->assertIsArray($first); - $this->assertSame('static', $first['default']); - } - - public function testUnresolvableClosureDefaultIsNull(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name', 'Name')->default(fn (Context $context): string => throw new \RuntimeException('needs answers')); - }) - ->build(); + $schema = (new SchemaGenerator($form))->generate(); - $prompts = (new SchemaGenerator($form))->generate()['prompts']; + // A note collects no answer, so it is not a prompt in the machine schema. + $prompts = $schema['prompts']; $this->assertIsArray($prompts); - $first = $prompts[0]; - $this->assertIsArray($first); - $this->assertNull($first['default']); + $ids = array_column($prompts, 'id'); + $this->assertSame(['name'], $ids); } public function testRoundTripsThroughJson(): void { @@ -444,4 +395,47 @@ public function testRoundTripsThroughJson(): void { $this->assertSame($schema, $decoded); } + /** + * A generated prompt, with only the given keys differing from the defaults. + * + * The generator emits every key on every prompt, so an expectation that + * spells them all out buries the handful that carry the point. + * + * @param array $overrides + * The keys this prompt declares. + * + * @return array + * The prompt, in the generator's own key order. + */ + protected static function prompt(array $overrides): array { + return array_merge([ + 'id' => '', + 'type' => '', + 'label' => '', + 'description' => '', + 'hint' => '', + 'placeholder' => '', + 'options' => [], + 'options_dynamic' => FALSE, + 'default' => '', + 'required' => FALSE, + 'env' => NULL, + 'env_aliases' => [], + 'min' => NULL, + 'max' => NULL, + 'step' => NULL, + 'min_selections' => NULL, + 'max_selections' => NULL, + 'min_date' => NULL, + 'max_date' => NULL, + 'week_start' => NULL, + 'template' => NULL, + 'placeholders' => [], + 'when' => NULL, + 'derive' => NULL, + 'discover' => NULL, + 'depends_on' => [], + ], $overrides); + } + } From 678313e12fdb5616b6b8dc6280dfaad8003b645d Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:31:59 +1000 Subject: [PATCH 05/10] [#140] Tabulated the agent help's shape, environment and default cases. --- tests/phpunit/Unit/Schema/AgentHelpTest.php | 512 ++++++++++---------- 1 file changed, 257 insertions(+), 255 deletions(-) diff --git a/tests/phpunit/Unit/Schema/AgentHelpTest.php b/tests/phpunit/Unit/Schema/AgentHelpTest.php index 653b8be6..8890d4a4 100644 --- a/tests/phpunit/Unit/Schema/AgentHelpTest.php +++ b/tests/phpunit/Unit/Schema/AgentHelpTest.php @@ -9,6 +9,7 @@ use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Schema\AgentHelp; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -40,322 +41,323 @@ public function testGenerate(): void { $this->assertMatchesRegularExpression('/"x-precedence":\s*\[\s*"provided",\s*"environment",\s*"discovered",\s*"derived",\s*"default"\s*\]/', $help); } - public function testSelectOptions(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + #[DataProvider('dataProviderDescribesFieldShape')] + public function testDescribesFieldShape(\Closure $declare, array $contains, array $absent, array $matches): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); + + $this->assertHelp((new AgentHelp($form))->generate(), $contains, $absent, $matches); + } + + /** + * Data provider for testDescribesFieldShape(). + * + * @return \Iterator + * A panel declaration, then the fragments the help must carry, the ones it + * must not, and the patterns it must match. + */ + public static function dataProviderDescribesFieldShape(): \Iterator { + yield 'select is an enum' => [ + static function (PanelBuilder $p): void { $p->select('fruit', 'Fruit')->default('banana')->options([ 'apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry', ]); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertMatchesRegularExpression('/"enum":\s*\[\s*"apple",\s*"banana",\s*"cherry"\s*\]/', $help); - $this->assertStringContainsString('"default": "banana"', $help); - } - - public function testMultipleSelectIsAnArrayOfOptions(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->select('veg', 'Vegetables')->multiple()->options([ - 'carrot' => 'Carrot', - 'tomato' => 'Tomato', - ]); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertStringContainsString('"type": "array"', $help); - $this->assertMatchesRegularExpression('/"items":\s*\{\s*"enum":\s*\[\s*"carrot",\s*"tomato"\s*\]\s*\}/', $help); - } - - public function testMultipleSelectionBounds(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + }, + ['"default": "banana"'], + [], + ['/"enum":\s*\[\s*"apple",\s*"banana",\s*"cherry"\s*\]/'], + ]; + + yield 'multiple select is an array of options' => [ + static function (PanelBuilder $p): void { + $p->select('veg', 'Vegetables')->multiple()->options(['carrot' => 'Carrot', 'tomato' => 'Tomato']); + }, + ['"type": "array"'], + [], + ['/"items":\s*\{\s*"enum":\s*\[\s*"carrot",\s*"tomato"\s*\]\s*\}/'], + ]; + + yield 'selection bounds are item bounds' => [ + static function (PanelBuilder $p): void { $p->select('veg', 'Vegetables')->multiple()->minSelections(2)->maxSelections(4)->options([ 'carrot' => 'Carrot', 'tomato' => 'Tomato', 'potato' => 'Potato', ]); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertStringContainsString('"type": "array"', $help); - $this->assertStringContainsString('"minItems": 2', $help); - $this->assertStringContainsString('"maxItems": 4', $help); - } - - public function testNumberBounds(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->number('port', 'HTTP port')->min(1)->max(65535)->step(5); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + ['"type": "array"', '"minItems": 2', '"maxItems": 4'], + [], + [], + ]; - $this->assertStringContainsString('"type": "integer"', $help); - $this->assertStringContainsString('"minimum": 1', $help); - $this->assertStringContainsString('"maximum": 65535', $help); // The step is a keyboard increment, not a value constraint: the schema // must accept every in-range integer the collection accepts. - $this->assertStringNotContainsString('multipleOf', $help); - } - - public function testRatingScale(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->rating('taste', 'Taste')->min(1)->max(5)->captions([1 => 'Poor', 5 => 'Excellent']); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + yield 'number bounds without the step' => [ + static function (PanelBuilder $p): void { + $p->number('port', 'HTTP port')->min(1)->max(65535)->step(5); + }, + ['"type": "integer"', '"minimum": 1', '"maximum": 65535'], + ['multipleOf'], + [], + ]; // The scale travels as an integer range, so an agent answers with a point. - $this->assertStringContainsString('"type": "integer"', $help); - $this->assertStringContainsString('"minimum": 1', $help); - $this->assertStringContainsString('"maximum": 5', $help); // Captions name what the points mean; they are not a closed value set, so // they never narrow the answer to an enum. - $this->assertStringNotContainsString('enum', $help); - } - - public function testCalendarFormat(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'rating is an integer range' => [ + static function (PanelBuilder $p): void { + $p->rating('taste', 'Taste')->min(1)->max(5)->captions([1 => 'Poor', 5 => 'Excellent']); + }, + ['"type": "integer"', '"minimum": 1', '"maximum": 5'], + ['enum'], + [], + ]; + + yield 'calendar is a date' => [ + static function (PanelBuilder $p): void { $p->calendar('due', 'Due date'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertStringContainsString('"format": "date"', $help); - } - - public function testTemplatePattern(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{grade}}'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + ['"format": "date"'], + [], + [], + ]; // The answer is the assembled string, described by the expression it must // match rather than by the pattern's own slot syntax. - $this->assertStringContainsString('"type": "string"', $help); - $this->assertStringContainsString('"pattern": "^(.*?)-(.*?)$"', $help); - $this->assertStringNotContainsString('{{orchard}}', $help); - } - - public function testFieldDescription(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'template is a matched string' => [ + static function (PanelBuilder $p): void { + $p->template('crate', 'Crate label')->pattern('{{orchard}}-{{grade}}'); + }, + ['"type": "string"', '"pattern": "^(.*?)-(.*?)$"'], + ['{{orchard}}'], + [], + ]; + + yield 'description travels' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Site name')->description('The public name'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + ['"description": "The public name"'], + [], + [], + ]; - $this->assertStringContainsString('"description": "The public name"', $help); - } - - public function testFieldHintAndPlaceholderTravelAsExtensionKeys(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + // Each text keeps its own key, so an agent reads the same three guidance + // texts a human does rather than one merged description. + yield 'hint and placeholder travel as extension keys' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Site name') ->description('The public name') ->hint('Type a few letters to filter.') ->placeholder('E.g. Golden Beetroot'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - // Each text keeps its own key, so an agent reads the same three guidance - // texts a human does rather than one merged description. - $this->assertStringContainsString('"description": "The public name"', $help); - $this->assertStringContainsString('"x-hint": "Type a few letters to filter."', $help); - $this->assertStringContainsString('"x-placeholder": "E.g. Golden Beetroot"', $help); - } - - public function testUndeclaredHintAndPlaceholderAreOmitted(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + }, + ['"description": "The public name"', '"x-hint": "Type a few letters to filter."', '"x-placeholder": "E.g. Golden Beetroot"'], + [], + [], + ]; + + yield 'undeclared hint and placeholder are omitted' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Site name'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertStringNotContainsString('"x-hint"', $help); - $this->assertStringNotContainsString('"x-placeholder"', $help); - } - - public function testNoEnvPrefixOmitsEnv(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('x', 'X'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); - - $this->assertStringNotContainsString('"env"', $help); + }, + [], + ['"x-hint"', '"x-placeholder"'], + [], + ]; } - public function testDeclaredEnvNameIsAdvertisedInsteadOfTheMechanicalOne(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('crate_size', 'Crate size')->env('LEGACY_CRATE'); - }) - ->build(); - - $help = (new AgentHelp($form, 'APP_'))->generate(); + #[DataProvider('dataProviderAdvertisesEnvironmentVariables')] + public function testAdvertisesEnvironmentVariables(\Closure $declare, string $prefix, array $contains, array $absent, array $matches): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); - $this->assertStringContainsString('"env": "LEGACY_CRATE"', $help); - $this->assertStringNotContainsString('APP_CRATE_SIZE', $help); + $this->assertHelp((new AgentHelp($form, $prefix))->generate(), $contains, $absent, $matches); } - public function testDeclaredEnvNameIsAdvertisedWithoutPrefix(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + /** + * Data provider for testAdvertisesEnvironmentVariables(). + * + * @return \Iterator + * A panel declaration and the prefix in force, then the fragments the help + * must carry, the ones it must not, and the patterns it must match. + */ + public static function dataProviderAdvertisesEnvironmentVariables(): \Iterator { + yield 'no prefix advertises nothing' => [ + static function (PanelBuilder $p): void { + $p->text('x', 'X'); + }, + '', + [], + ['"env"'], + [], + ]; + + yield 'declared name replaces the mechanical one' => [ + static function (PanelBuilder $p): void { $p->text('crate_size', 'Crate size')->env('LEGACY_CRATE'); - $p->text('grade', 'Grade'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + 'APP_', + ['"env": "LEGACY_CRATE"'], + ['APP_CRATE_SIZE'], + [], + ]; // The named field advertises itself; its unnamed neighbour has no // namespaced variable to offer, so it stays absent. - $this->assertStringContainsString('"env": "LEGACY_CRATE"', $help); - $this->assertStringNotContainsString('"env": "GRADE"', $help); - } - - public function testEnvAliasesAreAdvertisedInDeclarationOrder(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'declared name is advertised without a prefix' => [ + static function (PanelBuilder $p): void { + $p->text('crate_size', 'Crate size')->env('LEGACY_CRATE'); + $p->text('grade', 'Grade'); + }, + '', + ['"env": "LEGACY_CRATE"'], + ['"env": "GRADE"'], + [], + ]; + + yield 'aliases keep their declaration order' => [ + static function (PanelBuilder $p): void { $p->text('crate_size', 'Crate size')->envAliases(['OLD_CRATE', 'OLDER_CRATE']); - }) - ->build(); - - $help = (new AgentHelp($form, 'APP_'))->generate(); - - $this->assertStringContainsString('"env": "APP_CRATE_SIZE"', $help); - $this->assertMatchesRegularExpression('/"x-env-aliases":\s*\[\s*"OLD_CRATE",\s*"OLDER_CRATE"\s*\]/', $help); - } - - public function testEnvAliasesAreAdvertisedWithoutAdvertisableCanonicalName(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('crate_size', 'Crate size')->envAliases(['OLD_CRATE']); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + 'APP_', + ['"env": "APP_CRATE_SIZE"'], + [], + ['/"x-env-aliases":\s*\[\s*"OLD_CRATE",\s*"OLDER_CRATE"\s*\]/'], + ]; // The bare mechanical name stays hidden, but the alias answers the field // either way, so withholding it would advertise less than is honoured. - $this->assertStringNotContainsString('"env":', $help); - $this->assertMatchesRegularExpression('/"x-env-aliases":\s*\[\s*"OLD_CRATE"\s*\]/', $help); - } - - public function testNoEnvAliasesOmitsTheAnnotation(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'aliases show without an advertisable canonical name' => [ + static function (PanelBuilder $p): void { + $p->text('crate_size', 'Crate size')->envAliases(['OLD_CRATE']); + }, + '', + [], + ['"env":'], + ['/"x-env-aliases":\s*\[\s*"OLD_CRATE"\s*\]/'], + ]; + + yield 'no aliases omits the annotation' => [ + static function (PanelBuilder $p): void { $p->text('crate_size', 'Crate size'); - }) - ->build(); - - $this->assertStringNotContainsString('x-env-aliases', (new AgentHelp($form, 'APP_'))->generate()); + }, + 'APP_', + [], + ['x-env-aliases'], + [], + ]; } - public function testResolvesClosureDefault(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { - $p->text('name', 'Name')->default(fn (Context $context): string => 'computed'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + #[DataProvider('dataProviderResolvesDefault')] + public function testResolvesDefault(\Closure $declare, Context $context, array $contains, array $absent): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); - $this->assertStringContainsString('"default": "computed"', $help); + $this->assertHelp((new AgentHelp($form, '', $context))->generate(), $contains, $absent, []); } - public function testClosureDefaultUsesProvidedContext(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + /** + * Data provider for testResolvesDefault(). + * + * @return \Iterator + * A panel declaration and the context it resolves against, then the + * fragments the help must carry and the ones it must not. + */ + public static function dataProviderResolvesDefault(): \Iterator { + yield 'closure resolved' => [ + static function (PanelBuilder $p): void { + $p->text('name', 'Name')->default(fn (Context $context): string => 'computed'); + }, + new Context(), + ['"default": "computed"'], + [], + ]; + + yield 'closure reads the provided context' => [ + static function (PanelBuilder $p): void { $p->text('version', 'Version')->default(fn (Context $context): string => $context->version); - }) - ->build(); - - $help = (new AgentHelp($form, '', new Context(version: '7.7.7')))->generate(); - - $this->assertStringContainsString('"default": "7.7.7"', $help); - } - - public function testDeclaredSchemaDefaultStandsInForClosure(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + }, + new Context(version: '7.7.7'), + ['"default": "7.7.7"'], + [], + ]; + + yield 'declared schema default stands in' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Name')->default(fn (Context $context): string => 'live')->schemaDefault('static'); - }) - ->build(); - - $help = (new AgentHelp($form))->generate(); + }, + new Context(), + ['"default": "static"'], + ['live'], + ]; - $this->assertStringContainsString('"default": "static"', $help); - $this->assertStringNotContainsString('live', $help); - } - - public function testUnresolvableClosureDefaultOmitsDefault(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + // The unresolvable closure emits no `default` key; `"default"` still occurs + // in the x-precedence list, so match the key form with its colon. + yield 'unresolvable closure omits the key' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Name')->default(fn (Context $context): string => throw new \RuntimeException('needs answers')); - }) - ->build(); + }, + new Context(), + ['"name"'], + ['"default":'], + ]; + } - $help = (new AgentHelp($form))->generate(); + #[DataProvider('dataProviderSkipsNonAnsweringField')] + public function testSkipsNonAnsweringField(\Closure $declare, string $absent): void { + $form = Form::create('T')->panel('p', 'p', $declare)->build(); - // The unresolvable closure emits no `default` key; `"default"` still occurs - // in the x-precedence list, so match the key form with its colon. - $this->assertStringContainsString('"name"', $help); - $this->assertStringNotContainsString('"default":', $help); + // A field that carries no answer is not one an agent is asked to provide. + $this->assertHelp((new AgentHelp($form, 'APP_'))->generate(), ['"name"'], [$absent], []); } - public function testPauseIsSkipped(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + /** + * Data provider for testSkipsNonAnsweringField(). + * + * @return \Iterator + * A panel declaration and the id of the field the help must leave out. + */ + public static function dataProviderSkipsNonAnsweringField(): \Iterator { + yield 'pause' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Name')->required(); $p->pause('ready', 'Review'); - }) - ->build(); + }, + 'ready', + ]; - $help = (new AgentHelp($form, 'APP_'))->generate(); - - $this->assertStringContainsString('"name"', $help); - $this->assertStringNotContainsString('ready', $help); - } - - public function testNoteIsSkipped(): void { - $form = Form::create('T') - ->panel('p', 'p', function (PanelBuilder $p): void { + yield 'note' => [ + static function (PanelBuilder $p): void { $p->text('name', 'Name')->required(); $p->note('intro', 'Intro')->description('Welcome.'); - }) - ->build(); - - $help = (new AgentHelp($form, 'APP_'))->generate(); + }, + 'intro', + ]; + } - // A note carries no answer, so an agent is not asked to provide one. - $this->assertStringContainsString('"name"', $help); - $this->assertStringNotContainsString('intro', $help); + /** + * Assert what the generated help does and does not say. + * + * @param string $help + * The generated help. + * @param string[] $contains + * Fragments the help must carry. + * @param string[] $absent + * Fragments the help must not carry. + * @param string[] $matches + * Patterns the help must match, for fragments whose whitespace varies. + */ + protected function assertHelp(string $help, array $contains, array $absent, array $matches): void { + foreach ($contains as $fragment) { + $this->assertStringContainsString($fragment, $help); + } + + foreach ($absent as $fragment) { + $this->assertStringNotContainsString($fragment, $help); + } + + foreach ($matches as $pattern) { + $this->assertMatchesRegularExpression($pattern, $help); + } } } From 9d96ca7a364db9e7e3a842e5651d623032ebf797 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:35:36 +1000 Subject: [PATCH 06/10] [#140] Tabulated the panel controller's dismissal, editor-hint and fullscreen guard twins. --- .../Unit/Render/PanelControllerTest.php | 197 ++++++++++-------- 1 file changed, 109 insertions(+), 88 deletions(-) diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php index 46a9b405..1cd1e07a 100644 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ b/tests/phpunit/Unit/Render/PanelControllerTest.php @@ -29,6 +29,7 @@ use DrevOps\Tui\Theme\Spacing; use DrevOps\Tui\Theme\VAlign; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -681,24 +682,38 @@ public function testTextareaExternalEditAbortKeepsEditing(): void { $this->assertSame('seeded', $controller->answers()->value('notes')); } - public function testTextareaEditorHintShownWhenAvailable(): void { - $controller = $this->textareaController($this->fixedEditor(NULL)); + #[DataProvider('dataProviderTextareaEditorHintFollowsAvailability')] + public function testTextareaEditorHintFollowsAvailability(bool $available, bool $shown): void { + $controller = $this->textareaController($available ? $this->fixedEditor(NULL) : $this->unavailableEditor()); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); - $this->assertStringContainsString('ctrl-e editor', Ansi::strip($controller->frame(12))); + $frame = Ansi::strip($controller->frame(12)); + + $this->assertSame($shown, str_contains($frame, 'ctrl-e editor'), 'the editor hint follows availability'); } - public function testTextareaEditorHintHiddenAndHandoffInertWhenUnavailable(): void { + /** + * Data provider for testTextareaEditorHintFollowsAvailability(). + * + * @return \Iterator + * Whether an editor is available and whether the hint offers it. + */ + public static function dataProviderTextareaEditorHintFollowsAvailability(): \Iterator { + yield 'editor available' => [TRUE, TRUE]; + yield 'no editor available' => [FALSE, FALSE]; + } + + public function testTextareaHandoffInertWithoutAnEditor(): void { $controller = $this->textareaController($this->unavailableEditor()); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); - $this->assertStringNotContainsString('ctrl-e editor', Ansi::strip($controller->frame(12))); // With no editor available the trigger is inert - editing continues. $controller->handle(Key::char("\x05")); + $this->assertTrue($controller->isEditing()); $this->assertSame('seeded', $controller->answers()->value('notes')); } @@ -805,40 +820,41 @@ public function testModalSubmitKeepsEditsAndReturnsToParent(): void { $this->assertSame(1, $controller->cursor()); } - public function testModalCancelButtonRestoresTheAnswers(): void { + #[DataProvider('dataProviderModalDismissalRestoresTheAnswers')] + public function testModalDismissalRestoresTheAnswers(array $dismissal): void { $controller = $this->modalController(); $controller->handle(Key::named(KeyName::Down)); $controller->handle(Key::named(KeyName::Enter)); - // Edit the field, then activate the Cancel button. + // Edit the field so the dialog has something to discard. $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::char('X')); $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); + + foreach ($dismissal as $key) { + $controller->handle($key); + } $this->assertFalse($controller->currentPanel()->isModal()); $this->assertSame('ace', $controller->answers()->value('nick')); + // The cursor is restored to the item that opened the dialog. + $this->assertSame(1, $controller->cursor()); } - public function testModalEscapeRestoresTheAnswers(): void { - $controller = $this->modalController(); - - $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::char('Z')); - $controller->handle(Key::named(KeyName::Enter)); - - // Escape dismisses the dialog like Cancel: edits are discarded. - $controller->handle(Key::named(KeyName::Escape)); + /** + * Data provider for testModalDismissalRestoresTheAnswers(). + * + * @return \Iterator + * The keys that dismiss an open dialog, discarding its edits. + */ + public static function dataProviderModalDismissalRestoresTheAnswers(): \Iterator { + // Past the field to Cancel, then activate it. + yield 'cancel button' => [ + [Key::named(KeyName::Down), Key::named(KeyName::Down), Key::named(KeyName::Enter)], + ]; - $this->assertFalse($controller->currentPanel()->isModal()); - $this->assertSame('ace', $controller->answers()->value('nick')); - $this->assertSame(1, $controller->cursor()); + yield 'escape' => [[Key::named(KeyName::Escape)]]; } public function testModalQuitDismissesInsteadOfEndingTheForm(): void { @@ -992,70 +1008,91 @@ public function testRunFullscreenPositionsTheCappedFrame(): void { $this->assertSame(str_repeat(' ', 15) . '+' . str_repeat('-', 28) . '+', rtrim($lines[0])); } - public function testRunFullscreenTooSmallGuardSwallowsAllButQuit(): void { + #[DataProvider('dataProviderRunFullscreenTooSmallGuard')] + public function testRunFullscreenTooSmallGuard(array $keys, bool $done, bool $interrupted): void { $controller = $this->fullscreenController(['fullscreen' => TRUE]); - // Six rows are below the ten-row minimum; Down must be swallowed by the - // guard screen, then quit ends the loop. - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Down)), 'q'], 6, 40); + // Six rows are below the ten-row minimum, so the guard screen stands in + // for the frame and swallows everything that is not an exit. + $terminal = new BufferedTerminal($keys, 6, 40); $controller->run($terminal); $output = Ansi::strip($terminal->output()); $this->assertStringContainsString('Terminal too small.', $output); $this->assertStringContainsString('Need at least 24 x 10 - have 40 x 6.', $output); - $this->assertTrue($controller->isDone()); + $this->assertSame($done, $controller->isDone()); + $this->assertSame($interrupted, $controller->isInterrupted()); $this->assertSame(0, $controller->cursor()); } - public function testRunFullscreenTooSmallGuardStillInterrupts(): void { - $controller = $this->fullscreenController(['fullscreen' => TRUE]); - $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Interrupt))], 6, 40); - - $controller->run($terminal); + /** + * Data provider for testRunFullscreenTooSmallGuard(). + * + * @return \Iterator + * The encoded keys the guard screen reads, and whether they leave the + * controller finished and interrupted. + */ + public static function dataProviderRunFullscreenTooSmallGuard(): \Iterator { + // The Down is swallowed by the guard, then quit ends the loop. + yield 'quit after a swallowed key' => [ + [KeyEncoder::encode(Key::named(KeyName::Down)), 'q'], + TRUE, + FALSE, + ]; - $this->assertTrue($controller->isInterrupted()); - $this->assertFalse($controller->isDone()); + yield 'interrupt' => [[KeyEncoder::encode(Key::named(KeyName::Interrupt))], FALSE, TRUE]; } - public function testRunFullscreenMinWidthIsMeasuredFromContent(): void { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('window', 'Preferred delivery window of the season'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(30, ['color' => FALSE, 'unicode' => FALSE, 'fullscreen' => TRUE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['window' => 'Morning']); + #[DataProvider('dataProviderRunFullscreenMinimums')] + public function testRunFullscreenMinimums(array $options, int $width, string $label, int $rows, int $cols, bool $too_small): void { + $controller = $this->fullscreenController($options, $width, $label); + $terminal = new BufferedTerminal([], $rows, $cols); - // Thirty columns cannot fit the measured 50-column field row. - $terminal = new BufferedTerminal([], 24, 30); $controller->run($terminal); - $this->assertStringContainsString('Terminal too small.', Ansi::strip($terminal->output())); - } + $output = Ansi::strip($terminal->output()); - public function testRunFullscreenExplicitMinWidthOverridesTheMeasure(): void { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('window', 'Preferred delivery window of the season'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(30, ['color' => FALSE, 'unicode' => FALSE, 'fullscreen' => TRUE, 'min_width' => 10, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['window' => 'Morning']); + // The guard screen stands in for the frame, so the two are exclusive. + $this->assertSame($too_small, str_contains($output, 'Terminal too small.'), 'the guard screen shows only when the terminal is too small'); + $this->assertSame(!$too_small, str_contains($output, 'General'), 'the frame renders only when the terminal is big enough'); + } - $terminal = new BufferedTerminal([], 24, 30); - $controller->run($terminal); + /** + * Data provider for testRunFullscreenMinimums(). + * + * @return \Iterator, int, string, int, int, bool}> + * The theme options, theme width and field label, the terminal rows and + * columns, and whether the run dead-ends on the too-small guard. + */ + public static function dataProviderRunFullscreenMinimums(): \Iterator { + $long = 'Preferred delivery window of the season'; - $output = Ansi::strip($terminal->output()); - $this->assertStringNotContainsString('Terminal too small.', $output); - $this->assertStringContainsString('General', $output); - } + // Thirty columns cannot fit the measured 50-column field row. + yield 'measured min width exceeds the terminal' => [['fullscreen' => TRUE], 30, $long, 24, 30, TRUE]; - public function testRunOutsideFullscreenIgnoresTheMinimums(): void { - // The same six-row terminal renders the plain frame when not fullscreen. - $controller = $this->fullscreenController([]); - $terminal = new BufferedTerminal([], 6, 40); + yield 'explicit min width overrides the measure' => [ + ['fullscreen' => TRUE, 'min_width' => 10], + 30, + $long, + 24, + 30, + FALSE, + ]; - $controller->run($terminal); + // The content measures ~50 columns, but the 30-column cap is the + // consumer's word that clipping is acceptable: a 40-column terminal must + // render the capped frame, not dead-end on an unsatisfiable notice. + yield 'max width caps the measured min width' => [ + ['fullscreen' => TRUE, 'max_width' => 30], + 40, + $long, + 24, + 40, + FALSE, + ]; - $output = Ansi::strip($terminal->output()); - $this->assertStringNotContainsString('Terminal too small.', $output); - $this->assertStringContainsString('General', $output); + // The same six-row terminal renders the plain frame when not fullscreen. + yield 'outside fullscreen the minimums do not apply' => [[], 40, 'Name', 6, 40, FALSE]; } public function testRunFullscreenTooSmallQuitDismissesAnOpenModal(): void { @@ -1104,24 +1141,6 @@ public function testModalBodyUsesTheFullScreenBudget(): void { $this->assertStringContainsString('Fourth', Ansi::strip($controller->frame(14))); } - public function testRunFullscreenMeasuredMinWidthIsCappedByMaxWidth(): void { - $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('window', 'Preferred delivery window of the season'); - }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'unicode' => FALSE, 'fullscreen' => TRUE, 'max_width' => 30, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['window' => 'Morning']); - - // The content measures ~50 columns, but the 30-column cap is the - // consumer's word that clipping is acceptable: a 40-column terminal must - // render the capped frame, not dead-end on an unsatisfiable notice. - $terminal = new BufferedTerminal([], 24, 40); - $controller->run($terminal); - - $output = Ansi::strip($terminal->output()); - $this->assertStringNotContainsString('Terminal too small.', $output); - $this->assertStringContainsString('General', $output); - } - public function testRunFullscreenMinHeightIsCappedByMaxHeight(): void { // max_height 8 lowers the default 10-row minimum: a 9-row terminal is // enough for the 8-row frame, so no notice shows. @@ -1255,14 +1274,16 @@ protected function gridController(): PanelController { * Theme options merged over colourless ASCII defaults. * @param int $width * The theme width (the terminal width in fullscreen). + * @param string $label + * The field label, which is what the content measures at its widest. * * @return \DrevOps\Tui\Render\PanelController * The controller. */ - protected function fullscreenController(array $options, int $width = 40): PanelController { + protected function fullscreenController(array $options, int $width = 40, string $label = 'Name'): PanelController { $builder = Form::create('Demo') - ->panel('general', 'General', function (PanelBuilder $p): void { - $p->text('name', 'Name'); + ->panel('general', 'General', function (PanelBuilder $p) use ($label): void { + $p->text('name', $label); }); return new PanelController($builder->build(), new DefaultTheme($width, ['color' => FALSE, 'unicode' => FALSE] + $options + ['border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme']); From 114fda927c7a399fb1abd89ea167b39e17907823 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:36:27 +1000 Subject: [PATCH 07/10] [#140] Drew the panel controller's plain theme from the shared trait. --- .../Unit/Render/PanelControllerTest.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php index 1cd1e07a..12128560 100644 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ b/tests/phpunit/Unit/Render/PanelControllerTest.php @@ -112,7 +112,7 @@ public function testButtonsOptOut(): void { ->panel('p', 'p', function (PanelBuilder $p): void { $p->text('a', 'A'); }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['a' => 'x']); + $controller = new PanelController($builder->build(), $this->plainTheme(), ['a' => 'x']); $this->assertStringNotContainsString('Submit', Ansi::strip($controller->frame(12))); @@ -164,7 +164,7 @@ public function testInlineEditRendersChoiceListInThePanel(): void { $p->select('env', 'Env')->default('dev')->options(['dev' => 'Development', 'prod' => 'Production']); $p->text('note', 'Note'); }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['env' => 'dev', 'note' => 'n']); + $controller = new PanelController($builder->build(), $this->plainTheme(), ['env' => 'dev', 'note' => 'n']); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); @@ -200,7 +200,7 @@ public function testStandaloneEditTakesTheFullScreen(): void { $p->text('name', 'Name')->standalone(); $p->text('other', 'Other'); }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme', 'other' => 'x']); + $controller = new PanelController($builder->build(), $this->plainTheme(), ['name' => 'Acme', 'other' => 'x']); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); @@ -515,7 +515,7 @@ public function testFooterHiddenWhenTurnedOff(): void { ->panel('p', 'p', function (PanelBuilder $p): void { $p->text('a', 'A'); }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['a' => 'x'], footer: FALSE); + $controller = new PanelController($builder->build(), $this->plainTheme(), ['a' => 'x'], footer: FALSE); // The hub footer is gone. $this->assertStringNotContainsString('quit', Ansi::strip($controller->frame(12))); @@ -603,7 +603,7 @@ public function testRunInterruptClearsEvenWhenClearOnExitOff(): void { $p->text('name', 'Name'); }) ->build(); - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); + $theme = $this->plainTheme(); // An interrupt renders the frame once (one clear) and then forces a second // clear at teardown despite clearOnExit being off. @@ -628,7 +628,7 @@ public function testRunInterruptAtBannerAbortsBeforeTheForm(): void { $p->text('name', 'Name'); }) ->build(); - $controller = new PanelController($config, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); + $controller = new PanelController($config, $this->plainTheme(), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); // Ctrl-C at the "press any key" banner aborts instead of entering the form. $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Interrupt))]); @@ -645,7 +645,7 @@ public function testRunRendersBannerThenTheForm(): void { ->panel('general', 'General', function (PanelBuilder $p): void { $p->text('name', 'Name'); }); - $controller = new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); + $controller = new PanelController($builder->build(), $this->plainTheme(), ['name' => 'Acme'], banner: 'WELCOME', version: '2.0'); // The first key dismisses the banner; input then ends. $terminal = new BufferedTerminal([KeyEncoder::encode(Key::named(KeyName::Enter))]); @@ -731,7 +731,7 @@ protected function textareaController(ExternalEditor $editor): PanelController { $p->textarea('notes', 'Notes')->externalEditor(); }); - return new PanelController($builder->build(), new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['notes' => 'seeded'], external_editor: $editor); + return new PanelController($builder->build(), $this->plainTheme(), ['notes' => 'seeded'], external_editor: $editor); } /** @@ -1301,7 +1301,7 @@ public function testEditEnforcesDeclaredValidatorAndTransform(): void { }) ->build(); - $controller = new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), values: ['name' => '']); + $controller = new PanelController($form, $this->plainTheme(), values: ['name' => '']); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); $this->assertTrue($controller->isEditing()); @@ -1456,7 +1456,7 @@ public function testEditEnforcesHandlerBehaviour(): void { }) ->build(); - $controller = new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), values: ['machine_name' => 'Seed'], handlers: new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler'])); + $controller = new PanelController($form, $this->plainTheme(), values: ['machine_name' => 'Seed'], handlers: new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler'])); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::char('X')); @@ -1475,7 +1475,7 @@ public function testConditionalFieldFollowsAnswers(): void { }) ->build(); - $controller = new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['extra' => FALSE, 'notes' => 'mixed']); + $controller = new PanelController($form, $this->plainTheme(), ['extra' => FALSE, 'notes' => 'mixed']); $controller->handle(Key::named(KeyName::Enter)); // The condition fails, so the field neither renders nor answers. @@ -1507,7 +1507,7 @@ public function testCursorClampsWhenFieldHides(): void { }) ->build(); - $controller = new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['gated' => 'g', 'extra' => TRUE]); + $controller = new PanelController($form, $this->plainTheme(), ['gated' => 'g', 'extra' => TRUE]); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Down)); $this->assertSame(1, $controller->cursor()); @@ -1570,7 +1570,7 @@ public function testEditAppliesFixups(): void { }) ->build(); - $controller = new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['tag' => '', 'note' => '']); + $controller = new PanelController($form, $this->plainTheme(), ['tag' => '', 'note' => '']); $controller->handle(Key::named(KeyName::Enter)); $controller->handle(Key::named(KeyName::Enter)); @@ -1594,7 +1594,7 @@ protected function derivedController(): PanelController { }) ->build(); - return new PanelController($form, new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['name' => 'Red Apple'], ['slug' => Provenance::Derived]); + return new PanelController($form, $this->plainTheme(), ['name' => 'Red Apple'], ['slug' => Provenance::Derived]); } protected function controller(): PanelController { @@ -1608,7 +1608,7 @@ protected function controller(): PanelController { ->panel('drupal', 'Drupal', function (PanelBuilder $p): void { $p->text('profile', 'Profile'); }); - $theme = new DefaultTheme(40, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]); + $theme = $this->plainTheme(); return new PanelController($builder->build(), $theme, ['name' => 'Acme', 'debug' => FALSE, 'profile' => 'standard']); } From e5b1658e591c56e7dcb60cbc4022769d39698e8a Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:37:19 +1000 Subject: [PATCH 08/10] [#140] Named the panel controller's drill-then-edit opener. --- .../Unit/Render/PanelControllerTest.php | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php index 12128560..c87b44f7 100644 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ b/tests/phpunit/Unit/Render/PanelControllerTest.php @@ -140,8 +140,7 @@ public function testButtonsOnlyOnRoot(): void { public function testInlineEditExpandsWidgetInsideThePanel(): void { $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $frame = Ansi::strip($controller->frame(12)); @@ -166,8 +165,7 @@ public function testInlineEditRendersChoiceListInThePanel(): void { }); $controller = new PanelController($builder->build(), $this->plainTheme(), ['env' => 'dev', 'note' => 'n']); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $frame = Ansi::strip($controller->frame(12)); @@ -186,8 +184,7 @@ public function testInlineEditKeepsTheFieldDescription(): void { }); $controller = new PanelController($builder->build(), new DefaultTheme(50, ['color' => FALSE, 'border' => Border::None, 'spacing' => Spacing::Normal]), ['cdn' => TRUE]); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); // The field's help text stays visible while its editor is open in the row. $this->assertStringContainsString('Cache assets at the edge.', Ansi::strip($controller->frame(12))); @@ -202,8 +199,7 @@ public function testStandaloneEditTakesTheFullScreen(): void { }); $controller = new PanelController($builder->build(), $this->plainTheme(), ['name' => 'Acme', 'other' => 'x']); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $frame = Ansi::strip($controller->frame(12)); @@ -247,8 +243,7 @@ public function testLeftRightIgnoredOffButtons(): void { public function testEditFieldReturnsWithValue(): void { $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $controller->handle(Key::char('!')); @@ -261,8 +256,7 @@ public function testEditFieldReturnsWithValue(): void { public function testEditCancelKeepsValue(): void { $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $controller->handle(Key::named(KeyName::Escape)); @@ -324,8 +318,7 @@ public function testFrameShowsSelectionAndValue(): void { public function testEditingFrameShowsWidget(): void { $controller = $this->controller(); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $frame = $controller->frame(12); @@ -521,8 +514,7 @@ public function testFooterHiddenWhenTurnedOff(): void { $this->assertStringNotContainsString('quit', Ansi::strip($controller->frame(12))); // And so is the editor's hint line (drill into the panel, then the field). - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $this->assertStringNotContainsString('accept', Ansi::strip($controller->frame(12))); } @@ -659,8 +651,7 @@ public function testRunRendersBannerThenTheForm(): void { public function testTextareaExternalEditCommitsCapturedValue(): void { $controller = $this->textareaController($this->fixedEditor('FROM EDITOR')); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $controller->handle(Key::char("\x05")); @@ -673,8 +664,7 @@ public function testTextareaExternalEditCommitsCapturedValue(): void { public function testTextareaExternalEditAbortKeepsEditing(): void { $controller = $this->textareaController($this->fixedEditor(NULL)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $controller->handle(Key::char("\x05")); // A NULL capture (aborted edit) leaves the field open, value intact. @@ -686,8 +676,7 @@ public function testTextareaExternalEditAbortKeepsEditing(): void { public function testTextareaEditorHintFollowsAvailability(bool $available, bool $shown): void { $controller = $this->textareaController($available ? $this->fixedEditor(NULL) : $this->unavailableEditor()); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $frame = Ansi::strip($controller->frame(12)); @@ -708,8 +697,7 @@ public static function dataProviderTextareaEditorHintFollowsAvailability(): \Ite public function testTextareaHandoffInertWithoutAnEditor(): void { $controller = $this->textareaController($this->unavailableEditor()); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); // With no editor available the trigger is inert - editing continues. $controller->handle(Key::char("\x05")); @@ -890,8 +878,7 @@ public function testModalEditsFieldInlineInsideTheDialog(): void { $controller = $this->modalController(); $controller->handle(Key::named(KeyName::Down)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); // Type a character so the live editor value differs from the stored one - @@ -1302,8 +1289,7 @@ public function testEditEnforcesDeclaredValidatorAndTransform(): void { ->build(); $controller = new PanelController($form, $this->plainTheme(), values: ['name' => '']); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); // An invalid value is rejected: the editor stays open showing the error. @@ -1330,8 +1316,7 @@ public function testSubmitRefusedWhileRequiredFieldIsEmpty(): void { // Drill into the panel, fill the field, and come back out. $controller->handle(Key::named(KeyName::Up)); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $this->assertTrue($controller->isEditing()); $controller->handle(Key::char('P')); $controller->handle(Key::named(KeyName::Enter)); @@ -1457,8 +1442,7 @@ public function testEditEnforcesHandlerBehaviour(): void { ->build(); $controller = new PanelController($form, $this->plainTheme(), values: ['machine_name' => 'Seed'], handlers: new HandlerRegistry(['DrevOps\Tui\Tests\Fixtures\Handler'])); - $controller->handle(Key::named(KeyName::Enter)); - $controller->handle(Key::named(KeyName::Enter)); + $this->drillAndEdit($controller); $controller->handle(Key::char('X')); $controller->handle(Key::named(KeyName::Enter)); @@ -1597,6 +1581,17 @@ protected function derivedController(): PanelController { return new PanelController($form, $this->plainTheme(), ['name' => 'Red Apple'], ['slug' => Provenance::Derived]); } + /** + * Enter the panel under the cursor, then open the editor on its field. + * + * @param \DrevOps\Tui\Render\PanelController $controller + * The controller to drive. + */ + protected function drillAndEdit(PanelController $controller): void { + $controller->handle(Key::named(KeyName::Enter)); + $controller->handle(Key::named(KeyName::Enter)); + } + protected function controller(): PanelController { $builder = Form::create('Demo') ->panel('general', 'General', function (PanelBuilder $p): void { From 1e7db38284c915004f6f980e79763dcf6ed35e44 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 17:39:44 +1000 Subject: [PATCH 09/10] [#140] Narrowed the provider parameters the static analyser reads. --- tests/phpunit/Unit/Render/PanelControllerTest.php | 10 ++++++++++ tests/phpunit/Unit/Widget/WidgetFactoryTest.php | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php index c87b44f7..0d47ccb1 100644 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ b/tests/phpunit/Unit/Render/PanelControllerTest.php @@ -995,6 +995,16 @@ public function testRunFullscreenPositionsTheCappedFrame(): void { $this->assertSame(str_repeat(' ', 15) . '+' . str_repeat('-', 28) . '+', rtrim($lines[0])); } + /** + * Tests the guard screen a terminal below the minimum size falls back to. + * + * @param list $keys + * The encoded keys the guard screen reads. + * @param bool $done + * Whether the keys finish the form. + * @param bool $interrupted + * Whether the keys interrupt it. + */ #[DataProvider('dataProviderRunFullscreenTooSmallGuard')] public function testRunFullscreenTooSmallGuard(array $keys, bool $done, bool $interrupted): void { $controller = $this->fullscreenController(['fullscreen' => TRUE]); diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index bb4ed4c3..aefba8ee 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -46,6 +46,16 @@ #[Group('widget')] final class WidgetFactoryTest extends TestCase { + /** + * Tests the widget each field type builds an editor from. + * + * @param \DrevOps\Tui\Model\Field $field + * The field to build an editor for. + * @param mixed $current + * The value the widget opens on. + * @param class-string $expected + * The widget the factory builds. + */ #[DataProvider('dataProviderCreatesByType')] public function testCreatesByType(Field $field, mixed $current, string $expected): void { $this->assertInstanceOf($expected, (new WidgetFactory())->create($field, $current)); From fd0142b0ab592f43d015b1caf98dd84dd7e88923 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 29 Jul 2026 18:04:11 +1000 Subject: [PATCH 10/10] Addressed code review: seeded the picker cases from a virtual filesystem and bracketed the today assertion. --- .../phpunit/Unit/Widget/WidgetFactoryTest.php | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index aefba8ee..b3f3f2e9 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -34,6 +34,7 @@ use DrevOps\Tui\Widget\TextWidget; use DrevOps\Tui\Widget\ToggleWidget; use DrevOps\Tui\Widget\WidgetFactory; +use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -113,27 +114,35 @@ public static function dataProviderSeedsValueFromCurrent(): \Iterator { // flag reaches the select and search widgets. yield 'multiple select' => [self::multiFieldWithOptions(FieldType::Select), ['a', 'b'], ['a', 'b']]; yield 'multiple search' => [self::multiFieldWithOptions(FieldType::Search), ['a'], ['a']]; - // A current path outside the start is ignored and the missing directory - // lists nothing, so a single picker opens empty. - yield 'single picker outside its start' => [ - new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: '/nonexistent'), - 'x', - '', - ]; + } + + public function testFilePickerSeedsValueFromCurrent(): void { + // Kept out of the seeding provider: the picker reads a directory, and a + // virtual one lists nothing without depending on what the host happens to + // have on disk. + vfsStream::setup('crates'); + $start = vfsStream::url('crates'); + + // A current path outside the start is ignored and the empty directory + // lists nothing, so the value is empty. + $single = new Field('f', 'F', '', FieldType::FilePicker, '', pickerStart: $start); + $this->assertSame('', (new WidgetFactory())->create($single, 'x')->value()); - yield 'multiple picker keeps its paths' => [ - new Field('g', 'G', '', FieldType::FilePicker, [], pickerStart: '/nonexistent', multiple: TRUE), - ['/a', '/b'], - ['/a', '/b'], - ]; + // The multiple picker yields a list seeded from the current value, proving + // the multiple flag is threaded through. + $multi = new Field('g', 'G', '', FieldType::FilePicker, [], pickerStart: $start, multiple: TRUE); + $this->assertSame(['/a', '/b'], (new WidgetFactory())->create($multi, ['/a', '/b'])->value()); } public function testDateWithNonStringCurrentOpensOnToday(): void { // Kept out of the seeding provider: a static provider would fix "today" at - // collection time, so a run spanning midnight would fail. + // collection time. Bracketing the call keeps a midnight rollover between + // the two reads from failing the run. + $before = (new \DateTimeImmutable('today'))->format('Y-m-d'); $widget = (new WidgetFactory())->create(self::field(FieldType::Calendar), 42); + $after = (new \DateTimeImmutable('today'))->format('Y-m-d'); - $this->assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $widget->value()); + $this->assertContains($widget->value(), [$before, $after]); } public function testNoteHasNoEditorWidget(): void {