/** * Pure payload logic for the viewer selection popup ("add to * conversation"): the fence header (relative path + line span), the * over-limit plain-line fallback, and the best-effort reverse-search that * maps a preview selection back to source lines. */ import { describe, expect, it } from 'vitest' import { SELECTION_LIMIT, buildSelectionInsert, headerOf, linesOfSelection, } from '../src/client/selection-payload.ts' describe('headerOf', () => { it('projects the path relative to the session cwd', () => { expect(headerOf('/p/docs/plan.md', '/p', { start: 12, end: 12 })).toBe('docs/plan.md:12') }) it('falls back to the absolute path when the cwd is unknown', () => { expect(headerOf('/p/docs/plan.md', undefined, { start: 3, end: 3 })).toBe('/p/docs/plan.md:3') }) it('keeps the absolute path for files outside the cwd', () => { expect(headerOf('/other/x.md', '/p', { start: 1, end: 1 })).toBe('/other/x.md:1') }) it('writes a single line number for single-line selections', () => { expect(headerOf('/p/a.md', '/p', { start: 5, end: 5 })).toBe('a.md:5') }) it('writes the start-end range for multi-line selections', () => { expect(headerOf('/p/a.md', '/p', { start: 5, end: 9 })).toBe('a.md:5-9') }) it('omits the line suffix entirely when the lines are unknown', () => { expect(headerOf('/p/a.md', '/p', undefined)).toBe('a.md') }) }) describe('buildSelectionInsert', () => { it('wraps a small selection in a fenced block with the path:line info line', () => { expect(buildSelectionInsert('/p/a.ts', '/p', { start: 2, end: 4 }, 'const x = 1')).toBe( '```a.ts:2-4\nconst x = 1\n```', ) }) it('accepts a selection exactly at the limit (fenced)', () => { const text = 'x'.repeat(SELECTION_LIMIT) expect(buildSelectionInsert('/p/a.ts', '/p', { start: 1, end: 1 }, text)).toBe( `\`\`\`a.ts:1\n${text}\n\`\`\``, ) }) it('drops the content past the limit: one plain path line, no fence', () => { const text = 'x'.repeat(SELECTION_LIMIT + 1) expect(buildSelectionInsert('/p/a.ts', '/p', { start: 7, end: 7 }, text)).toBe('a.ts:7') }) it('keeps the plain-line form when past the limit without line numbers', () => { const text = 'x'.repeat(SELECTION_LIMIT + 1) expect(buildSelectionInsert('/p/a.ts', '/p', undefined, text)).toBe('a.ts') }) }) describe('linesOfSelection', () => { const source = 'alpha\nbeta\ngamma alpha\ndelta\n' it('maps a unique hit to its source line span', () => { expect(linesOfSelection(source, 'beta')).toEqual({ start: 2, end: 2 }) expect(linesOfSelection(source, 'gamma alpha')).toEqual({ start: 3, end: 3 }) }) it('spans the end line when the selection crosses line breaks', () => { expect(linesOfSelection(source, 'beta\ngamma')).toEqual({ start: 2, end: 3 }) }) it('returns null when the text is missing from the source', () => { expect(linesOfSelection(source, 'nope')).toBeNull() }) it('returns null on an ambiguous (multi-hit) match', () => { expect(linesOfSelection(source, 'alpha')).toBeNull() }) it('strips a single trailing newline before searching (DOM block selections)', () => { expect(linesOfSelection(source, 'delta\n')).toEqual({ start: 4, end: 4 }) }) it('returns null for an empty selection', () => { expect(linesOfSelection(source, '')).toBeNull() expect(linesOfSelection(source, '\n')).toBeNull() }) })