11import { describe , expect , it } from "vitest" ;
22import type { CandidateDiagnostics , ContextDiagnostics } from "../../src/diagnostics.js" ;
33import type { DoctorResult } from "../../src/doctor.js" ;
4- import { createTerminalUi , renderDoctorReport , renderErrorBlock , renderReviewScreen } from "../../src/ui.js" ;
4+ import {
5+ createTerminalUi ,
6+ renderActionSummary ,
7+ renderDoctorReport ,
8+ renderErrorBlock ,
9+ renderExplainBlock ,
10+ renderReviewScreen ,
11+ renderStateCard ,
12+ renderValidationBlock
13+ } from "../../src/ui.js" ;
514
615function baseContext ( ) : ContextDiagnostics {
716 return {
@@ -62,51 +71,74 @@ function baseCandidate(overrides: Partial<CandidateDiagnostics> = {}): Candidate
6271
6372describe ( "ui renderers" , ( ) => {
6473 it ( "renders the primary review screen as a stable rich-layout snapshot" , ( ) => {
65- const ui = createTerminalUi ( { isTTY : false , columns : 80 } , { forceRichLayout : true } ) ;
74+ const ui = createTerminalUi ( { isTTY : false , columns : 80 } , {
75+ forceRichLayout : true ,
76+ forceUnicode : true
77+ } ) ;
6678
6779 expect ( renderReviewScreen ( ui , baseContext ( ) , baseCandidate ( ) , {
6880 explain : true ,
6981 alternativesCount : 1
7082 } ) ) . toBe ( [
71- "== Review commit message ==" ,
83+ "╭──────────────────────────────────────────────────────────────────────────────╮" ,
84+ "│ Review commit │" ,
85+ "├──────────────────────────────────────────────────────────────────────────────┤" ,
86+ "│ [VALID] [REPAIRED] │" ,
87+ "│ │" ,
88+ "│ feat(cli): add baseline │" ,
89+ "│ │" ,
90+ "│ Refs ABC-123 │" ,
91+ "│ │" ,
92+ "│ expected feat · scope cli · ticket ABC-123 · source repaired │" ,
93+ "╰──────────────────────────────────────────────────────────────────────────────╯" ,
7294 "" ,
73- "== Context ==" ,
74- " Expected type : feat (diff inference)" ,
75- " Scope : cli (changed files)" ,
76- " Ticket : ABC-123 (branch inference)" ,
77- "" ,
78- "== Commit preview ==" ,
79- " [VALID] [REPAIRED] [SCOPE cli] [TICKET ABC-123]" ,
80- " Subject : feat(cli): add baseline" ,
81- " Body :" ,
82- " Refs ABC-123" ,
83- "" ,
84- "== Why this message ==" ,
85- " Source : repaired" ,
86- " Expected type : feat (diff inference)" ,
87- " Selected scope : cli (changed files)" ,
88- " Selected ticket : ABC-123 (branch inference)" ,
89- " Validation : valid" ,
90- " Ranking : 1111100 (valid, subject-fit, type-match, scope-match," ,
91- " ticket-footer)" ,
92- " Alternatives : 1"
95+ "╭──────────────────────────────────────────────────────────────────────────────╮" ,
96+ "│ Why it won │" ,
97+ "├──────────────────────────────────────────────────────────────────────────────┤" ,
98+ "│ signals valid · type-match · scope-match · ticket-footer · subject-fit │" ,
99+ "│ score 1111100 · 1 alternative │" ,
100+ "│ type diff · scope files · ticket branch │" ,
101+ "╰──────────────────────────────────────────────────────────────────────────────╯"
93102 ] . join ( "\n" ) ) ;
94103 } ) ;
95104
96105 it ( "renders a representative failure block as a stable snapshot" , ( ) => {
97- const ui = createTerminalUi ( { isTTY : false , columns : 80 } , { forceRichLayout : true } ) ;
106+ const ui = createTerminalUi ( { isTTY : false , columns : 80 } , {
107+ forceRichLayout : true ,
108+ forceUnicode : true
109+ } ) ;
98110
99111 expect ( renderErrorBlock (
100112 ui ,
101113 "Generated message failed validation" ,
102114 "Scope is required and must be one of: cli." ,
103115 "Revise, edit, regenerate, or rerun with `--allow-invalid` if you want to override."
104116 ) ) . toBe ( [
105- "== Problem ==" ,
106- " Problem : Generated message failed validation" ,
107- " Why : Scope is required and must be one of: cli." ,
108- " Next step : Revise, edit, regenerate, or rerun with `--allow-invalid` if you" ,
109- " want to override."
117+ "╭──────────────────────────────────────────────────────────────────────────────╮" ,
118+ "│ Generated message failed validation │" ,
119+ "├──────────────────────────────────────────────────────────────────────────────┤" ,
120+ "│ Scope is required and must be one of: cli. │" ,
121+ "│ │" ,
122+ "│ next Revise, edit, regenerate, or rerun with `--allow-invalid` if you want │" ,
123+ "│ to override. │" ,
124+ "╰──────────────────────────────────────────────────────────────────────────────╯"
125+ ] . join ( "\n" ) ) ;
126+ } ) ;
127+
128+ it ( "supports an ASCII rich-layout fallback for card rendering" , ( ) => {
129+ const ui = createTerminalUi ( { isTTY : false , columns : 72 } , {
130+ forceRichLayout : true ,
131+ forceUnicode : false
132+ } ) ;
133+
134+ expect ( renderActionSummary ( ui , "Hooks installed" , [
135+ "/repo/.git/hooks/prepare-commit-msg"
136+ ] ) ) . toBe ( [
137+ "+----------------------------------------------------------------------+" ,
138+ "| Hooks installed |" ,
139+ "+----------------------------------------------------------------------+" ,
140+ "| - /repo/.git/hooks/prepare-commit-msg |" ,
141+ "+----------------------------------------------------------------------+"
110142 ] . join ( "\n" ) ) ;
111143 } ) ;
112144
@@ -118,8 +150,159 @@ describe("ui renderers", () => {
118150 } ) ) . toBe ( "feat(cli): add baseline\n\nRefs ABC-123" ) ;
119151 } ) ;
120152
153+ it ( "renders compact plain explain and validation output without extra sections" , ( ) => {
154+ const ui = createTerminalUi ( { isTTY : false , columns : 80 } ) ;
155+ const invalidCandidate = baseCandidate ( {
156+ message : "bad message" ,
157+ subject : "bad message" ,
158+ source : "model" ,
159+ final : {
160+ type : null ,
161+ scope : {
162+ value : null ,
163+ source : "none"
164+ } ,
165+ ticket : {
166+ value : null ,
167+ source : "none"
168+ }
169+ } ,
170+ validation : {
171+ ok : false ,
172+ errors : [
173+ "Not Conventional Commits format" ,
174+ "Message must reference a ticket."
175+ ]
176+ } ,
177+ ranking : {
178+ valid : false ,
179+ validPoints : 0 ,
180+ subjectWithinLimit : false ,
181+ subjectWithinLimitPoints : 0 ,
182+ expectedTypeMatch : false ,
183+ expectedTypePoints : 0 ,
184+ expectedScopeMatch : false ,
185+ expectedScopePoints : 0 ,
186+ ticketFooterPresent : false ,
187+ ticketFooterPoints : 0 ,
188+ genericDescriptionPenalty : true ,
189+ genericDescriptionPoints : - 10 ,
190+ total : - 10
191+ }
192+ } ) ;
193+
194+ expect ( renderReviewScreen ( ui , baseContext ( ) , invalidCandidate , {
195+ explain : true ,
196+ validationNextStep : "Regenerate the message and retry."
197+ } ) ) . toBe ( [
198+ "bad message" ,
199+ "" ,
200+ "errors:" ,
201+ "- Not Conventional Commits format" ,
202+ "- Message must reference a ticket." ,
203+ "next: Regenerate the message and retry."
204+ ] . join ( "\n" ) ) ;
205+
206+ expect ( renderValidationBlock ( ui , [ "Missing ticket footer" ] , {
207+ note : "score -10" ,
208+ nextStep : "Add a ticket footer."
209+ } ) ) . toBe ( [
210+ "errors:" ,
211+ "- Missing ticket footer" ,
212+ "score -10" ,
213+ "next: Add a ticket footer."
214+ ] . join ( "\n" ) ) ;
215+ } ) ;
216+
217+ it ( "renders baseline rich explain output when no ranking signals or origins apply" , ( ) => {
218+ const ui = createTerminalUi ( { isTTY : false , columns : 72 } , {
219+ forceRichLayout : true ,
220+ forceUnicode : false
221+ } ) ;
222+ const minimalCandidate = baseCandidate ( {
223+ message : "chore: bump deps" ,
224+ subject : "chore: bump deps" ,
225+ source : "model" ,
226+ final : {
227+ type : "chore" ,
228+ scope : {
229+ value : null ,
230+ source : "message"
231+ } ,
232+ ticket : {
233+ value : null ,
234+ source : "none"
235+ }
236+ } ,
237+ ranking : {
238+ valid : false ,
239+ validPoints : 0 ,
240+ subjectWithinLimit : false ,
241+ subjectWithinLimitPoints : 0 ,
242+ expectedTypeMatch : false ,
243+ expectedTypePoints : 0 ,
244+ expectedScopeMatch : false ,
245+ expectedScopePoints : 0 ,
246+ ticketFooterPresent : false ,
247+ ticketFooterPoints : 0 ,
248+ genericDescriptionPenalty : false ,
249+ genericDescriptionPoints : 0 ,
250+ total : 0
251+ }
252+ } ) ;
253+
254+ expect ( renderExplainBlock ( ui , {
255+ expectedType : {
256+ value : null ,
257+ source : "none"
258+ } ,
259+ scope : {
260+ suggested : null ,
261+ effective : null ,
262+ source : "none"
263+ } ,
264+ ticket : {
265+ value : null ,
266+ source : "none"
267+ }
268+ } , minimalCandidate ) ) . toBe ( [
269+ "+----------------------------------------------------------------------+" ,
270+ "| Why it won |" ,
271+ "+----------------------------------------------------------------------+" ,
272+ "| signals baseline fit |" ,
273+ "| score 0 |" ,
274+ "+----------------------------------------------------------------------+"
275+ ] . join ( "\n" ) ) ;
276+ } ) ;
277+
278+ it ( "supports plain error output and tty-colored rich state cards" , ( ) => {
279+ const plainUi = createTerminalUi ( { isTTY : false , columns : 80 } ) ;
280+ expect ( renderErrorBlock ( plainUi , "Hook install failed" , "Existing hook is unmanaged" ) ) . toBe ( [
281+ "Problem: Hook install failed" ,
282+ "Why: Existing hook is unmanaged" ,
283+ "Next: Review the error details and retry."
284+ ] . join ( "\n" ) ) ;
285+
286+ const ttyUi = createTerminalUi ( { isTTY : true , columns : 68 } , {
287+ forceRichLayout : true ,
288+ forceUnicode : false
289+ } ) ;
290+ const rendered = renderStateCard ( ttyUi , {
291+ title : "Commit created" ,
292+ headline : "fix(cli): tighten output" ,
293+ meta : [ "scope cli" ] ,
294+ tone : "success"
295+ } ) ;
296+
297+ expect ( rendered ) . toContain ( "\u001b[1m\u001b[32mCommit created\u001b[0m" ) ;
298+ expect ( rendered ) . toContain ( "\u001b[1m\u001b[36mfix(cli): tighten output\u001b[0m" ) ;
299+ } ) ;
300+
121301 it ( "includes doctor sections and next steps in the report" , ( ) => {
122- const ui = createTerminalUi ( { isTTY : false , columns : 80 } , { forceRichLayout : true } ) ;
302+ const ui = createTerminalUi ( { isTTY : false , columns : 80 } , {
303+ forceRichLayout : true ,
304+ forceUnicode : false
305+ } ) ;
123306 const result : DoctorResult = {
124307 ok : false ,
125308 exitCode : 3 ,
@@ -141,9 +324,48 @@ describe("ui renderers", () => {
141324 } ;
142325
143326 const report = renderDoctorReport ( ui , result ) ;
144- expect ( report ) . toContain ( "== Doctor ==" ) ;
145- expect ( report ) . toContain ( "== Environment ==" ) ;
146- expect ( report ) . toContain ( "== Ollama ==" ) ;
147- expect ( report ) . toContain ( "next: Run `ollama pull gpt-oss:120b-cloud` and retry." ) ;
327+ expect ( report ) . toContain ( "| Doctor" ) ;
328+ expect ( report ) . toContain ( "| Environment" ) ;
329+ expect ( report ) . toContain ( "| Ollama" ) ;
330+ expect ( report ) . toContain ( "next Run `ollama pull gpt-oss:120b-cloud` and retry." ) ;
331+ } ) ;
332+
333+ it ( "renders plain doctor sections and default width fallback cleanly" , ( ) => {
334+ const ui = createTerminalUi ( undefined ) ;
335+ const result : DoctorResult = {
336+ ok : false ,
337+ exitCode : 3 ,
338+ checks : [
339+ {
340+ section : "Environment" ,
341+ name : "Node.js" ,
342+ ok : true ,
343+ detail : "Detected 20.12.0"
344+ } ,
345+ {
346+ section : "Ollama" ,
347+ name : "Configured model" ,
348+ ok : false ,
349+ detail : "Model not found" ,
350+ nextStep : "Run `ollama pull gpt-oss:120b-cloud` and retry."
351+ }
352+ ]
353+ } ;
354+
355+ expect ( ui . width ) . toBe ( 88 ) ;
356+ expect ( ui . richLayout ) . toBe ( false ) ;
357+ expect ( ui . unicode ) . toBe ( false ) ;
358+ expect ( renderDoctorReport ( ui , result ) ) . toBe ( [
359+ "Doctor" ,
360+ "1 check need attention" ,
361+ "review the failing section below" ,
362+ "" ,
363+ "Environment:" ,
364+ "- OK Node.js: Detected 20.12.0" ,
365+ "" ,
366+ "Ollama:" ,
367+ "- FAIL Configured model: Model not found" ,
368+ " next: Run `ollama pull gpt-oss:120b-cloud` and retry."
369+ ] . join ( "\n" ) ) ;
148370 } ) ;
149371} ) ;
0 commit comments