@pi-in-go/pigpen-ask-user-question 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CREDITS.md +17 -0
  2. package/LICENSE +22 -0
  3. package/README.md +27 -2
  4. package/extensions/rpiv-ask-user-question/config.go +189 -0
  5. package/extensions/rpiv-ask-user-question/config_test.go +142 -0
  6. package/extensions/rpiv-ask-user-question/copy.go +34 -0
  7. package/extensions/rpiv-ask-user-question/envelope_test.go +177 -0
  8. package/extensions/rpiv-ask-user-question/execute_test.go +119 -0
  9. package/extensions/rpiv-ask-user-question/export_test.go +8 -0
  10. package/extensions/rpiv-ask-user-question/extension.go +125 -0
  11. package/extensions/rpiv-ask-user-question/fakehost_test.go +548 -0
  12. package/extensions/rpiv-ask-user-question/go.mod +6 -0
  13. package/extensions/rpiv-ask-user-question/go.sum +2 -0
  14. package/extensions/rpiv-ask-user-question/guidance_test.go +126 -0
  15. package/extensions/rpiv-ask-user-question/helpers_test.go +70 -0
  16. package/extensions/rpiv-ask-user-question/items_test.go +38 -0
  17. package/extensions/rpiv-ask-user-question/jsutil.go +18 -0
  18. package/extensions/rpiv-ask-user-question/logic.go +185 -0
  19. package/extensions/rpiv-ask-user-question/normalize_test.go +64 -0
  20. package/extensions/rpiv-ask-user-question/normalize_tool_test.go +50 -0
  21. package/extensions/rpiv-ask-user-question/parallel_test.go +90 -0
  22. package/extensions/rpiv-ask-user-question/params.go +80 -0
  23. package/extensions/rpiv-ask-user-question/reconcile_test.go +117 -0
  24. package/extensions/rpiv-ask-user-question/rig_test.go +230 -0
  25. package/extensions/rpiv-ask-user-question/rpc.go +203 -0
  26. package/extensions/rpiv-ask-user-question/rpc_test.go +210 -0
  27. package/extensions/rpiv-ask-user-question/schema.go +58 -0
  28. package/extensions/rpiv-ask-user-question/skips_test.go +29 -0
  29. package/extensions/rpiv-ask-user-question/tooldef_test.go +68 -0
  30. package/extensions/rpiv-ask-user-question/twin_ext_test.go +28 -0
  31. package/extensions/rpiv-ask-user-question/twin_test.go +28 -0
  32. package/extensions/rpiv-ask-user-question/types.go +114 -0
  33. package/extensions/rpiv-ask-user-question/types_test.go +288 -0
  34. package/extensions/rpiv-ask-user-question/validate_test.go +71 -0
  35. package/package.json +37 -4
  36. package/port/PORT.md +102 -0
  37. package/port/demo/pane-answered.txt +36 -0
  38. package/port/demo/pane-input.txt +36 -0
  39. package/port/demo/pane-select.txt +36 -0
  40. package/port/demo/turns.json +4 -0
  41. package/port/gen-mutations.py +119 -0
  42. package/port/gen-scenarios.py +118 -0
  43. package/port/golden/guidance-in-request.jsonl +11 -0
  44. package/port/golden/line-terminators.jsonl +19 -0
  45. package/port/golden/mixed-questions.jsonl +21 -0
  46. package/port/golden/multi-select-dismissed.jsonl +19 -0
  47. package/port/golden/multi-select-numbers.jsonl +19 -0
  48. package/port/golden/multi-select-variants.jsonl +22 -0
  49. package/port/golden/other-input-dismissed.jsonl +20 -0
  50. package/port/golden/partial-then-dismissed.jsonl +20 -0
  51. package/port/golden/previews.jsonl +19 -0
  52. package/port/golden/reserved-and-duplicates.jsonl +46 -0
  53. package/port/golden/schema-rejections.jsonl +60 -0
  54. package/port/golden/single-dismissed.jsonl +19 -0
  55. package/port/golden/single-other-typed.jsonl +20 -0
  56. package/port/golden/single-select.jsonl +19 -0
  57. package/port/golden/two-calls.jsonl +23 -0
  58. package/port/golden/unlisted-choice.jsonl +19 -0
  59. package/port/mutations.json +524 -0
  60. package/port/oracle/CHANGELOG.md +489 -0
  61. package/port/oracle/LICENSE +21 -0
  62. package/port/oracle/README.md +94 -0
  63. package/port/oracle/UPSTREAM.md +8 -0
  64. package/port/oracle/ask-user-question.execute.test.ts +525 -0
  65. package/port/oracle/ask-user-question.guidance.test.ts +142 -0
  66. package/port/oracle/ask-user-question.listener.test.ts +286 -0
  67. package/port/oracle/ask-user-question.normalize.test.ts +147 -0
  68. package/port/oracle/ask-user-question.session-load.test.ts +123 -0
  69. package/port/oracle/ask-user-question.test.ts +703 -0
  70. package/port/oracle/ask-user-question.ts +412 -0
  71. package/port/oracle/banned-flags.test.ts +39 -0
  72. package/port/oracle/config.test.ts +119 -0
  73. package/port/oracle/config.ts +100 -0
  74. package/port/oracle/docs/configuration.md +121 -0
  75. package/port/oracle/docs/hosts.md +97 -0
  76. package/port/oracle/docs/keyboard.md +111 -0
  77. package/port/oracle/docs/localization.md +87 -0
  78. package/port/oracle/docs/tool-schema.md +127 -0
  79. package/port/oracle/events.ts +59 -0
  80. package/port/oracle/factory.test.ts +1103 -0
  81. package/port/oracle/index.ts +54 -0
  82. package/port/oracle/locales/de.json +31 -0
  83. package/port/oracle/locales/en.json +34 -0
  84. package/port/oracle/locales/es.json +31 -0
  85. package/port/oracle/locales/fr.json +31 -0
  86. package/port/oracle/locales/pt-BR.json +31 -0
  87. package/port/oracle/locales/pt.json +31 -0
  88. package/port/oracle/locales/ru.json +31 -0
  89. package/port/oracle/locales/uk.json +31 -0
  90. package/port/oracle/locales/zh.json +33 -0
  91. package/port/oracle/locales.test.ts +36 -0
  92. package/port/oracle/package-lock.json +42 -0
  93. package/port/oracle/package.json +101 -0
  94. package/port/oracle/reconcile.test.ts +155 -0
  95. package/port/oracle/reconcile.ts +47 -0
  96. package/port/oracle/rpc-fallback.test.ts +273 -0
  97. package/port/oracle/rpc-fallback.ts +166 -0
  98. package/port/oracle/ship-manifest.test.ts +8 -0
  99. package/port/oracle/state/build-questionnaire.ts +333 -0
  100. package/port/oracle/state/external-editor.test.ts +48 -0
  101. package/port/oracle/state/external-editor.ts +62 -0
  102. package/port/oracle/state/i18n-bridge.shim.test.ts +101 -0
  103. package/port/oracle/state/i18n-bridge.ts +51 -0
  104. package/port/oracle/state/key-router.test.ts +986 -0
  105. package/port/oracle/state/key-router.ts +353 -0
  106. package/port/oracle/state/questionnaire-session.test.ts +314 -0
  107. package/port/oracle/state/questionnaire-session.ts +285 -0
  108. package/port/oracle/state/questionnaire-state.test.ts +110 -0
  109. package/port/oracle/state/row-intent.test.ts +104 -0
  110. package/port/oracle/state/row-intent.ts +143 -0
  111. package/port/oracle/state/selectors/contract.ts +24 -0
  112. package/port/oracle/state/selectors/derivations.ts +40 -0
  113. package/port/oracle/state/selectors/focus.ts +17 -0
  114. package/port/oracle/state/selectors/projections.ts +106 -0
  115. package/port/oracle/state/state-reducer.test.ts +577 -0
  116. package/port/oracle/state/state-reducer.ts +341 -0
  117. package/port/oracle/state/state.ts +65 -0
  118. package/port/oracle/test-fixtures.ts +170 -0
  119. package/port/oracle/tool/format-answer.ts +29 -0
  120. package/port/oracle/tool/normalize-params.test.ts +77 -0
  121. package/port/oracle/tool/normalize-params.ts +56 -0
  122. package/port/oracle/tool/response-envelope.ts +58 -0
  123. package/port/oracle/tool/types.test.ts +298 -0
  124. package/port/oracle/tool/types.ts +156 -0
  125. package/port/oracle/tool/validate-questionnaire.ts +56 -0
  126. package/port/oracle/view/component-binding.ts +45 -0
  127. package/port/oracle/view/components/inline-input.test.ts +42 -0
  128. package/port/oracle/view/components/inline-input.ts +66 -0
  129. package/port/oracle/view/components/multi-select-view.test.ts +476 -0
  130. package/port/oracle/view/components/multi-select-view.ts +191 -0
  131. package/port/oracle/view/components/option-list-view.test.ts +97 -0
  132. package/port/oracle/view/components/option-list-view.ts +68 -0
  133. package/port/oracle/view/components/preview/markdown-content-cache.ts +77 -0
  134. package/port/oracle/view/components/preview/preview-block-renderer.test.ts +147 -0
  135. package/port/oracle/view/components/preview/preview-block-renderer.ts +118 -0
  136. package/port/oracle/view/components/preview/preview-box-renderer.ts +86 -0
  137. package/port/oracle/view/components/preview/preview-layout-decider.test.ts +245 -0
  138. package/port/oracle/view/components/preview/preview-layout-decider.ts +207 -0
  139. package/port/oracle/view/components/preview/preview-pane.test.ts +970 -0
  140. package/port/oracle/view/components/preview/preview-pane.ts +233 -0
  141. package/port/oracle/view/components/submit-picker.test.ts +70 -0
  142. package/port/oracle/view/components/submit-picker.ts +65 -0
  143. package/port/oracle/view/components/tab-bar.test.ts +130 -0
  144. package/port/oracle/view/components/tab-bar.ts +56 -0
  145. package/port/oracle/view/components/wrapping-select.test.ts +659 -0
  146. package/port/oracle/view/components/wrapping-select.ts +297 -0
  147. package/port/oracle/view/dialog-builder.test.ts +25 -0
  148. package/port/oracle/view/dialog-builder.ts +272 -0
  149. package/port/oracle/view/dialog-container.test.ts +711 -0
  150. package/port/oracle/view/dialog-overflow.test.ts +601 -0
  151. package/port/oracle/view/props-adapter.test.ts +210 -0
  152. package/port/oracle/view/props-adapter.ts +117 -0
  153. package/port/oracle/view/stateful-view.ts +24 -0
  154. package/port/oracle/view/tab-components.ts +16 -0
  155. package/port/oracle/view/tab-content-strategy.ts +316 -0
  156. package/port/red-run.log +183 -0
  157. package/port/scenarios/guidance-in-request.json +18 -0
  158. package/port/scenarios/line-terminators.json +48 -0
  159. package/port/scenarios/mixed-questions.json +87 -0
  160. package/port/scenarios/multi-select-dismissed.json +49 -0
  161. package/port/scenarios/multi-select-numbers.json +57 -0
  162. package/port/scenarios/multi-select-variants.json +103 -0
  163. package/port/scenarios/other-input-dismissed.json +55 -0
  164. package/port/scenarios/partial-then-dismissed.json +69 -0
  165. package/port/scenarios/previews.json +54 -0
  166. package/port/scenarios/reserved-and-duplicates.json +157 -0
  167. package/port/scenarios/schema-rejections.json +225 -0
  168. package/port/scenarios/single-dismissed.json +52 -0
  169. package/port/scenarios/single-other-typed.json +55 -0
  170. package/port/scenarios/single-select.json +52 -0
  171. package/port/scenarios/two-calls.json +72 -0
  172. package/port/scenarios/unlisted-choice.json +52 -0
  173. package/port/slices.json +24 -0
  174. package/port/upstream-tests.json +719 -0
  175. package/provenance.json +22 -0
