diff --git a/docs/en/prompt-types/multi-select.md b/docs/en/prompt-types/multi-select.md index 42223ed..444c9d0 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 5f7f97b..4b463cb 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`, 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 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 a6c635d..24338cc 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 f3ab054..3fb58cb 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` に含まれない場合、`DefaultValue` は何も選択しません。互換性のある型の値(`long` のプロンプトに対する `int` など)は変換され、互換性のない型は `ArgumentException` をスローします。 +- 何も選択されていない状態で Enter を押すとエラーを表示します。表示される項目が 1 つだけの場合(フィルターの有無にかかわらず)や、フィルターが項目のテキストと完全に一致する場合は、その項目が選択されます。 +- `Items` が空の場合は、何も選択できないため、プロンプトを表示する前に `ArgumentException` をスローします。 + ## Enum 型の使用 Enum 型を使用すると、項目が自動的に生成されます: diff --git a/src/Sharprompt/Forms/MultiSelectForm.cs b/src/Sharprompt/Forms/MultiSelectForm.cs index 17dd221..c126fb6 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 0ef6440..55c69fd 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 091ebec..afce1ca 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 10bb1ac..0962db2 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 34bbcfe..9a7cfc7 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 dd07014..5975471 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 792bc45..7247fca 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 8f9a7b6..a8243e0 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]