Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5645b926a3 | |||
| 64c7d7bad2 | |||
| ab00cbefcc | |||
| c67749a53c | |||
| ba7623f813 |
@@ -0,0 +1,86 @@
|
||||
# Session 0047.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-34 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-44 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**#33 (task, P3)** — harden the F9/F10 author-coloring PUA sentinels against
|
||||
intra-emphasis markdown. Anchor: leaf `task` → §4.3 R2 (no design gate). Picked
|
||||
autonomously (next clean plan-and-executable backlog item; doesn't touch the
|
||||
content repo). Approach = the token-aware fix recommended in the issue #33 comment.
|
||||
|
||||
Two characterized failure modes (issue #33 comment, session 0032):
|
||||
- **CASE1** — a span boundary lands strictly inside a delimiter run (`a**b**c`,
|
||||
boundary between the two `*`): the injected sentinel splits `**` → markdown parse
|
||||
breaks (stray `<em></em>`, raw `**` left).
|
||||
- **CASE3** — a span boundary inside an emphasis run (`**bold**`, span covers
|
||||
`**bo`): emphasis renders but the author `<span>` and `<strong>` **misnest**
|
||||
(`<strong>bo</span>ld</strong>`).
|
||||
|
||||
Fix (both needed):
|
||||
1. `injectSentinels` — **clamp** any sentinel offset that lands strictly inside a
|
||||
markdown delimiter run (`* _ ~ \``) to the run's start, so a sentinel never
|
||||
splits a delimiter (fixes CASE1). Skip now-zero-width spans.
|
||||
2. `sentinelsToSpans` — replace the naive split/join with a **token-aware walker**
|
||||
over the rendered HTML: emit `cw-by-*` spans only around TEXT runs, closing the
|
||||
span before any `<tag>` and reopening after, so a span never crosses an element
|
||||
boundary (fixes CASE3 — one span segment per text run). Strip any stray sentinel
|
||||
left inside a tag (no PUA leakage).
|
||||
|
||||
Pure/vscode-free/deterministic (INV-33). Non-goals: link/attribute sentinel cases,
|
||||
visual language, attribution model.
|
||||
|
||||
Tasks: unit tests reproducing CASE1 + CASE3 (+ regression on existing
|
||||
colorByAuthor/renderReview cases) → implement clamp + walker → green; spec already
|
||||
notes the hardening (§1.7/§9). No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**#33 (task, P3) shipped to `main`** — PR
|
||||
[#53](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/53)
|
||||
(merged), issue #33 closed. Picked autonomously (next clean plan-and-executable
|
||||
leaf; doesn't touch the content repo).
|
||||
|
||||
- `trackChangesModel.ts` `injectSentinels` — clamp any sentinel offset landing
|
||||
strictly inside a delimiter run (`* _ ~ \``) to the run's start (fixes CASE1
|
||||
parse-break); drop spans that clamp to empty.
|
||||
- `trackChangesModel.ts` `sentinelsToSpans` — token-aware walker emitting the
|
||||
`cw-by-*` span only around text runs, split at every `<tag>` boundary (fixes
|
||||
CASE3 misnest); strays stripped (no PUA leak).
|
||||
- 4 new unit tests (CASE1/CASE3/CASE2-regression/no-leak), real markdown-it via
|
||||
`renderReview`.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **222 unit** green; **74/5 host E2E** green;
|
||||
the F10/authorship E2E (`cw-by-claude`/`cw-by-human`) still pass (common case
|
||||
byte-identical).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook required autonomously determining/executing the next milestone;
|
||||
chose #33 (leaf task, no content-repo dependency). Claimed 0047, clean `main`.
|
||||
2. Read issue #33 + its investigation comment (2 failure modes, recommended
|
||||
token-aware approach) + session 0032 characterization + the sentinel code.
|
||||
3. TDD: CASE1/CASE3 reproduced red → implemented clamp + walker → green; full
|
||||
suite + E2E green; self-reviewed (one pure module).
|
||||
4. Shipped (PR #53), closed #33.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- _No low-confidence calls this session._ (Approach was the one recommended in the
|
||||
issue #33 comment; the fix is a pure-module robustness change with full test
|
||||
coverage and no regression.)
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
# Session 0047.0 — Transcript
|
||||
# Session 0048.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-34 (PST)
|
||||
> Start: 2026-06-13T08-45 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0047 for vscode-cowriting-plugin. The driver replaces this
|
||||
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--<end>.md form at session end.
|
||||
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
|
||||
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
|
||||
|
||||
```
|
||||
|
||||
@@ -139,5 +139,8 @@
|
||||
},
|
||||
"0047": {
|
||||
"title": ""
|
||||
},
|
||||
"0048": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,19 @@ interface DocAttribution {
|
||||
* orphans on reload).
|
||||
*/
|
||||
hadAttributions: boolean;
|
||||
/**
|
||||
* #40: text-keyed attribution snapshots for exact provenance restoration on
|
||||
* undo/redo. Maps a document-text state → the live spans at that state (offsets
|
||||
* valid for that exact text). Captured after every forward edit + at load;
|
||||
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
|
||||
* evicted) — a far-back/evicted state falls back to the #38 neutral reconcile.
|
||||
*/
|
||||
attrHistory: Map<string, LiveSpan[]>;
|
||||
}
|
||||
|
||||
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
|
||||
const ATTR_HISTORY_MAX = 200;
|
||||
|
||||
export class AttributionController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocAttribution>();
|
||||
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
private state(docPath: string): DocAttribution {
|
||||
let s = this.docs.get(docPath);
|
||||
if (!s) {
|
||||
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
|
||||
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
|
||||
this.docs.set(docPath, s);
|
||||
}
|
||||
return s;
|
||||
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
|
||||
s.spans = coalesce(s.spans);
|
||||
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
||||
}
|
||||
// #40: seed the snapshot history with the loaded state so undoing back to it
|
||||
// restores its exact attribution.
|
||||
this.snapshotAttribution(s, document.getText());
|
||||
this.render(document);
|
||||
}
|
||||
|
||||
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
|
||||
|
||||
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
||||
|
||||
/** #40: snapshot the current spans keyed by the document's current text. */
|
||||
private snapshotAttribution(s: DocAttribution, text: string): void {
|
||||
s.attrHistory.delete(text); // re-insert at the end (recency order)
|
||||
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
|
||||
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
|
||||
const oldest = s.attrHistory.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
s.attrHistory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||
const docPath = this.keyOf(e.document);
|
||||
@@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
}
|
||||
// #40: exact provenance across history navigation. After a FORWARD edit,
|
||||
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
|
||||
// left re-inserted text neutral (#38); if a snapshot's text equals the current
|
||||
// buffer, restore that state's spans exactly (offsets are valid — identical
|
||||
// text) so Claude's restored text is blue again, the human's green. No match
|
||||
// (far-back/evicted state) keeps the #38 neutral fallback.
|
||||
if (isUndoRedo) {
|
||||
const restored = s.attrHistory.get(e.document.getText());
|
||||
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
|
||||
} else {
|
||||
this.snapshotAttribution(s, e.document.getText());
|
||||
}
|
||||
if (s.spans.length > 0) s.hadAttributions = true;
|
||||
this.render(e.document);
|
||||
}
|
||||
|
||||
@@ -545,13 +545,33 @@ function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } |
|
||||
: { cls: "cw-by-human", label: "You" };
|
||||
}
|
||||
|
||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
||||
// markdown-it's delimiter pairing (#33 CASE1).
|
||||
const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`";
|
||||
|
||||
/**
|
||||
* #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at`
|
||||
* sits between two identical delimiter chars, snap it to the run's start (a
|
||||
* position outside the run) so the run stays intact. Delimiters are invisible once
|
||||
* rendered, so snapping only shifts the colored boundary across markup, never over
|
||||
* visible text.
|
||||
*/
|
||||
function clampOffDelimiterRun(raw: string, at: number): number {
|
||||
if (at <= 0 || at >= raw.length) return at;
|
||||
if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at;
|
||||
let p = at;
|
||||
while (p > 0 && raw[p - 1] === raw[at]) p--;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||
const inserts: { at: number; marker: string }[] = [];
|
||||
for (const s of spans) {
|
||||
const lo = Math.max(0, s.start - blockStart);
|
||||
const hi = Math.min(raw.length, s.end - blockStart);
|
||||
if (hi <= lo) continue;
|
||||
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
|
||||
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||
}
|
||||
@@ -564,13 +584,69 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Replace the rendered sentinels with author <span> tags. */
|
||||
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
|
||||
[SENT.claude.open]: { author: "claude", open: true },
|
||||
[SENT.claude.close]: { author: "claude", open: false },
|
||||
[SENT.human.open]: { author: "human", open: true },
|
||||
[SENT.human.close]: { author: "human", open: false },
|
||||
};
|
||||
const ALL_SENTINELS = new RegExp(
|
||||
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
/**
|
||||
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
|
||||
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||
* REOPENING it after, so a span is always well-nested within the inline elements
|
||||
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
||||
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
||||
*/
|
||||
function sentinelsToSpans(html: string): string {
|
||||
return html
|
||||
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
||||
.split(SENT.claude.close).join("</span>")
|
||||
.split(SENT.human.open).join('<span class="cw-by-human">')
|
||||
.split(SENT.human.close).join("</span>");
|
||||
const out: string[] = [];
|
||||
let current: AuthorKind | null = null; // which author region we're inside
|
||||
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||
const openSpan = () => {
|
||||
if (current && !spanOpen) {
|
||||
out.push(`<span class="cw-by-${current}">`);
|
||||
spanOpen = true;
|
||||
}
|
||||
};
|
||||
const closeSpan = () => {
|
||||
if (spanOpen) {
|
||||
out.push("</span>");
|
||||
spanOpen = false;
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < html.length; i++) {
|
||||
const ch = html[i];
|
||||
const sentinel = SENTINEL_OF[ch];
|
||||
if (sentinel) {
|
||||
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
|
||||
else {
|
||||
closeSpan();
|
||||
current = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "<") {
|
||||
// An HTML tag: never let an author span straddle it (text `<` is escaped to
|
||||
// <, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free.
|
||||
closeSpan();
|
||||
const gt = html.indexOf(">", i);
|
||||
const end = gt === -1 ? html.length - 1 : gt;
|
||||
out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, ""));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
openSpan();
|
||||
out.push(ch);
|
||||
}
|
||||
closeSpan();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.attributionController, "exports attribution");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
|
||||
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
|
||||
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
|
||||
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
|
||||
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
|
||||
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.attributionController;
|
||||
|
||||
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
|
||||
const t = "xxxx";
|
||||
const start = doc.getText().indexOf(t);
|
||||
const ok = await ctl.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
|
||||
"ROBOT",
|
||||
AGENT,
|
||||
{ turnId: "turn-s40" },
|
||||
);
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
assert.ok(
|
||||
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
|
||||
"ROBOT is agent-attributed after the seam edit",
|
||||
);
|
||||
|
||||
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
|
||||
const rs = doc.getText().indexOf("ROBOT");
|
||||
const del = new vscode.WorkspaceEdit();
|
||||
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
|
||||
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
|
||||
await settle();
|
||||
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
|
||||
|
||||
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("undo");
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
|
||||
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
|
||||
|
||||
const r2 = doc.getText().indexOf("ROBOT");
|
||||
const spans = ctl.getSpans(key);
|
||||
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
|
||||
assert.ok(over.length > 0, "restored ROBOT carries a span");
|
||||
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
|
||||
});
|
||||
|
||||
test("edit → undo → redo round-trips attribution to identical state", async () => {
|
||||
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.attributionController;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
const start = doc.getText().indexOf("yyyy");
|
||||
await ctl.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
|
||||
"BLUEWORD",
|
||||
AGENT,
|
||||
{ turnId: "turn-s40b" },
|
||||
);
|
||||
await settle();
|
||||
const norm = (k: string) =>
|
||||
ctl
|
||||
.getSpans(k)
|
||||
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
|
||||
.sort((x, y) => (x.t < y.t ? -1 : 1));
|
||||
const afterAgent = JSON.stringify(norm(key));
|
||||
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
|
||||
|
||||
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
|
||||
const bs = doc.getText().indexOf("BLUEWORD");
|
||||
const del = new vscode.WorkspaceEdit();
|
||||
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
|
||||
await vscode.workspace.applyEdit(del);
|
||||
await settle();
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
|
||||
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,45 @@ describe("colorByAuthor", () => {
|
||||
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
||||
expect(html).toContain("world");
|
||||
});
|
||||
|
||||
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
||||
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
|
||||
// since the failure is in markdown-it's inline parsing / element nesting.
|
||||
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
||||
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
||||
const doc = "a**b**c";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
|
||||
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
||||
expect(html).not.toContain("**"); // no raw delimiters left
|
||||
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
||||
expect(html).toContain('class="cw-by-human"'); // coloring present
|
||||
});
|
||||
// CASE3 — a span boundary inside an emphasis run must color the text without
|
||||
// misnesting span/element (the span is split at the element boundary).
|
||||
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
||||
const doc = "**bold**";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
|
||||
expect(html).toContain("<strong>");
|
||||
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
||||
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
||||
});
|
||||
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
||||
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
||||
const doc = "**bold**";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
|
||||
expect(html).toContain("<strong>");
|
||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain("bold");
|
||||
});
|
||||
test("#33: no Private-Use-Area sentinel chars leak into the rendered output", () => {
|
||||
const doc = "a**b**c and `co de` and _x_";
|
||||
const spans: AuthorSpan[] = [
|
||||
{ start: 0, end: 2, author: "human" },
|
||||
{ start: 12, end: 16, author: "claude" },
|
||||
];
|
||||
const html = renderReview(doc, doc, spans, []);
|
||||
expect(html).not.toMatch(/[\uE000-\uF8FF]/); // no leftover BMP Private-Use-Area sentinels
|
||||
});
|
||||
});
|
||||
|
||||
import { renderPlain } from "../src/trackChangesModel";
|
||||
|
||||
Reference in New Issue
Block a user