Two fixes for the "accept Petal's change, then it nags about the same sentence moments later" report: - replacePending now suppresses any freshly-generated suggestion whose original->replacement matches one the user already accepted or dismissed for that doc. The model has no memory between passes, so without this it re-proposes the identical edit on the next checkpoint. - findRange anchored suggestions by exact string match, which missed whenever the model echoed an `original` with plain ASCII (straight quotes, --, ...) while the document held the Typography-converted glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant no highlight AND a silent no-op on accept, which then fed the re-nag above. foldTypography canonicalizes those variants (with a source index map for length changes) so matching survives the mismatch. Covered by a server-side regression test (accept+dismiss then re-check returns nothing) and frontend unit tests for the fold and anchoring. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
55 lines
2.3 KiB
TypeScript
55 lines
2.3 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { foldTypography, findRange } from './SuggestionHighlight'
|
||
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||
|
||
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
|
||
// walk without pulling in the full editor.
|
||
const schema = new Schema({
|
||
nodes: {
|
||
doc: { content: 'block+' },
|
||
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||
text: { group: 'inline' },
|
||
},
|
||
})
|
||
|
||
// Build a single-paragraph doc with the given plaintext.
|
||
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||
|
||
describe('foldTypography', () => {
|
||
it('folds curly quotes back to straight ASCII', () => {
|
||
expect(foldTypography('“He’s here,” she said').folded).toBe('"He\'s here," she said')
|
||
})
|
||
|
||
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
|
||
const { folded, map } = foldTypography('wait—really...')
|
||
expect(folded).toBe('wait—really…')
|
||
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
|
||
// so the trailing sentinel still points just past the last source char.
|
||
expect(map[map.length - 1]).toBe('wait—really...'.length)
|
||
})
|
||
|
||
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
|
||
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
|
||
})
|
||
})
|
||
|
||
describe('findRange typographic anchoring', () => {
|
||
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
|
||
const doc = para('She said “hello” to me')
|
||
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
|
||
expect(range).not.toBeNull()
|
||
// The matched span must cover the curly-quoted region in the real document.
|
||
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
|
||
})
|
||
|
||
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
|
||
const doc = para('I waited—forever')
|
||
const range = findRange(doc, 'waited--forever')
|
||
expect(range).not.toBeNull()
|
||
})
|
||
|
||
it('still returns null when the text genuinely is not present', () => {
|
||
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
|
||
})
|
||
})
|