/** * The address of a line in a diff: what a click writes into the fragment, and * what a reader arriving on that fragment gets back. */ import { describe, expect, it } from 'vitest'; import { extendSelection, extendSpan, inSelection, lineFragment, lineId, parseLineFragment, parseSourceFragment, sourceFragment, sourceLineId, } from '../src/lib/line-links.js'; /** @param {number|null} oldLine @param {number|null} newLine */ const row = (oldLine, newLine) => ({ oldLine, newLine }); describe('lineFragment and parseLineFragment', () => { it('carries one line there and back', () => { const selection = { path: 'src/pds.js', side: 'new', start: 12, end: 12 }; const fragment = lineFragment(selection); expect(fragment).toBe('line-src/pds.js-new:12'); expect(parseLineFragment(fragment)).toEqual(selection); }); it('carries a span there and back', () => { const selection = { path: 'src/pds.js', side: 'old', start: 12, end: 20 }; const fragment = lineFragment(selection); expect(fragment).toBe('line-src/pds.js-old:12-20'); expect(parseLineFragment(fragment)).toEqual(selection); }); // A path holds hyphens more often than not, and the fragment ends with a // number that could be read as part of one. it('reads a path with hyphens in it', () => { const selection = { path: 'packages/git-ui/src/diff-file.jsx', side: 'new', start: 3, end: 9, }; expect(parseLineFragment(lineFragment(selection))).toEqual(selection); }); it('answers nothing to a fragment naming something else', () => { expect(parseLineFragment('comment-src/pds.js-new:12')).toBeNull(); expect(parseLineFragment('')).toBeNull(); expect(parseLineFragment('line-src/pds.js-both:12')).toBeNull(); }); it('orders the ends of a span written backwards', () => { expect(parseLineFragment('line-a.js-new:20-12')).toEqual({ path: 'a.js', side: 'new', start: 12, end: 20, }); }); it('names the row a link lands on', () => { expect(lineId('src/pds.js', 'new:12')).toBe('line-src/pds.js-new:12'); }); }); describe('extendSelection', () => { const at = { path: 'a.js', side: 'new', at: 20 }; it('selects one line on a plain click', () => { expect(extendSelection(null, at)).toEqual({ path: 'a.js', side: 'new', start: 20, end: 20, }); }); it('takes the span from the line already selected', () => { const first = { path: 'a.js', side: 'new', start: 12, end: 12 }; expect(extendSelection(first, at, true)).toEqual({ path: 'a.js', side: 'new', start: 12, end: 20, }); }); // The line already selected is one end of the span, so a shift-click above // it takes the lines between rather than starting again. it('takes a span upwards from the line already selected', () => { const first = { path: 'a.js', side: 'new', start: 30, end: 34 }; expect(extendSelection(first, at, true)).toEqual({ path: 'a.js', side: 'new', start: 20, end: 30, }); }); it('starts again on another file or another side', () => { const elsewhere = { path: 'b.js', side: 'new', start: 12, end: 12 }; expect(extendSelection(elsewhere, at, true).start).toBe(20); const otherSide = { path: 'a.js', side: 'old', start: 12, end: 12 }; expect(extendSelection(otherSide, at, true).start).toBe(20); }); }); describe('inSelection', () => { const selection = { path: 'a.js', side: 'new', start: 12, end: 20 }; it('marks the rows the selection names', () => { expect(inSelection(selection, 'a.js', row(8, 12))).toBe(true); expect(inSelection(selection, 'a.js', row(null, 16))).toBe(true); expect(inSelection(selection, 'a.js', row(8, 11))).toBe(false); }); it('marks nothing in another file, or with nothing selected', () => { expect(inSelection(selection, 'b.js', row(null, 16))).toBe(false); expect(inSelection(null, 'a.js', row(null, 16))).toBe(false); }); // A deleted row is on the old side alone, which is the side it is addressed // by, so a selection on the new side passes over it. it('marks a deleted row only from the old side', () => { expect(inSelection(selection, 'a.js', row(16, null))).toBe(false); const old = { path: 'a.js', side: 'old', start: 12, end: 20 }; expect(inSelection(old, 'a.js', row(16, null))).toBe(true); }); }); describe('sourceFragment and parseSourceFragment', () => { it('carries a line and a span there and back', () => { expect(sourceFragment({ start: 12, end: 12 })).toBe('L12'); expect(sourceFragment({ start: 12, end: 20 })).toBe('L12-20'); expect(parseSourceFragment('L12')).toEqual({ start: 12, end: 12 }); expect(parseSourceFragment('L12-20')).toEqual({ start: 12, end: 20 }); }); // A forge writes the second number with its own L, and a reader pasting one // of those addresses means the same span. it('reads a span whose second number carries an L', () => { expect(parseSourceFragment('L12-L20')).toEqual({ start: 12, end: 20 }); }); it('answers nothing to a fragment naming something else', () => { expect(parseSourceFragment('line-a.js-new:12')).toBeNull(); expect(parseSourceFragment('readme')).toBeNull(); }); it('names the row a link lands on', () => { expect(sourceLineId(12)).toBe('L12'); expect(sourceLineId(12)).toBe(sourceFragment({ start: 12, end: 12 })); }); }); describe('extendSpan', () => { it('selects one line on a plain click', () => { expect(extendSpan({ start: 4, end: 9 }, 20)).toEqual({ start: 20, end: 20, }); }); it('takes the span from the line already selected, either way', () => { expect(extendSpan({ start: 12, end: 12 }, 20, true)).toEqual({ start: 12, end: 20, }); expect(extendSpan({ start: 30, end: 34 }, 20, true)).toEqual({ start: 20, end: 30, }); }); it('selects one line when there is nothing to reach from', () => { expect(extendSpan(null, 20, true)).toEqual({ start: 20, end: 20 }); }); });