diff --git a/CHANGELOG.md b/CHANGELOG.md index 3706383..ab312df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `sustech-cli` are documented in this file. ### Fixed +- Corrected terminal-table alignment for Unicode Roman numerals, ellipses, + and combining characters, and kept grapheme clusters intact during truncation. - Made `auth status` use a metadata-only macOS Keychain lookup instead of reading the stored password, and bounded credential-helper subprocesses to five seconds with a structured `CREDENTIAL_STORE_TIMEOUT` status. diff --git a/package-lock.json b/package-lock.json index 8236a65..90909e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", "playwright-core": "^1.62.1", + "string-width": "^7.2.0", "zod": "^4.5.2" }, "bin": { @@ -300,6 +301,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -315,6 +328,12 @@ "node": ">= 8" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -338,6 +357,18 @@ "node": ">=18.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -410,6 +441,38 @@ "node": ">=8" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index d909e19..fe330ad 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@modelcontextprotocol/server": "^2.0.0", "@napi-rs/keyring": "1.3.0", "playwright-core": "^1.62.1", + "string-width": "^7.2.0", "zod": "^4.5.2" } } diff --git a/src/core/text.ts b/src/core/text.ts index 1901f30..c1c855e 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -1,3 +1,4 @@ +import stringWidth from "string-width"; import type { Semester } from "./semester.js"; import type { StudentProfileReport } from "../profile/report.js"; import type { TimetableResult } from "../tis/planner.js"; @@ -581,10 +582,12 @@ function padCell(value: string, width: number): string { return `${truncated}${" ".repeat(Math.max(0, width - displayWidth(truncated)))}`; } +const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" }); + function truncateDisplay(value: string, width: number): string { if (displayWidth(value) <= width) return value; let output = ""; - for (const character of value) { + for (const { segment: character } of graphemes.segment(value)) { if (displayWidth(`${output}${character}…`) > width) break; output += character; } @@ -592,5 +595,6 @@ function truncateDisplay(value: string, width: number): string { } function displayWidth(value: string): number { - return [...value].reduce((width, character) => width + (/[^\u0000-\u00ff]/.test(character) ? 2 : 1), 0); + // Terminals normally render ambiguous-width characters such as Ⅴ and … in one cell. + return stringWidth(value, { ambiguousIsNarrow: true }); } diff --git a/src/test/text.test.ts b/src/test/text.test.ts new file mode 100644 index 0000000..7efb6b0 --- /dev/null +++ b/src/test/text.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatGrades } from "../core/text.js"; + +function gradeRow(name: string): string { + const output = formatGrades([{ + code: "TEST101", + name, + nameEn: "", + semester: "2026秋季", + credits: 3, + letterGrade: "B", + numericScore: 80, + nature: "", + department: "", + }], { gpa: 0, credits: 0, courseCount: 0 }); + return output.split("\n").find((line) => line.includes("TEST101"))!; +} + +// Expected padding uses known terminal-cell widths, independent of the renderer. +function expectedRow(name: string, nameWidth: number): string { + return `2026秋季${" ".repeat(8)}TEST101${" ".repeat(7)}${name}${" ".repeat(32 - nameWidth)}B${" ".repeat(8)}80${" ".repeat(7)}3${" ".repeat(7)}`; +} + +test("grade columns align for ASCII and Unicode Roman numerals", () => { + assert.equal(gradeRow("体育V"), expectedRow("体育V", 5)); + assert.equal(gradeRow("体育Ⅴ"), expectedRow("体育Ⅴ", 5)); + assert.equal(gradeRow("体育Ⅳ"), expectedRow("体育Ⅳ", 5)); +}); + +test("truncated grade names reserve one cell for the ellipsis", () => { + assert.equal(gradeRow("中".repeat(16)), expectedRow(`${"中".repeat(14)}…`, 29)); + assert.equal(gradeRow("A".repeat(31)), expectedRow(`${"A".repeat(29)}…`, 30)); + assert.equal(gradeRow("中".repeat(15)), expectedRow("中".repeat(15), 30)); +}); + +test("grade names preserve combining and emoji graphemes when truncating", () => { + assert.equal(gradeRow("Cafe\u0301"), expectedRow("Cafe\u0301", 4)); + assert.equal(gradeRow("AB"), expectedRow("AB", 4)); + assert.equal(gradeRow(`${"A".repeat(28)}👩‍💻BC`), expectedRow(`${"A".repeat(28)}…`, 29)); + assert.equal(gradeRow(`${"A".repeat(27)}👩‍💻BC`), expectedRow(`${"A".repeat(27)}👩‍💻…`, 30)); +});