test(web): cover the cursor replay's remaining branches

CI's coverage gate caught `ansi.ts` at 95.31% branches. My local check had
scoped coverage to that one file, which measures a different test set than
the gate does — the gate is the authority and it was right.

Three branches. Two are now pinned, each verified in a real terminal first:

`ab\r` + U+0301 + `x` shows `xb` — the redraw leaves the cursor at column 0,
so a combining mark has no cell to attach to and the terminal shows nothing
for it. It also revealed that the mark was kept when `cursor` was 0, which is
fixed.

`ab\rX\x1b[31m` then a plain line shows `Xb` unstyled and the next line RED:
the mirror of the reset case, where the scan ends styled while the last cell
is not, so the convergence has to OPEN the run at the line end for it to
reach the following line.

The third was `?? ''` on a `String.split` result, which always yields at
least one element — removed rather than tested, since no input can reach it.
This commit is contained in:
Chinesezjc
2026-07-29 16:20:01 +08:00
parent b588060b90
commit 4145cd7075
2 changed files with 28 additions and 4 deletions
+5 -4
View File
@@ -177,9 +177,10 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
if (ZERO_WIDTH.test(char)) {
// No column of its own: it attaches to the cell already written, so a
// redraw that covers that cell covers the mark with it.
const at = Math.max(0, cursor - 1)
const base = columns[at]
if (base !== undefined) columns[at] = { sgr: base.sgr, char: base.char + char }
// With no cell to attach to (line start, or straight after a redraw to
// column 0) a terminal shows nothing rather than a lone accent.
const base = cursor > 0 ? columns[cursor - 1] : undefined
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
continue
}
columns[cursor] = { sgr, char }
@@ -207,7 +208,7 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
// later write can still land past them.
// Only the FIRST parameter selects the mode; a terminal ignores the rest
// (`1;2K` erases exactly as `1K` does — verified against a real terminal).
const mode = params.split(';')[0] ?? ''
const mode = String(params.split(';')[0])
if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' }
else columns.length = mode === '2' ? 0 : cursor
continue
@@ -9,6 +9,8 @@ import { parseAnsiLines } from '../src/ansi.ts'
const ESC = '\u001b'
const BS = '\u0008'
/** A combining acute accent: zero-width, so it takes no terminal column. */
const ACCENT = '\u0301'
/** Paint `text` with the SGR `codes`, then reset. */
function sgr(codes: string, text: string): string {
@@ -318,6 +320,27 @@ describe('parseAnsiLines: line-end state and column widths', () => {
expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
})
it('drops a combining mark left with no cell to attach to by a redraw', () => {
// Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`.
// The redraw puts the cursor at column 0, so the mark has no preceding cell
// and the terminal shows nothing for it rather than a lone accent.
expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined })
// A mark with no movement on its line never reaches the replay at all: it
// is width business, not a cursor move, so it stays as authored.
expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined })
})
it('carries a colour opened after the last write onto the next line', () => {
// The mirror of the reset case, verified in a real terminal: `ab` CR `X` then
// `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red.
// The scan ends styled while the last cell is not, so the convergence has to
// open the run at the line end for it to reach the following line.
expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([
[{ text: 'Xb', style: undefined }],
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
])
})
it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
// Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
// glyph's second cell becomes a blank rather than closing the gap, so the