From 2f0d57410580a2e01d4a6a1699e3fe88383baec6 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 21 Oct 2026 16:29:18 +0900 Subject: [PATCH 1/2] Make Select and MultiSelect item and limit handling consistent - Equal items are listed once, at the position of the first. In MultiSelect, checking one of several equal items checked all of them and returned each, so the result could exceed Maximum and disagree with the Minimum check. - Options that leave the prompt unanswerable now throw before it is shown: an empty Select (ArgumentException) and a MultiSelect whose Minimum exceeds the number of items (ArgumentOutOfRangeException). Before, the user could only cancel, which exits the process when ThrowExceptionOnCancel is false. - Options are validated and Items are snapshotted before the base constructor creates the console driver, so invalid options no longer leave it undisposed. - The Japanese invalid-minimum message referenced {1} with a single argument and threw FormatException instead of the intended ArgumentOutOfRangeException. - Docs (en/ja) describe the item rules and how Minimum and Maximum apply to each MultiSelect operation. Co-Authored-By: Claude Opus 5.5 --- docs/en/prompt-types/multi-select.md | 18 +++ docs/en/prompt-types/select.md | 7 + docs/ja/prompt-types/multi-select.md | 18 +++ docs/ja/prompt-types/select.md | 7 + src/Sharprompt/Forms/MultiSelectForm.cs | 30 +++- src/Sharprompt/Forms/SelectForm.cs | 26 +++- src/Sharprompt/Strings/Resource.Designer.cs | 18 +++ src/Sharprompt/Strings/Resource.de-DE.resx | 6 + src/Sharprompt/Strings/Resource.ja.resx | 8 +- src/Sharprompt/Strings/Resource.resx | 6 + src/Sharprompt/Strings/Resource.zh-Hans.resx | 6 + .../Forms/FormInteractionTests.cs | 142 +++++++++++++++++- 12 files changed, 277 insertions(+), 15 deletions(-) diff --git a/docs/en/prompt-types/multi-select.md b/docs/en/prompt-types/multi-select.md index 42223edf..444c9d00 100644 --- a/docs/en/prompt-types/multi-select.md +++ b/docs/en/prompt-types/multi-select.md @@ -49,6 +49,24 @@ var cities = Prompt.MultiSelect(new MultiSelectOptions | `TextSelector` | `Func` | `x => x.ToString()!` | Display text selector | | `LoopingSelection` | `bool` | `true` | Whether to loop at the beginning/end of the list | +## Selection Rules + +- `Items` is read once, when the prompt starts. Equal items (compared with `EqualityComparer.Default`) are listed once, at the position of the first one. +- `DefaultValues` that are not in `Items` are ignored. +- The result contains the selected items in the order of `Items`. +- `Minimum` and `Maximum` limit the number of selected items: + +| Operation | Result | +|-----------|--------| +| Space on an unchecked item at `Maximum` | The item is not checked, and an error is shown. | +| Ctrl+A or Ctrl+I that would exceed `Maximum` | Nothing changes, and an error is shown. | +| More `DefaultValues` than `Maximum` | They are all checked. Enter is rejected until enough are unchecked. | +| Enter below `Minimum` or above `Maximum` | Rejected with an error. | + +Ctrl+A and Ctrl+I only affect the items that match the current filter. Checked items hidden by the filter stay checked. + +Before the prompt is shown, `ArgumentOutOfRangeException` is thrown when `Minimum` is negative, when `Maximum` is less than `Minimum`, or when `Minimum` is greater than the number of items, because the prompt could not be answered. For an empty `Items`, set `Minimum` to `0`. + ## With Enum Type ```csharp diff --git a/docs/en/prompt-types/select.md b/docs/en/prompt-types/select.md index 5f7f97b7..c99d1374 100644 --- a/docs/en/prompt-types/select.md +++ b/docs/en/prompt-types/select.md @@ -50,6 +50,13 @@ var city = Prompt.Select("Select your city", Console.WriteLine($"Hello, {city}!"); ``` +## Item Rules + +- `Items` is read once, when the prompt starts. Equal items (compared with `EqualityComparer.Default`) are listed once, at the position of the first one. +- `DefaultValue` selects the matching item when the prompt starts. If it is not in `Items`, nothing is selected. A value of a compatible type, such as an `int` for a `long` prompt, is converted; an incompatible one throws `ArgumentException`. +- Enter with nothing selected shows an error. When the filter leaves a single item, or exactly matches an item's text, that item is selected. +- An empty `Items` throws `ArgumentException` before the prompt is shown, because nothing could be selected. + ## With Enum Type When using an enum type, items are automatically generated: diff --git a/docs/ja/prompt-types/multi-select.md b/docs/ja/prompt-types/multi-select.md index a6c635d0..24338cc4 100644 --- a/docs/ja/prompt-types/multi-select.md +++ b/docs/ja/prompt-types/multi-select.md @@ -49,6 +49,24 @@ var cities = Prompt.MultiSelect(new MultiSelectOptions | `TextSelector` | `Func` | `x => x.ToString()!` | 表示テキストセレクタ | | `LoopingSelection` | `bool` | `true` | リストの先頭/末尾でループするかどうか | +## 選択のルール + +- `Items` はプロンプトの開始時に 1 回だけ読み取ります。等しい項目(`EqualityComparer.Default` で比較)は、最初の位置に 1 つだけ表示します。 +- `Items` に含まれない `DefaultValues` は無視されます。 +- 結果には、選択された項目が `Items` の順序で含まれます。 +- `Minimum` と `Maximum` は選択数を制限します: + +| 操作 | 結果 | +|------|------| +| 選択数が `Maximum` のときに、未選択の項目で Space | 項目は選択されず、エラーを表示します。 | +| `Maximum` を超える Ctrl+A または Ctrl+I | 何も変更せず、エラーを表示します。 | +| `Maximum` より多い `DefaultValues` | すべて選択された状態で始まります。十分に選択を外すまで Enter は拒否されます。 | +| `Minimum` 未満または `Maximum` 超過での Enter | エラーを表示して拒否します。 | + +Ctrl+A と Ctrl+I は、現在のフィルターに一致する項目だけに作用します。フィルターで隠れている選択済みの項目は、選択されたままです。 + +`Minimum` が負の値、`Maximum` が `Minimum` より小さい、または `Minimum` が項目数より大きい場合は、プロンプトに回答できないため、表示する前に `ArgumentOutOfRangeException` をスローします。`Items` が空の場合は `Minimum` を `0` にしてください。 + ## Enum 型の使用 ```csharp diff --git a/docs/ja/prompt-types/select.md b/docs/ja/prompt-types/select.md index f3ab0543..b8f3ab3f 100644 --- a/docs/ja/prompt-types/select.md +++ b/docs/ja/prompt-types/select.md @@ -50,6 +50,13 @@ var city = Prompt.Select("Select your city", Console.WriteLine($"Hello, {city}!"); ``` +## 項目のルール + +- `Items` はプロンプトの開始時に 1 回だけ読み取ります。等しい項目(`EqualityComparer.Default` で比較)は、最初の位置に 1 つだけ表示します。 +- `DefaultValue` は、プロンプトの開始時に一致する項目を選択します。`Items` に含まれない場合は何も選択しません。互換性のある型の値(`long` のプロンプトに対する `int` など)は変換され、互換性のない型は `ArgumentException` をスローします。 +- 何も選択されていない状態で Enter を押すとエラーを表示します。フィルターで項目が 1 つに絞られた場合や、フィルターが項目のテキストと完全に一致する場合は、その項目が選択されます。 +- `Items` が空の場合は、何も選択できないため、プロンプトを表示する前に `ArgumentException` をスローします。 + ## Enum 型の使用 Enum 型を使用すると、項目が自動的に生成されます: diff --git a/src/Sharprompt/Forms/MultiSelectForm.cs b/src/Sharprompt/Forms/MultiSelectForm.cs index 17dd221d..c126fb6b 100644 --- a/src/Sharprompt/Forms/MultiSelectForm.cs +++ b/src/Sharprompt/Forms/MultiSelectForm.cs @@ -10,23 +10,25 @@ namespace Sharprompt.Forms; internal class MultiSelectForm : SelectFormBase> where T : notnull { - public MultiSelectForm(MultiSelectOptions options, PromptConfiguration configuration) : base(configuration) + public MultiSelectForm(MultiSelectOptions options, PromptConfiguration configuration) : this(options, Prepare(options), configuration) { - options.EnsureOptions(); + } + private MultiSelectForm(MultiSelectOptions options, T[] items, PromptConfiguration configuration) : base(configuration) + { _options = options; - _items = options.Items.ToArray(); + _items = items; InitializePaginator(_items, options.PageSize, Optional.Empty, options.TextSelector, options.LoopingSelection); // Default values that are not in Items can never be returned, so ignore them // instead of letting them count towards Minimum. - var items = new HashSet(_items); + var itemSet = new HashSet(_items); foreach (var defaultValue in options.DefaultValues) { - if (items.Contains(defaultValue)) + if (itemSet.Contains(defaultValue)) { _selectedItems.Add(defaultValue); } @@ -40,6 +42,24 @@ public MultiSelectForm(MultiSelectOptions options, PromptConfiguration config private readonly MultiSelectOptions _options; private readonly T[] _items; + // Runs before the base constructor creates the console driver, so invalid options throw without leaving it undisposed. + private static T[] Prepare(MultiSelectOptions options) + { + options.EnsureOptions(); + + // Items are enumerated once, and rendering and the result both use this snapshot. Equal items are listed + // once, at the position of the first; otherwise checking one would check all of them and return each. + var items = options.Items.Distinct().ToArray(); + + // Enter would always be rejected, so the prompt could only be canceled. + if (options.Minimum > items.Length) + { + throw new ArgumentOutOfRangeException(nameof(options.Minimum), string.Format(Resource.Validation_Minimum_ExceedsItems, options.Minimum, items.Length)); + } + + return items; + } + private readonly HashSet _selectedItems = []; protected override void InputTemplate(OffscreenBuffer offscreenBuffer) diff --git a/src/Sharprompt/Forms/SelectForm.cs b/src/Sharprompt/Forms/SelectForm.cs index 0ef64408..55c69fd6 100644 --- a/src/Sharprompt/Forms/SelectForm.cs +++ b/src/Sharprompt/Forms/SelectForm.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Sharprompt.Internal; using Sharprompt.Strings; @@ -9,17 +10,36 @@ namespace Sharprompt.Forms; internal class SelectForm : SelectFormBase where T : notnull { - public SelectForm(SelectOptions options, PromptConfiguration configuration) : base(configuration) + public SelectForm(SelectOptions options, PromptConfiguration configuration) : this(options, Prepare(options), configuration) { - options.EnsureOptions(); + } + private SelectForm(SelectOptions options, (T[] Items, Optional DefaultValue) prepared, PromptConfiguration configuration) : base(configuration) + { _options = options; - InitializePaginator(options.Items, options.PageSize, Optional.Create(options.DefaultValue, nameof(options.DefaultValue)), options.TextSelector, options.LoopingSelection); + InitializePaginator(prepared.Items, options.PageSize, prepared.DefaultValue, options.TextSelector, options.LoopingSelection); } private readonly SelectOptions _options; + // Runs before the base constructor creates the console driver, so invalid options throw without leaving it undisposed. + private static (T[] Items, Optional DefaultValue) Prepare(SelectOptions options) + { + options.EnsureOptions(); + + // Items are enumerated once; equal items are listed once, at the position of the first. + var items = options.Items.Distinct().ToArray(); + + // Nothing could be selected, so the prompt could only be canceled. + if (items.Length == 0) + { + throw new ArgumentException(Resource.Validation_Items_Empty, nameof(options.Items)); + } + + return (items, Optional.Create(options.DefaultValue, nameof(options.DefaultValue))); + } + protected override void InputTemplate(OffscreenBuffer offscreenBuffer) { Paginator.UpdatePageSize(Math.Min(_options.PageSize, Height - 2)); diff --git a/src/Sharprompt/Strings/Resource.Designer.cs b/src/Sharprompt/Strings/Resource.Designer.cs index 091ebec2..afce1ca9 100644 --- a/src/Sharprompt/Strings/Resource.Designer.cs +++ b/src/Sharprompt/Strings/Resource.Designer.cs @@ -176,6 +176,24 @@ internal static string Validation_Minimum_OutOfRange { return ResourceManager.GetString("Validation_Minimum_OutOfRange", resourceCulture); } } + + /// + /// The minimum ({0}) exceeds the number of items ({1}) に類似しているローカライズされた文字列を検索します。 + /// + internal static string Validation_Minimum_ExceedsItems { + get { + return ResourceManager.GetString("Validation_Minimum_ExceedsItems", resourceCulture); + } + } + + /// + /// There must be at least one item to select に類似しているローカライズされた文字列を検索します。 + /// + internal static string Validation_Items_Empty { + get { + return ResourceManager.GetString("Validation_Items_Empty", resourceCulture); + } + } /// /// A minimum selection of {0} items is required に類似しているローカライズされた文字列を検索します。 diff --git a/src/Sharprompt/Strings/Resource.de-DE.resx b/src/Sharprompt/Strings/Resource.de-DE.resx index 10bb1ace..0962db21 100644 --- a/src/Sharprompt/Strings/Resource.de-DE.resx +++ b/src/Sharprompt/Strings/Resource.de-DE.resx @@ -177,4 +177,10 @@ Nein + + Es muss mindestens ein Element zur Auswahl vorhanden sein + + + Das Minimum ({0}) übersteigt die Anzahl der Elemente ({1}) + \ No newline at end of file diff --git a/src/Sharprompt/Strings/Resource.ja.resx b/src/Sharprompt/Strings/Resource.ja.resx index 34bbcfed..9a7cfc7e 100644 --- a/src/Sharprompt/Strings/Resource.ja.resx +++ b/src/Sharprompt/Strings/Resource.ja.resx @@ -163,7 +163,7 @@ {0} 個以上の入力が必須です - 最小値 ({1}) は不正です + 最小値 ({0}) は不正です {0} 個以上の選択が必須です @@ -171,4 +171,10 @@ 引数を持つメソッドは指定できません + + 選択できる項目が 1 つ以上必要です + + + 最小値 ({0}) が項目数 ({1}) を超えています + \ No newline at end of file diff --git a/src/Sharprompt/Strings/Resource.resx b/src/Sharprompt/Strings/Resource.resx index dd070147..59754718 100644 --- a/src/Sharprompt/Strings/Resource.resx +++ b/src/Sharprompt/Strings/Resource.resx @@ -177,4 +177,10 @@ No + + There must be at least one item to select + + + The minimum ({0}) exceeds the number of items ({1}) + \ No newline at end of file diff --git a/src/Sharprompt/Strings/Resource.zh-Hans.resx b/src/Sharprompt/Strings/Resource.zh-Hans.resx index 792bc459..7247fcac 100644 --- a/src/Sharprompt/Strings/Resource.zh-Hans.resx +++ b/src/Sharprompt/Strings/Resource.zh-Hans.resx @@ -158,4 +158,10 @@ No + + 至少需要一个可选择的项目 + + + 最小值 ({0}) 超过了项目数 ({1}) + diff --git a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs index 8f9a7b66..a8243e01 100644 --- a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs +++ b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs @@ -291,25 +291,155 @@ public void MultiSelectForm_CtrlI_ExceedingMaximum_KeepsSelection() } [Fact] - public void SelectForm_EmptyItems_RendersWithoutThrowing() + public void SelectForm_EmptyItems_ThrowsBeforeCreatingConsoleDriver() + { + var configuration = CreateConfigurationThatMustNotCreateDriver(); + + var options = new SelectOptions + { + Message = "message", + Items = [] + }; + + // Nothing could be selected, so the prompt could only be canceled. + var exception = Assert.Throws(() => new SelectForm(options, configuration)); + + Assert.Equal("Items", exception.ParamName); + } + + [Theory] + [InlineData(new[] { "apple", "banana" }, 3)] + [InlineData(new[] { "apple", "apple" }, 2)] + [InlineData(new string[0], 1)] + public void MultiSelectForm_MinimumExceedingItems_ThrowsBeforeCreatingConsoleDriver(string[] items, int minimum) + { + var configuration = CreateConfigurationThatMustNotCreateDriver(); + + var options = new MultiSelectOptions + { + Message = "message", + Items = items, + Minimum = minimum + }; + + // Enter would always be rejected. Duplicates count once, since they are listed once. + var exception = Assert.Throws(() => new MultiSelectForm(options, configuration)); + + Assert.Equal("Minimum", exception.ParamName); + } + + [Fact] + public void MultiSelectForm_InvalidOptions_ThrowBeforeCreatingConsoleDriver() + { + var configuration = CreateConfigurationThatMustNotCreateDriver(); + + var options = new MultiSelectOptions + { + Message = "message", + Items = ["apple"], + Minimum = -1 + }; + + Assert.Throws(() => new MultiSelectForm(options, configuration)); + } + + [Theory] + [InlineData("ja-JP")] + [InlineData("de-DE")] + [InlineData("zh-Hans")] + public void MultiSelectForm_LimitErrors_FormatInEveryLanguage(string cultureName) + { + var previousCulture = Resource.Culture; + + try + { + Resource.Culture = new CultureInfo(cultureName); + + var configuration = CreateConfigurationThatMustNotCreateDriver(); + + // The Japanese message for an invalid minimum used {1} with a single argument, which threw FormatException. + Assert.Throws(() => new MultiSelectForm(new MultiSelectOptions { Message = "message", Items = ["apple"], Minimum = -1 }, configuration)); + Assert.Throws(() => new MultiSelectForm(new MultiSelectOptions { Message = "message", Items = ["apple"], Minimum = 2, Maximum = 1 }, configuration)); + Assert.Throws(() => new MultiSelectForm(new MultiSelectOptions { Message = "message", Items = ["apple"], Minimum = 2 }, configuration)); + Assert.Throws(() => new SelectForm(new SelectOptions { Message = "message", Items = [] }, configuration)); + } + finally + { + Resource.Culture = previousCulture; + } + } + + [Fact] + public void SelectForm_DuplicateItems_AreListedOnce() { var (driver, configuration) = CreateTestContext(); driver.EnqueueKey(ConsoleKey.DownArrow); - driver.EnqueueKey(ConsoleKey.UpArrow); + driver.EnqueueKey(ConsoleKey.DownArrow); driver.EnqueueEnter(); - driver.EnqueueKey(ConsoleKey.Escape); var options = new SelectOptions { Message = "message", - Items = [] + Items = ["apple", "apple", "banana"] }; using var form = new SelectForm(options, configuration); - // Nothing can be selected, so Enter keeps prompting until the user cancels. - Assert.Throws(() => form.Start()); + Assert.Equal("banana", form.Start()); + } + + [Fact] + public void MultiSelectForm_DuplicateItems_AreListedAndReturnedOnce() + { + var (driver, configuration) = CreateTestContext(); + + driver.EnqueueKey(ConsoleKey.DownArrow); + driver.EnqueueKey(ConsoleKey.Spacebar, keyChar: ' '); + driver.EnqueueKey(ConsoleKey.DownArrow); + driver.EnqueueKey(ConsoleKey.Spacebar, keyChar: ' '); + driver.EnqueueEnter(); + + var options = new MultiSelectOptions + { + Message = "message", + Items = ["apple", "banana", "apple"], + Maximum = 2 + }; + + using var form = new MultiSelectForm(options, configuration); + + // Checking "apple" once used to return it twice, which exceeded Maximum. + Assert.Equal(new[] { "apple", "banana" }, form.Start()); + } + + [Fact] + public void MultiSelectForm_DuplicateItemsWithCtrlA_RespectMaximum() + { + var (driver, configuration) = CreateTestContext(); + + driver.EnqueueKey(ConsoleKey.A, ConsoleModifiers.Control, '\x01'); + driver.EnqueueEnter(); + + var options = new MultiSelectOptions + { + Message = "message", + Items = ["apple", "apple"], + Maximum = 1 + }; + + using var form = new MultiSelectForm(options, configuration); + + Assert.Equal(new[] { "apple" }, form.Start()); + } + + private static PromptConfiguration CreateConfigurationThatMustNotCreateDriver() + { + return new PromptConfiguration + { + ThrowExceptionOnCancel = true, + ConsoleDriverFactory = () => throw new InvalidOperationException("The console driver must not be created for invalid options.") + }; } [Fact] From 9311dac19c73a015add6e9017c7a2c1b448ac2d4 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 21 Oct 2026 16:35:45 +0900 Subject: [PATCH 2/2] Clarify in the Select docs that a single listed item is always selected An unmatched DefaultValue does not select anything, but when only one item is listed, with or without a filter, that item is selected, so Enter returns it. Co-Authored-By: Claude Opus 5.5 --- docs/en/prompt-types/select.md | 4 ++-- docs/ja/prompt-types/select.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/en/prompt-types/select.md b/docs/en/prompt-types/select.md index c99d1374..4b463cba 100644 --- a/docs/en/prompt-types/select.md +++ b/docs/en/prompt-types/select.md @@ -53,8 +53,8 @@ Console.WriteLine($"Hello, {city}!"); ## Item Rules - `Items` is read once, when the prompt starts. Equal items (compared with `EqualityComparer.Default`) are listed once, at the position of the first one. -- `DefaultValue` selects the matching item when the prompt starts. If it is not in `Items`, nothing is selected. A value of a compatible type, such as an `int` for a `long` prompt, is converted; an incompatible one throws `ArgumentException`. -- Enter with nothing selected shows an error. When the filter leaves a single item, or exactly matches an item's text, that item is selected. +- `DefaultValue` selects the matching item when the prompt starts. If it is not in `Items`, it does not select anything. A value of a compatible type, such as an `int` for a `long` prompt, is converted; an incompatible one throws `ArgumentException`. +- Enter with nothing selected shows an error. When only one item is listed, with or without a filter, or when the filter exactly matches an item's text, that item is selected. - An empty `Items` throws `ArgumentException` before the prompt is shown, because nothing could be selected. ## With Enum Type diff --git a/docs/ja/prompt-types/select.md b/docs/ja/prompt-types/select.md index b8f3ab3f..3fb58cb3 100644 --- a/docs/ja/prompt-types/select.md +++ b/docs/ja/prompt-types/select.md @@ -53,8 +53,8 @@ Console.WriteLine($"Hello, {city}!"); ## 項目のルール - `Items` はプロンプトの開始時に 1 回だけ読み取ります。等しい項目(`EqualityComparer.Default` で比較)は、最初の位置に 1 つだけ表示します。 -- `DefaultValue` は、プロンプトの開始時に一致する項目を選択します。`Items` に含まれない場合は何も選択しません。互換性のある型の値(`long` のプロンプトに対する `int` など)は変換され、互換性のない型は `ArgumentException` をスローします。 -- 何も選択されていない状態で Enter を押すとエラーを表示します。フィルターで項目が 1 つに絞られた場合や、フィルターが項目のテキストと完全に一致する場合は、その項目が選択されます。 +- `DefaultValue` は、プロンプトの開始時に一致する項目を選択します。`Items` に含まれない場合、`DefaultValue` は何も選択しません。互換性のある型の値(`long` のプロンプトに対する `int` など)は変換され、互換性のない型は `ArgumentException` をスローします。 +- 何も選択されていない状態で Enter を押すとエラーを表示します。表示される項目が 1 つだけの場合(フィルターの有無にかかわらず)や、フィルターが項目のテキストと完全に一致する場合は、その項目が選択されます。 - `Items` が空の場合は、何も選択できないため、プロンプトを表示する前に `ArgumentException` をスローします。 ## Enum 型の使用