package/CREDITS.md ADDED
@@ -0,0 +1,17 @@
1
+ # Credits
2
+
3
+ `extensions/rpiv-ask-user-question` is a Go port of **@juicesharp/rpiv-ask-user-question** 2.11.0 from **rpiv-mono**,
4
+ https://github.com/juicesharp/rpiv-mono, by **juicesharp** (MIT, Copyright (c) 2026 juicesharp).
5
+
6
+ - Original: `packages/rpiv-ask-user-question`
7
+ - Commit: `61904e69e1a50e12585bdf15f0310e633a62ba36` (release v2.11.0)
8
+ - The unmodified original (its tests included, images omitted) is kept at
9
+ [`port/oracle/`](port/oracle) as the equivalence oracle, with the license at
10
+ [`port/oracle/LICENSE`](port/oracle/LICENSE). `@juicesharp/rpiv-config` 2.12.0 (MIT, juicesharp), the
11
+ original's configuration library, is installed from npm into `port/oracle/node_modules` and is not
12
+ copied; the port's `config.go` is the todo port's, which was written against its `config.ts` at the pinned commit.
13
+
14
+ The Go code, the scenarios and the tests were written for this Package by Michael Kinsy. Wording, tool schema,
15
+ prompt guidance and behavior follow the original. Modified paths: none of the original is modified; the port is a
16
+ separate implementation. Not ported: the locales (E1, English only: approved by the owner), the interactive questionnaire component and the overlay
17
+ collapse key (see [port/PORT.md](port/PORT.md)).
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 juicesharp (the original, @juicesharp/rpiv-ask-user-question)
4
+ Copyright (c) 2026 Michael Kinsy (the Go port)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # rpiv-ask-user-question (Go port, partial)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Go extension for PiG: the `ask_user_question` tool, which lets the model put up to four structured questions
4
+ (2-4 options each, single or multiple choice, an optional preview per option) to you when it would otherwise guess.
5
+ Every question gets a "Type something." row for a free-text answer; dismissing a dialog declines the questionnaire.
6
+
7
+ It is a port of `@juicesharp/rpiv-ask-user-question` 2.11.0 (see [CREDITS.md](CREDITS.md)), made with the
8
+ [extension porting Skill](../extension-port/README.md). It needs no Node runtime.
9
+
10
+ ## What is ported
11
+
12
+ This is **slice A**: the tool and everything the model sees and the equivalence checks can reach.
13
+
14
+ - The tool, its JSON Schema (limits enforced by the host), its description and prompt guidance, and the config
15
+ overrides (`~/.config/rpiv-ask-user-question/config.json`: `guidance.description`, `guidance.promptSnippet`,
16
+ `guidance.promptGuidelines`).
17
+ - Line-terminator normalisation, the validation (reserved and duplicate labels, option counts), the answer envelope
18
+ and decline text, the `rpiv:ask-user:prompt` and `rpiv:ask-user:blocked` events.
19
+ - The sequential dialog walker (one select or input dialog per question), which is what the original runs on RPC hosts.
20
+ **In a terminal this port runs the same dialogs**, where the original shows its tabbed questionnaire.
21
+ - Removing the tool from the active set on hosts without a UI (`before_agent_start`).
22
+
23
+ **Not ported yet (slice B):** the interactive questionnaire (tabs, per-option notes, submit tab, side-by-side preview
24
+ pane, external editor, collapse key), the locales, and the terminal bell. See [port/PORT.md](port/PORT.md).
25
+
26
+ ```sh
27
+ pig install ./components/ask-user-question
28
+ ```
@@ -0,0 +1,189 @@
1
+ package ask_user_question
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ )
9
+
10
+ // Configuration: <config home>/rpiv-ask-user-question/config.json, read fresh on every call (no caching, no /reload).
11
+ // upstream: config.ts, and rpiv-config config.ts (loadJsonConfigWithLegacyFallback) at the same commit.
12
+ const (
13
+ // defaultCollapseKey is the collapse and expand shortcut when collapseKey is missing or invalid.
14
+ defaultCollapseKey = "ctrl+]"
15
+ // collapseKeyOff disables the collapse shortcut entirely.
16
+ collapseKeyOff = "off"
17
+ configName = "rpiv-ask-user-question"
18
+ )
19
+
20
+ type guidanceFields struct {
21
+ PromptSnippet string
22
+ PromptGuidelines []string
23
+ Description string
24
+ }
25
+
26
+ // askConfig is the file's content. The loader does not validate: each resolver decides.
27
+ type askConfig struct {
28
+ Guidance any
29
+ CollapseKey any
30
+ }
31
+
32
+ func homeDir() string {
33
+ home, _ := os.UserHomeDir()
34
+ return home
35
+ }
36
+
37
+ func defaultConfigDir() string { return filepath.Join(homeDir(), ".config") }
38
+
39
+ // resolveConfigDir is $XDG_CONFIG_HOME when it is set and absolute (a leading ~ is expanded), else ~/.config.
40
+ func resolveConfigDir() string {
41
+ xdg := jsTrim(os.Getenv("XDG_CONFIG_HOME"))
42
+ if xdg == "" {
43
+ return defaultConfigDir()
44
+ }
45
+ switch {
46
+ case xdg == "~":
47
+ xdg = homeDir()
48
+ case strings.HasPrefix(xdg, "~/"):
49
+ xdg = filepath.Join(homeDir(), xdg[2:])
50
+ }
51
+ if filepath.IsAbs(xdg) {
52
+ return xdg
53
+ }
54
+ return defaultConfigDir()
55
+ }
56
+
57
+ // loadJSONConfig reads a JSON object; a missing, unreadable, invalid or non-object file reads as empty.
58
+ func loadJSONConfig(path string) map[string]any {
59
+ data, err := os.ReadFile(path)
60
+ if err != nil {
61
+ return map[string]any{}
62
+ }
63
+ var parsed any
64
+ if err := json.Unmarshal(data, &parsed); err != nil {
65
+ return map[string]any{}
66
+ }
67
+ m, ok := parsed.(map[string]any)
68
+ if !ok {
69
+ return map[string]any{}
70
+ }
71
+ return m
72
+ }
73
+
74
+ func fileExists(path string) bool {
75
+ _, err := os.Stat(path)
76
+ return err == nil
77
+ }
78
+
79
+ func loadConfig() askConfig {
80
+ xdgPath := filepath.Join(resolveConfigDir(), configName, "config.json")
81
+ m := loadJSONConfig(filepath.Join(defaultConfigDir(), configName, "config.json"))
82
+ if fileExists(xdgPath) {
83
+ m = loadJSONConfig(xdgPath)
84
+ }
85
+ return askConfig{Guidance: m["guidance"], CollapseKey: m["collapseKey"]}
86
+ }
87
+
88
+ var (
89
+ specialKeys = map[string]bool{
90
+ "escape": true, "esc": true, "enter": true, "return": true, "tab": true, "space": true, "backspace": true,
91
+ "delete": true, "insert": true, "clear": true, "home": true, "end": true, "pageup": true, "pagedown": true,
92
+ "up": true, "down": true, "left": true, "right": true,
93
+ "f1": true, "f2": true, "f3": true, "f4": true, "f5": true, "f6": true, "f7": true, "f8": true,
94
+ "f9": true, "f10": true, "f11": true, "f12": true,
95
+ }
96
+ modifierKeys = map[string]bool{"ctrl": true, "shift": true, "alt": true, "super": true}
97
+ )
98
+
99
+ const baseKeyChars = "abcdefghijklmnopqrstuvwxyz0123456789_-!@#$%^&*()|~`'\":;,./<>?[]{}=\\"
100
+
101
+ // isValidCollapseKeySpec validates a spec against pi-tui's KeyId grammar: zero or more distinct modifiers,
102
+ // then a base key that is one printable character or a named special key. A loose check is not enough: pi-tui
103
+ // takes the last `+` part as the key and ignores unknown parts, so a typo like `ctr+]` would match every bare
104
+ // `]` keypress. upstream: config.ts:78-93.
105
+ func isValidCollapseKeySpec(spec string) bool {
106
+ if spec == "" || strings.HasPrefix(spec, "+") || strings.HasSuffix(spec, "+") || strings.Contains(spec, "++") {
107
+ return false
108
+ }
109
+ parts := strings.Split(spec, "+")
110
+ base := parts[len(parts)-1]
111
+ modifiers := parts[:len(parts)-1]
112
+ seen := map[string]bool{}
113
+ for _, m := range modifiers {
114
+ if seen[m] || !modifierKeys[m] {
115
+ return false
116
+ }
117
+ seen[m] = true
118
+ }
119
+ if r := []rune(base); len(r) == 1 && r[0] < 0x10000 {
120
+ return strings.ContainsRune(baseKeyChars, r[0])
121
+ }
122
+ return specialKeys[base]
123
+ }
124
+
125
+ // resolveCollapseKey is the collapse key: the default when the field is missing, not a string, blank or
126
+ // invalid; the sentinel "off"; or the lowercased validated spec.
127
+ func resolveCollapseKey() string {
128
+ raw, ok := loadConfig().CollapseKey.(string)
129
+ if !ok {
130
+ return defaultCollapseKey
131
+ }
132
+ raw = strings.ToLower(jsTrim(raw))
133
+ switch {
134
+ case raw == "":
135
+ return defaultCollapseKey
136
+ case raw == collapseKeyOff:
137
+ return collapseKeyOff
138
+ case isValidCollapseKeySpec(raw):
139
+ return raw
140
+ }
141
+ return defaultCollapseKey
142
+ }
143
+
144
+ // validateGuidanceFields keeps the guidance fields that have the right type and are not empty.
145
+ // upstream: rpiv-config config.ts validateGuidanceFields.
146
+ func validateGuidanceFields(fields any) guidanceFields {
147
+ g, ok := fields.(map[string]any)
148
+ if !ok {
149
+ return guidanceFields{}
150
+ }
151
+ var out guidanceFields
152
+ if s, ok := g["promptSnippet"].(string); ok && s != "" {
153
+ out.PromptSnippet = s
154
+ }
155
+ if list, ok := g["promptGuidelines"].([]any); ok && len(list) > 0 {
156
+ lines := make([]string, 0, len(list))
157
+ for _, e := range list {
158
+ s, ok := e.(string)
159
+ if !ok || s == "" {
160
+ lines = nil
161
+ break
162
+ }
163
+ lines = append(lines, s)
164
+ }
165
+ out.PromptGuidelines = lines
166
+ }
167
+ if s, ok := g["description"].(string); ok && s != "" {
168
+ out.Description = s
169
+ }
170
+ return out
171
+ }
172
+
173
+ // formatKeySpecForDisplay renders a key spec for a hint: `ctrl+]` as `Ctrl+]`, `pageup` as `PageUp`.
174
+ // upstream: config.ts:formatKeySpecForDisplay.
175
+ func formatKeySpecForDisplay(spec string) string {
176
+ compound := map[string]string{"pageup": "PageUp", "pagedown": "PageDown"}
177
+ parts := strings.Split(spec, "+")
178
+ for i, p := range parts {
179
+ switch {
180
+ case compound[p] != "":
181
+ parts[i] = compound[p]
182
+ case len([]rune(p)) <= 1:
183
+ parts[i] = strings.ToUpper(p)
184
+ default:
185
+ parts[i] = strings.ToUpper(p[:1]) + p[1:]
186
+ }
187
+ }
188
+ return strings.Join(parts, "+")
189
+ }
@@ -0,0 +1,142 @@
1
+ package ask_user_question
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ const fConfig = "config"
10
+
11
+ // configHome points HOME at a temp dir (and clears XDG_CONFIG_HOME) and returns a writer for
12
+ // ~/.config/rpiv-ask-user-question/config.json (upstream: config.test.ts).
13
+ func configHome(t *testing.T) (write func(contents string)) {
14
+ t.Helper()
15
+ home := t.TempDir()
16
+ t.Setenv("HOME", home)
17
+ t.Setenv("USERPROFILE", home)
18
+ t.Setenv("XDG_CONFIG_HOME", "")
19
+ path := filepath.Join(home, ".config", "rpiv-ask-user-question", "config.json")
20
+ return func(contents string) {
21
+ t.Helper()
22
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ }
29
+ }
30
+
31
+ func TestFormatKeySpecForDisplay(t *testing.T) {
32
+ tw(t, fConfig, "capitalizes each +-part of a resolved spec", func(t *testing.T) {
33
+ eq(t, formatKeySpecForDisplay("ctrl+shift+t"), "Ctrl+Shift+T", "spec")
34
+ eq(t, formatKeySpecForDisplay("alt+o"), "Alt+O", "alt")
35
+ })
36
+ tw(t, fConfig, "handles named special keys and bare keys", func(t *testing.T) {
37
+ eq(t, formatKeySpecForDisplay("escape"), "Escape", "escape")
38
+ eq(t, formatKeySpecForDisplay("f5"), "F5", "f5")
39
+ eq(t, formatKeySpecForDisplay("]"), "]", "bare")
40
+ })
41
+ tw(t, fConfig, "cases compound-word named keys conventionally", func(t *testing.T) {
42
+ eq(t, formatKeySpecForDisplay("pageup"), "PageUp", "pageup")
43
+ eq(t, formatKeySpecForDisplay("ctrl+pagedown"), "Ctrl+PageDown", "pagedown")
44
+ })
45
+ tw(t, fConfig, "round-trips the default key to the historical hint casing", func(t *testing.T) {
46
+ eq(t, formatKeySpecForDisplay(defaultCollapseKey), "Ctrl+]", "default")
47
+ })
48
+ }
49
+
50
+ func TestResolveCollapseKey(t *testing.T) {
51
+ tw(t, fConfig, "returns the default when config has no collapseKey", func(t *testing.T) {
52
+ w := configHome(t)
53
+ eq(t, resolveCollapseKey(), "ctrl+]", "no file")
54
+ w(`{}`)
55
+ eq(t, resolveCollapseKey(), "ctrl+]", "empty config")
56
+ })
57
+ tw(t, fConfig, "returns the default when collapseKey is empty or whitespace", func(t *testing.T) {
58
+ w := configHome(t)
59
+ w(`{"collapseKey":""}`)
60
+ eq(t, resolveCollapseKey(), defaultCollapseKey, "empty")
61
+ w(`{"collapseKey":" "}`)
62
+ eq(t, resolveCollapseKey(), defaultCollapseKey, "blank")
63
+ })
64
+ tw(t, fConfig, "normalizes the spec (trim + lowercase)", func(t *testing.T) {
65
+ w := configHome(t)
66
+ w(`{"collapseKey":" Alt+O "}`)
67
+ eq(t, resolveCollapseKey(), "alt+o", "key")
68
+ })
69
+ tw(t, fConfig, "returns the off sentinel unchanged (case-insensitive)", func(t *testing.T) {
70
+ w := configHome(t)
71
+ for _, v := range []string{"off", "OFF", " Off "} {
72
+ w(`{"collapseKey":"` + v + `"}`)
73
+ eq(t, resolveCollapseKey(), collapseKeyOff, v)
74
+ }
75
+ })
76
+ tw(t, fConfig, "falls back to the default for malformed specs", func(t *testing.T) {
77
+ w := configHome(t)
78
+ for _, v := range []string{"+", "ctrl+", "+t", "ctrl++t", "ctrl+ctrl+t", ""} {
79
+ w(`{"collapseKey":"` + v + `"}`)
80
+ eq(t, resolveCollapseKey(), defaultCollapseKey, "spec "+v)
81
+ }
82
+ })
83
+ tw(t, fConfig, "falls back to the default for typo'd modifiers and unknown key names", func(t *testing.T) {
84
+ w := configHome(t)
85
+ for _, v := range []string{"ctr+]", "control+t", "ctrl+foo", "ctrl+f13", "meta+t"} {
86
+ w(`{"collapseKey":"` + v + `"}`)
87
+ eq(t, resolveCollapseKey(), defaultCollapseKey, "spec "+v)
88
+ }
89
+ })
90
+ tw(t, fConfig, "accepts named special keys and bare base keys", func(t *testing.T) {
91
+ w := configHome(t)
92
+ for _, v := range []string{"escape", "ctrl+pageup", "f12", "alt+o", "ctrl+]", "shift+tab", "super+k"} {
93
+ w(`{"collapseKey":"` + v + `"}`)
94
+ eq(t, resolveCollapseKey(), v, "spec "+v)
95
+ }
96
+ })
97
+ }
98
+
99
+ func TestLoadConfig(t *testing.T) {
100
+ tw(t, fConfig, "returns an empty config when no file is present", func(t *testing.T) {
101
+ configHome(t)
102
+ eq(t, loadConfig(), askConfig{}, "config")
103
+ })
104
+ tw(t, fConfig, "reads a valid JSON config", func(t *testing.T) {
105
+ w := configHome(t)
106
+ w(`{"collapseKey":"alt+o","guidance":{"promptSnippet":"s"}}`)
107
+ got := loadConfig()
108
+ eq(t, got.CollapseKey, "alt+o", "collapseKey")
109
+ eq(t, got.Guidance, map[string]any{"promptSnippet": "s"}, "guidance")
110
+ })
111
+ }
112
+
113
+ func TestConfigRobustness(t *testing.T) {
114
+ w := configHome(t)
115
+ for _, bad := range []string{`not json`, `[]`, `"x"`, `null`, ``} {
116
+ w(bad)
117
+ eq(t, loadConfig(), askConfig{}, "content "+bad)
118
+ }
119
+ // The default location is the legacy one; an XDG location wins when its file exists.
120
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
121
+ w(`{"collapseKey":"alt+o"}`)
122
+ eq(t, resolveCollapseKey(), "alt+o", "legacy location when XDG has no file")
123
+ xdg := os.Getenv("XDG_CONFIG_HOME")
124
+ if err := os.MkdirAll(filepath.Join(xdg, "rpiv-ask-user-question"), 0o755); err != nil {
125
+ t.Fatal(err)
126
+ }
127
+ if err := os.WriteFile(filepath.Join(xdg, "rpiv-ask-user-question", "config.json"), []byte(`{"collapseKey":"off"}`), 0o644); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ eq(t, resolveCollapseKey(), "off", "XDG file wins")
131
+ t.Setenv("XDG_CONFIG_HOME", "relative/dir")
132
+ eq(t, resolveCollapseKey(), "alt+o", "a relative XDG_CONFIG_HOME is ignored")
133
+ }
134
+
135
+ func TestValidateGuidanceFields(t *testing.T) {
136
+ eq(t, validateGuidanceFields(nil), guidanceFields{}, "nil")
137
+ eq(t, validateGuidanceFields("x"), guidanceFields{}, "string")
138
+ eq(t, validateGuidanceFields(map[string]any{"promptSnippet": "s", "promptGuidelines": []any{"a", "b"}, "description": "d"}),
139
+ guidanceFields{PromptSnippet: "s", PromptGuidelines: []string{"a", "b"}, Description: "d"}, "valid")
140
+ eq(t, validateGuidanceFields(map[string]any{"promptSnippet": "", "promptGuidelines": []any{"a", ""}, "description": 5.0}), guidanceFields{}, "invalid")
141
+ eq(t, validateGuidanceFields(map[string]any{"promptGuidelines": []any{}}), guidanceFields{}, "empty list")
142
+ }
@@ -0,0 +1,34 @@
1
+ package ask_user_question
2
+
3
+ // The tool's built-in model-facing copy. upstream: ask-user-question.ts DEFAULT_PROMPT_SNIPPET,
4
+ // DEFAULT_PROMPT_GUIDELINES and DEFAULT_TOOL_DESCRIPTION, with the limits (4 questions, 2-4 options) filled in.
5
+ var (
6
+ defaultPromptSnippet = "Ask the user up to 4 structured questions (2-4 options each) when requirements are ambiguous"
7
+
8
+ defaultPromptGuidelines = []string{
9
+ "Use ask_user_question whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to 4 questions per invocation.",
10
+ "Each question MUST have 2-4 options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended \"Type something.\" row on every question, or press Esc to abandon the questionnaire. Do NOT author \"Other\" or \"Type something.\" labels yourself — reserved labels are rejected at runtime.",
11
+ "Set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The \"Type something.\" row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append \"(Recommended)\" to its label.",
12
+ "Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
13
+ }
14
+
15
+ defaultToolDescription = "Ask the user one or more structured questions during execution. Use when you need to:\n" +
16
+ "1. Gather user preferences or requirements\n" +
17
+ "2. Clarify ambiguous instructions\n" +
18
+ "3. Get decisions on implementation choices as you work\n" +
19
+ "4. Offer choices to the user about what direction to take\n" +
20
+ "\n" +
21
+ "Usage notes:\n" +
22
+ "- Users can type a custom answer via the automatically appended \"Type something.\" row on every question or press Esc to abandon the questionnaire. Do NOT author \"Other\" or \"Type something.\" labels yourself — reserved labels are rejected at runtime.\n" +
23
+ "- Use multiSelect: true when multiple answers are valid. The \"Type something.\" row is available on every question, including when options carry a `preview`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.\n" +
24
+ "- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label.\n" +
25
+ "\n" +
26
+ "Preview feature:\n" +
27
+ "Use the optional `preview` field on options when presenting concrete artifacts that users need to visually compare:\n" +
28
+ "- ASCII mockups of UI layouts or components\n" +
29
+ "- Code snippets showing different implementations\n" +
30
+ "- Diagram variations\n" +
31
+ "- Configuration examples\n" +
32
+ "\n" +
33
+ "Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect)."
34
+ )
@@ -0,0 +1,177 @@
1
+ package ask_user_question
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+ )
7
+
8
+ const fMain = "ask-user-question"
9
+
10
+ func twoQ() questionParams {
11
+ return params(qn("Q1?", "A", opt("x", "d"), opt("y", "d")), qn("Q2?", "B", opt("p", "d"), opt("q", "d")))
12
+ }
13
+
14
+ func TestEnvelope(t *testing.T) {
15
+ const prefix, suffix = "User has answered your questions:", "You can now continue with the user's answers in mind."
16
+ tw(t, fMain, "null result → decline envelope + empty answers + cancelled true", func(t *testing.T) {
17
+ out := buildQuestionnaireResponse(nil, single())
18
+ eq(t, out.Text, "User declined to answer questions", "text")
19
+ eq(t, out.Details, questionnaireResult{Cancelled: true}, "details")
20
+ })
21
+ tw(t, fMain, "cancelled result preserves partial answers in details (not in content)", func(t *testing.T) {
22
+ r := &questionnaireResult{Cancelled: true, Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
23
+ out := buildQuestionnaireResponse(r, single())
24
+ eq(t, out.Text, "User declined to answer questions", "text")
25
+ eq(t, out.Details.Answers, r.Answers, "answers")
26
+ })
27
+ tw(t, fMain, "single answered question → CC envelope wrapper with question text and answer", func(t *testing.T) {
28
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
29
+ eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="A". `+suffix, "text")
30
+ })
31
+ tw(t, fMain, "envelope uses question text not header (no Q1 prefix)", func(t *testing.T) {
32
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
33
+ text := buildQuestionnaireResponse(r, single()).Text
34
+ if contains(text, "Pick") || contains(text, "Q1") {
35
+ t.Fatalf("header or Q1 leaked into %q", text)
36
+ }
37
+ })
38
+ tw(t, fMain, "two answered questions render as separate sentences inside one envelope", func(t *testing.T) {
39
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "x"), answerOpt(1, "Q2?", "q")}}
40
+ eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q1?"="x". "Q2?"="q". `+suffix, "text")
41
+ })
42
+ tw(t, fMain, "multiSelect answer renders as comma-joined labels in <A> position", func(t *testing.T) {
43
+ p := params(multi(qn("Q?", "H", opt("red", "r"), opt("blue", "b"))))
44
+ r := &questionnaireResult{Answers: []questionAnswer{answerMulti(0, "Q?", "red", "blue")}}
45
+ eq(t, buildQuestionnaireResponse(r, p).Text, prefix+` "Q?"="red, blue". `+suffix, "text")
46
+ })
47
+ tw(t, fMain, "custom typed answer renders raw text (no 'User answered:' prefix)", func(t *testing.T) {
48
+ r := &questionnaireResult{Answers: []questionAnswer{answerCustom(0, "Which?", "my own")}}
49
+ eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="my own". `+suffix, "text")
50
+ })
51
+ tw(t, fMain, "empty custom answer renders as (no input) in <A> position", func(t *testing.T) {
52
+ a := questionAnswer{QuestionIndex: 0, Question: "Which?", Kind: "custom"}
53
+ eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="(no input)". `+suffix, "text")
54
+ a.Answer = sp("")
55
+ eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="(no input)". `+suffix, "empty string")
56
+ })
57
+ tw(t, fMain, "notes are echoed as 'user notes: <text>' AND preserved in details", func(t *testing.T) {
58
+ a := answerOpt(0, "Which?", "A")
59
+ a.Notes = "because"
60
+ out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single())
61
+ eq(t, out.Text, prefix+` "Which?"="A". user notes: because. `+suffix, "text")
62
+ eq(t, at(out.Details.Answers, 0).Notes, "because", "details")
63
+ })
64
+ tw(t, fMain, "multiSelect answer with notes renders 'user notes:' suffix and NO 'selected preview:' suffix (FR-7)", func(t *testing.T) {
65
+ a := answerMulti(0, "Q?", "red")
66
+ a.Notes = "n"
67
+ p := params(multi(qn("Q?", "H", opt("red", "r"), opt("blue", "b"))))
68
+ eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, p).Text, prefix+` "Q?"="red". user notes: n. `+suffix, "text")
69
+ })
70
+ tw(t, fMain, "cancelled: false with no matching answers still returns DECLINE_MESSAGE text", func(t *testing.T) {
71
+ out := buildQuestionnaireResponse(&questionnaireResult{}, single())
72
+ eq(t, out.Text, "User declined to answer questions", "text")
73
+ eq(t, out.Details, questionnaireResult{Cancelled: true}, "details")
74
+ })
75
+ tw(t, fMain, "success + note: exact envelope bytes with the trailing 'global note: <note>.' segment (raw multiline echo)", func(t *testing.T) {
76
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}, GlobalNote: "line one\nline two"}
77
+ eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="A". global note: line one`+"\n"+`line two. `+suffix, "text")
78
+ })
79
+ tw(t, fMain, "the global-note segment is anchored by the envelope suffix", func(t *testing.T) {
80
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}, GlobalNote: "n"}
81
+ text := buildQuestionnaireResponse(r, single()).Text
82
+ eq(t, tail(text, len(" global note: n. "+suffix)), " global note: n. "+suffix, "tail")
83
+ })
84
+ tw(t, fMain, "zero answers + note is NOT a decline — answered envelope with details by reference", func(t *testing.T) {
85
+ r := &questionnaireResult{GlobalNote: "just this"}
86
+ out := buildQuestionnaireResponse(r, single())
87
+ eq(t, out.Text, prefix+` global note: just this. `+suffix, "text")
88
+ eq(t, out.Details, *r, "details")
89
+ })
90
+ tw(t, fMain, "cancelled + note: DECLINE_MESSAGE text, answers forwarded AND globalNote preserved in details", func(t *testing.T) {
91
+ r := &questionnaireResult{Cancelled: true, GlobalNote: "kept", Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
92
+ out := buildQuestionnaireResponse(r, single())
93
+ eq(t, out.Text, "User declined to answer questions", "text")
94
+ eq(t, out.Details, questionnaireResult{Cancelled: true, GlobalNote: "kept", Answers: r.Answers}, "details")
95
+ })
96
+ tw(t, fMain, "zero answers + no note still declines (fresh details literal carries no globalNote key)", func(t *testing.T) {
97
+ out := buildQuestionnaireResponse(&questionnaireResult{}, single())
98
+ b, _ := json.Marshal(out.Details)
99
+ eq(t, string(b), `{"answers":[],"cancelled":true}`, "wire")
100
+ })
101
+ tw(t, fMain, "formats 2 answered questions as comma-period-separated segments inside one envelope", func(t *testing.T) {
102
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "x"), answerCustom(1, "Q2?", "free")}}
103
+ eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q1?"="x". "Q2?"="free". `+suffix, "text")
104
+ })
105
+ tw(t, fMain, "formats 3 questions with mixed answer types in single-line envelope", func(t *testing.T) {
106
+ p := params(qn("Q1?", "A", two()...), multi(qn("Q2?", "B", opt("p", "d"), opt("q", "d"))), qn("Q3?", "C", two()...))
107
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "A"), answerMulti(1, "Q2?", "p", "q"), answerCustom(2, "Q3?", "z")}}
108
+ text := buildQuestionnaireResponse(r, p).Text
109
+ eq(t, text, prefix+` "Q1?"="A". "Q2?"="p, q". "Q3?"="z". `+suffix, "text")
110
+ if contains(text, "\n") {
111
+ t.Fatalf("envelope spans lines: %q", text)
112
+ }
113
+ })
114
+ tw(t, fMain, "skips unanswered questions (omits their segment from envelope)", func(t *testing.T) {
115
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(1, "Q2?", "p")}}
116
+ eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q2?"="p". `+suffix, "text")
117
+ })
118
+ tw(t, fMain, "preserves notes in details AND echoes them across multiple questions", func(t *testing.T) {
119
+ a, b := answerOpt(0, "Q1?", "x"), answerOpt(1, "Q2?", "p")
120
+ a.Notes, b.Notes = "n1", "n2"
121
+ out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a, b}}, twoQ())
122
+ eq(t, out.Text, prefix+` "Q1?"="x". user notes: n1. "Q2?"="p". user notes: n2. `+suffix, "text")
123
+ eq(t, at(out.Details.Answers, 1).Notes, "n2", "details")
124
+ })
125
+ tw(t, fMain, "echoes preview text in envelope when single-select answer matches a preview-bearing option", func(t *testing.T) {
126
+ a := answerOpt(0, "Which?", "A")
127
+ a.Preview = "+--+"
128
+ eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="A". selected preview: +--+. `+suffix, "text")
129
+ })
130
+ tw(t, fMain, "omits 'selected preview:' fragment when answer has no preview", func(t *testing.T) {
131
+ text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
132
+ if contains(text, "selected preview") {
133
+ t.Fatalf("preview fragment in %q", text)
134
+ }
135
+ })
136
+ tw(t, fMain, "omits 'user notes:' fragment when answer has no notes", func(t *testing.T) {
137
+ text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
138
+ if contains(text, "user notes") {
139
+ t.Fatalf("notes fragment in %q", text)
140
+ }
141
+ })
142
+ tw(t, fMain, "envelope wraps with CC prefix and suffix sentences", func(t *testing.T) {
143
+ text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
144
+ eq(t, head(text, len(prefix)), prefix, "prefix")
145
+ eq(t, tail(text, len(suffix)), suffix, "suffix")
146
+ })
147
+ tw(t, fMain, "locks the envelope shape", func(t *testing.T) {
148
+ out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single())
149
+ b, _ := json.Marshal(out.Details)
150
+ eq(t, string(b), `{"answers":[{"answer":"A","kind":"option","question":"Which?","questionIndex":0}],"cancelled":false}`, "details wire")
151
+ })
152
+ tskip(t, fMain, "passes details by reference (no clone)", "Go passes the result by value into the output; there is no reference identity to assert (the details equal the input: TestEnvelopeDetailsEqualTheInput)")
153
+ tw(t, fMain, "accepts error field in envelope", func(t *testing.T) {
154
+ out := buildToolResult("Error: x", questionnaireResult{Cancelled: true, Error: errNoUI})
155
+ b, _ := json.Marshal(out.Details)
156
+ eq(t, string(b), `{"answers":[],"cancelled":true,"error":"no_ui"}`, "wire")
157
+ eq(t, out.Text, "Error: x", "text")
158
+ })
159
+ }
160
+
161
+ func TestEnvelopeDetailsEqualTheInput(t *testing.T) {
162
+ r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
163
+ eq(t, buildQuestionnaireResponse(r, single()).Details, *r, "details")
164
+ }
165
+
166
+ func TestAnswerSegmentOrdersPreviewBeforeNotes(t *testing.T) {
167
+ a := answerOpt(0, "Q?", "A")
168
+ a.Preview, a.Notes = "P", "N"
169
+ eq(t, buildAnswerSegment(a), `"Q?"="A". selected preview: P. user notes: N.`, "segment")
170
+ }
171
+
172
+ func TestFormatAnswerScalar(t *testing.T) {
173
+ eq(t, formatAnswerScalar(answerMulti(0, "Q", "a", "b")), "a, b", "multi")
174
+ eq(t, formatAnswerScalar(answerMulti(0, "Q")), "(no input)", "multi empty")
175
+ eq(t, formatAnswerScalar(questionAnswer{Kind: "option"}), "(no input)", "option without answer")
176
+ eq(t, formatAnswerScalar(answerOpt(0, "Q", "")), "", "option with an empty answer keeps it (?? only replaces null)")
177
+ }