Full document, spreadsheet, slideshow, and diagram tooling
1import { describe, it, expect } from 'vitest';
2import {
3 extractFormulaRanges,
4 assignRangeColors,
5 RANGE_COLORS,
6} from '../src/sheets/range-highlight.js';
7
8/**
9 * Tests for Range Highlighting While Editing (Chainlink #95).
10 *
11 * When editing a formula, referenced cells/ranges should be highlighted
12 * on the grid with colored borders/overlays. Each unique reference gets
13 * a color from a rotating palette.
14 */
15
16describe('RANGE_COLORS', () => {
17 it('is an array of 6 colors', () => {
18 expect(Array.isArray(RANGE_COLORS)).toBe(true);
19 expect(RANGE_COLORS.length).toBe(6);
20 });
21
22 it('each color is a string', () => {
23 for (const c of RANGE_COLORS) {
24 expect(typeof c).toBe('string');
25 }
26 });
27});
28
29describe('extractFormulaRanges', () => {
30 it('is a function', () => {
31 expect(typeof extractFormulaRanges).toBe('function');
32 });
33
34 it('returns an array', () => {
35 const result = extractFormulaRanges('SUM(A1:B5)');
36 expect(Array.isArray(result)).toBe(true);
37 });
38
39 it('extracts a single cell reference', () => {
40 const result = extractFormulaRanges('A1+B1');
41 expect(result.length).toBe(2);
42 expect(result[0].ref).toBe('A1');
43 expect(result[1].ref).toBe('B1');
44 });
45
46 it('extracts a range reference', () => {
47 const result = extractFormulaRanges('SUM(A1:B5)');
48 const range = result.find(r => r.ref === 'A1:B5');
49 expect(range).toBeTruthy();
50 });
51
52 it('each result has ref, startIndex, and endIndex', () => {
53 const result = extractFormulaRanges('SUM(A1:B5)');
54 for (const r of result) {
55 expect(r).toHaveProperty('ref');
56 expect(r).toHaveProperty('startIndex');
57 expect(r).toHaveProperty('endIndex');
58 expect(typeof r.ref).toBe('string');
59 expect(typeof r.startIndex).toBe('number');
60 expect(typeof r.endIndex).toBe('number');
61 }
62 });
63
64 it('extracts absolute references', () => {
65 const result = extractFormulaRanges('$A$1+$B$2');
66 expect(result.length).toBe(2);
67 expect(result[0].ref).toBe('$A$1');
68 expect(result[1].ref).toBe('$B$2');
69 });
70
71 it('extracts cross-sheet references', () => {
72 const result = extractFormulaRanges('Sheet2!A1+B1');
73 expect(result.some(r => r.ref === 'Sheet2!A1')).toBe(true);
74 });
75
76 it('extracts multiple different references', () => {
77 const result = extractFormulaRanges('A1+B1+C1:D5');
78 expect(result.length).toBe(3);
79 });
80
81 it('returns positions that match the formula text', () => {
82 const formula = 'SUM(A1:B5)';
83 const result = extractFormulaRanges(formula);
84 for (const r of result) {
85 expect(formula.substring(r.startIndex, r.endIndex)).toBe(r.ref);
86 }
87 });
88
89 it('returns empty array for formulas with no references', () => {
90 const result = extractFormulaRanges('42+10');
91 expect(result).toEqual([]);
92 });
93
94 it('handles nested function calls', () => {
95 const result = extractFormulaRanges('IF(SUM(A1:A10)>100,B1,C1)');
96 expect(result.length).toBe(3);
97 expect(result.some(r => r.ref === 'A1:A10')).toBe(true);
98 expect(result.some(r => r.ref === 'B1')).toBe(true);
99 expect(result.some(r => r.ref === 'C1')).toBe(true);
100 });
101
102 it('handles quoted sheet names', () => {
103 const result = extractFormulaRanges("'My Sheet'!A1+B2");
104 expect(result.some(r => r.ref.includes('My Sheet'))).toBe(true);
105 });
106
107 it('does not extract function names as references', () => {
108 const result = extractFormulaRanges('SUM(A1)');
109 expect(result.every(r => r.ref !== 'SUM')).toBe(true);
110 });
111});
112
113describe('assignRangeColors', () => {
114 it('is a function', () => {
115 expect(typeof assignRangeColors).toBe('function');
116 });
117
118 it('assigns a color to each range', () => {
119 const ranges = [
120 { ref: 'A1', startIndex: 0, endIndex: 2 },
121 { ref: 'B1', startIndex: 3, endIndex: 5 },
122 ];
123 const result = assignRangeColors(ranges);
124 expect(Array.isArray(result)).toBe(true);
125 expect(result.length).toBe(2);
126 for (const r of result) {
127 expect(r).toHaveProperty('color');
128 expect(typeof r.color).toBe('string');
129 }
130 });
131
132 it('gives same ref same color', () => {
133 const ranges = [
134 { ref: 'A1', startIndex: 0, endIndex: 2 },
135 { ref: 'B1', startIndex: 3, endIndex: 5 },
136 { ref: 'A1', startIndex: 6, endIndex: 8 },
137 ];
138 const result = assignRangeColors(ranges);
139 const a1Colors = result.filter(r => r.ref === 'A1').map(r => r.color);
140 expect(new Set(a1Colors).size).toBe(1);
141 });
142
143 it('gives different refs different colors (up to palette size)', () => {
144 const ranges = [
145 { ref: 'A1', startIndex: 0, endIndex: 2 },
146 { ref: 'B1', startIndex: 3, endIndex: 5 },
147 { ref: 'C1', startIndex: 6, endIndex: 8 },
148 ];
149 const result = assignRangeColors(ranges);
150 const colors = result.map(r => r.color);
151 expect(new Set(colors).size).toBe(3);
152 });
153
154 it('cycles colors after palette is exhausted', () => {
155 const ranges = [];
156 const refs = ['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1'];
157 for (let i = 0; i < refs.length; i++) {
158 ranges.push({ ref: refs[i], startIndex: i * 3, endIndex: i * 3 + 2 });
159 }
160 const result = assignRangeColors(ranges);
161 // The 7th ref should cycle back to the 1st color
162 expect(result[6].color).toBe(result[0].color);
163 });
164
165 it('preserves original ref, startIndex, endIndex', () => {
166 const ranges = [{ ref: 'A1', startIndex: 0, endIndex: 2 }];
167 const result = assignRangeColors(ranges);
168 expect(result[0].ref).toBe('A1');
169 expect(result[0].startIndex).toBe(0);
170 expect(result[0].endIndex).toBe(2);
171 });
172
173 it('returns empty array for empty input', () => {
174 const result = assignRangeColors([]);
175 expect(result).toEqual([]);
176 });
177});
178
179// =====================================================================
180// Edge cases
181// =====================================================================
182
183describe('extractFormulaRanges — edge cases', () => {
184 it('handles empty string', () => {
185 expect(extractFormulaRanges('')).toEqual([]);
186 });
187
188 it('handles formula with only numbers and operators', () => {
189 expect(extractFormulaRanges('1+2*3')).toEqual([]);
190 });
191
192 it('handles string literals (should not extract refs inside strings)', () => {
193 // Strings in formulas are in quotes, but extractFormulaRanges operates on raw formula text
194 // The regex may pick up refs inside strings — testing actual behavior
195 const result = extractFormulaRanges('"A1"');
196 // A1 inside quotes may or may not be extracted depending on implementation
197 expect(Array.isArray(result)).toBe(true);
198 });
199
200 it('extracts mixed absolute and relative references', () => {
201 const result = extractFormulaRanges('$A1+B$2+$C$3');
202 expect(result).toHaveLength(3);
203 expect(result[0].ref).toBe('$A1');
204 expect(result[1].ref).toBe('B$2');
205 expect(result[2].ref).toBe('$C$3');
206 });
207
208 it('extracts cross-sheet range references', () => {
209 const result = extractFormulaRanges('Sheet2!A1:B5');
210 expect(result.some(r => r.ref.includes('Sheet2'))).toBe(true);
211 });
212
213 it('handles multiple cross-sheet refs', () => {
214 const result = extractFormulaRanges('Sheet1!A1+Sheet2!B2');
215 expect(result.length).toBeGreaterThanOrEqual(2);
216 });
217
218 it('extracts large column refs like AA1, AZ99', () => {
219 const result = extractFormulaRanges('AA1+AZ99');
220 expect(result.some(r => r.ref === 'AA1')).toBe(true);
221 expect(result.some(r => r.ref === 'AZ99')).toBe(true);
222 });
223
224 it('handles a complex nested formula', () => {
225 const result = extractFormulaRanges('IF(AND(A1>0,B1<100),SUM(C1:C10),D1)');
226 expect(result.some(r => r.ref === 'A1')).toBe(true);
227 expect(result.some(r => r.ref === 'B1')).toBe(true);
228 expect(result.some(r => r.ref === 'C1:C10')).toBe(true);
229 expect(result.some(r => r.ref === 'D1')).toBe(true);
230 });
231
232 it('does not extract TRUE or FALSE as references', () => {
233 const result = extractFormulaRanges('IF(TRUE,A1,FALSE)');
234 expect(result.every(r => r.ref !== 'TRUE' && r.ref !== 'FALSE')).toBe(true);
235 });
236});
237
238describe('assignRangeColors — edge cases', () => {
239 it('all 6 unique refs get distinct colors', () => {
240 const ranges = Array.from({ length: 6 }, (_, i) => ({
241 ref: `${String.fromCharCode(65 + i)}1`,
242 startIndex: i * 3,
243 endIndex: i * 3 + 2,
244 }));
245 const result = assignRangeColors(ranges);
246 const colors = new Set(result.map(r => r.color));
247 expect(colors.size).toBe(6);
248 });
249
250 it('colors come from RANGE_COLORS palette', () => {
251 const ranges = [{ ref: 'A1', startIndex: 0, endIndex: 2 }];
252 const result = assignRangeColors(ranges);
253 expect(RANGE_COLORS).toContain(result[0].color);
254 });
255
256 it('handles single range', () => {
257 const result = assignRangeColors([{ ref: 'A1:B5', startIndex: 0, endIndex: 5 }]);
258 expect(result).toHaveLength(1);
259 expect(result[0].color).toBeTruthy();
260 expect(result[0].ref).toBe('A1:B5');
261 });
262
263 it('duplicate refs in sequence share same color', () => {
264 const ranges = [
265 { ref: 'A1', startIndex: 0, endIndex: 2 },
266 { ref: 'B1', startIndex: 3, endIndex: 5 },
267 { ref: 'A1', startIndex: 6, endIndex: 8 },
268 { ref: 'B1', startIndex: 9, endIndex: 11 },
269 ];
270 const result = assignRangeColors(ranges);
271 expect(result[0].color).toBe(result[2].color);
272 expect(result[1].color).toBe(result[3].color);
273 expect(result[0].color).not.toBe(result[1].color);
274 });
275});