diff --git a/cmd/waza/cmd_check.go b/cmd/waza/cmd_check.go index 7ca358f5..29e773b4 100644 --- a/cmd/waza/cmd_check.go +++ b/cmd/waza/cmd_check.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" "time" - "unicode/utf8" "github.com/mattn/go-runewidth" @@ -246,15 +245,17 @@ func printCheckSummaryTable(w interface{ Write([]byte) (int, error) }, reports [ const maxNameWidth = 25 const minNameWidth = 10 - // Compute dynamic column width from the longest skill name. + // Compute dynamic column width (in terminal display cells) from the longest + // skill name. Display width, rather than the rune count, is used so the + // column stays aligned with padRight for wide characters (CJK, emoji). nameWidth := len("Skill") for _, r := range reports { n := r.skillName if n == "" { n = "unnamed" } - if runeLen := utf8.RuneCountInString(n); runeLen > nameWidth { - nameWidth = runeLen + if w := runewidth.StringWidth(n); w > nameWidth { + nameWidth = w } } if nameWidth > maxNameWidth { @@ -337,13 +338,15 @@ func printCheckSummaryTable(w interface{ Write([]byte) (int, error) }, reports [ fmt.Fprintf(w, "\n") //nolint:errcheck } -// truncateName shortens a name to maxLen runes, replacing the last rune with "…" if needed. +// truncateName shortens a name so that its terminal display width is at most +// maxLen cells, appending "…" when truncation happens. Display width is used +// (rather than the rune count) so that wide characters such as CJK and emoji +// stay aligned with padRight in the summary table. func truncateName(name string, maxLen int) string { - runes := []rune(name) - if len(runes) <= maxLen { + if runewidth.StringWidth(name) <= maxLen { return name } - return string(runes[:maxLen-1]) + "…" + return runewidth.Truncate(name, maxLen, "…") } // padRight pads s with spaces so its terminal display width reaches width. diff --git a/cmd/waza/cmd_check_test.go b/cmd/waza/cmd_check_test.go index ab215af1..aa6dd920 100644 --- a/cmd/waza/cmd_check_test.go +++ b/cmd/waza/cmd_check_test.go @@ -10,6 +10,7 @@ import ( "testing" "unicode/utf8" + "github.com/mattn/go-runewidth" "github.com/microsoft/waza/cmd/waza/tokens" "github.com/microsoft/waza/internal/scaffold" "github.com/microsoft/waza/internal/scoring" @@ -692,6 +693,48 @@ func TestPrintCheckSummaryTable_DynamicWidth(t *testing.T) { } } +func TestPrintCheckSummaryTable_WideCharAlignment(t *testing.T) { + // A skill name with fullwidth (CJK) characters must not break column + // alignment. The table pads by terminal display width (see padRight), so the + // dynamic name column has to be measured by display width too; otherwise a + // wide name overflows its column and shifts every following column right. + reports := []*readinessReport{ + {skillName: "日本語スキル", complianceLevel: "Medium-High", tokenCount: 100, tokenLimit: 500}, + {skillName: "azure-ai", complianceLevel: "Medium-High", tokenCount: 100, tokenLimit: 500}, + } + var buf bytes.Buffer + printCheckSummaryTable(&buf, reports) + + // The display column at which the second column begins must be identical on + // the header row ("Compliance") and on every data row ("Medium-High"). + displayCol := func(line, token string) (int, bool) { + idx := strings.Index(line, token) + if idx < 0 { + return 0, false + } + return runewidth.StringWidth(line[:idx]), true + } + + lines := strings.Split(buf.String(), "\n") + var want int + found := false + for _, line := range lines { + if col, ok := displayCol(line, "Compliance"); ok { + want, found = col, true + break + } + } + require.True(t, found, "header row with the Compliance column was not found") + + for _, line := range lines { + if !strings.Contains(line, "Medium-High") { + continue + } + col, _ := displayCol(line, "Medium-High") + assert.Equal(t, want, col, "second column misaligned for row %q", line) + } +} + func TestCheckCommandJSONOutput(t *testing.T) { tmpDir := t.TempDir() skillContent := `---