| 1 | /** |
| 2 | * Portions Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | /* |
| 9 | |
| 10 | Copyright (c) 2020 Jun Wu |
| 11 | |
| 12 | Permission is hereby granted, free of charge, to any person obtaining a copy |
| 13 | of this software and associated documentation files (the "Software"), to deal |
| 14 | in the Software without restriction, including without limitation the rights |
| 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 16 | copies of the Software, and to permit persons to whom the Software is |
| 17 | furnished to do so, subject to the following conditions: |
| 18 | |
| 19 | The above copyright notice and this permission notice shall be included in all |
| 20 | copies or substantial portions of the Software. |
| 21 | |
| 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 28 | SOFTWARE. |
| 29 | |
| 30 | */ |
| 31 | |
| 32 | // @lint-ignore-every SPELL |
| 33 | |
| 34 | import type {LineIdx, Rev} from '../linelog'; |
| 35 | |
| 36 | import {describe, expect, it} from '@jest/globals'; |
| 37 | import * as Immutable from 'immutable'; |
| 38 | import {splitLines} from 'shared/diff'; |
| 39 | import {LineLog, executeCache} from '../linelog'; |
| 40 | |
| 41 | describe('LineLog', () => { |
| 42 | it('can be empty', () => { |
| 43 | const log = new LineLog(); |
| 44 | expect(log.maxRev).toBe(0); |
| 45 | expect(log.checkOut(0)).toBe(''); |
| 46 | }); |
| 47 | |
| 48 | it('supports a single edit', () => { |
| 49 | let log = new LineLog(); |
| 50 | log = log.recordText('c\nd\ne'); |
| 51 | expect(log.maxRev).toBe(1); |
| 52 | expect(log.checkOut(1)).toBe('c\nd\ne'); |
| 53 | |
| 54 | expect(log.checkOutLines(1)).toMatchObject([ |
| 55 | {data: 'c\n', rev: 1}, |
| 56 | {data: 'd\n', rev: 1}, |
| 57 | {data: 'e', rev: 1}, |
| 58 | {data: '', rev: 0}, |
| 59 | ]); |
| 60 | }); |
| 61 | |
| 62 | it('supports modifying rev 0', () => { |
| 63 | let log = new LineLog(); |
| 64 | log = log.recordText('c\n', 0); |
| 65 | expect(log.maxRev).toBe(0); |
| 66 | expect(log.checkOut(0)).toBe('c\n'); |
| 67 | expect(log.checkOutLines(0)[0]).toMatchObject({rev: 0}); |
| 68 | log = log.recordText('c\nd', 1); |
| 69 | expect(log.checkOutLines(1)[1]).toMatchObject({rev: 1}); |
| 70 | expect(log.checkOut(0)).toBe('c\n'); |
| 71 | expect(log.checkOutLines(0)[0]).toMatchObject({rev: 0}); |
| 72 | }); |
| 73 | |
| 74 | it('supports multiple edits', () => { |
| 75 | let log = new LineLog(); |
| 76 | log = log.recordText('c\nd\ne\n'); |
| 77 | log = log.recordText('d\ne\nf\n'); |
| 78 | expect(log.maxRev).toBe(2); |
| 79 | expect(log.checkOut(2)).toBe('d\ne\nf\n'); |
| 80 | expect(log.checkOutLines(2)).toMatchObject([ |
| 81 | {data: 'd\n', rev: 1, deleted: false}, |
| 82 | {data: 'e\n', rev: 1, deleted: false}, |
| 83 | {data: 'f\n', rev: 2, deleted: false}, |
| 84 | {data: '', rev: 0, deleted: false}, |
| 85 | ]); |
| 86 | }); |
| 87 | |
| 88 | it('supports checkout', () => { |
| 89 | let log = new LineLog(); |
| 90 | log = log.recordText('c\nd\ne\n'); |
| 91 | log = log.recordText('d\ne\nf\n'); |
| 92 | expect(log.checkOut(1)).toBe('c\nd\ne\n'); |
| 93 | expect(log.checkOutLines(1)[0].deleted).toBe(false); |
| 94 | expect(log.checkOut(0)).toBe(''); |
| 95 | expect(log.checkOutLines(0)).toMatchObject([{data: ''}]); |
| 96 | expect(log.checkOut(2)).toBe('d\ne\nf\n'); |
| 97 | expect(log.checkOutLines(2)[2].deleted).toBe(false); |
| 98 | }); |
| 99 | |
| 100 | it('supports checkout range', () => { |
| 101 | const log = new LineLog() |
| 102 | .recordText('c\nd\ne\n') // rev 1 |
| 103 | .recordText('d\ne\nf\n') // rev 2 |
| 104 | .recordText('e\ng\nf\n'); // rev 3 |
| 105 | |
| 106 | expect(log.checkOutLines(2, 1)).toMatchObject([ |
| 107 | {data: 'c\n', rev: 1, deleted: true}, // 'c' not in rev 2 |
| 108 | {data: 'd\n', rev: 1, deleted: false}, |
| 109 | {data: 'e\n', rev: 1, deleted: false}, |
| 110 | {data: 'f\n', rev: 2, deleted: false}, |
| 111 | {data: '', rev: 0, deleted: false}, // END |
| 112 | ]); |
| 113 | |
| 114 | expect(log.checkOutLines(3, 0)).toMatchObject([ |
| 115 | {data: 'c\n', rev: 1, deleted: true}, // 'c' not in rev 3 |
| 116 | {data: 'd\n', rev: 1, deleted: true}, // 'd' not in rev 3 |
| 117 | {data: 'e\n', rev: 1, deleted: false}, // 'e' in rev 3 |
| 118 | {data: 'g\n', rev: 3, deleted: false}, |
| 119 | {data: 'f\n', rev: 2, deleted: false}, |
| 120 | {data: '', rev: 0, deleted: false}, |
| 121 | ]); |
| 122 | |
| 123 | // should not reuse cache |
| 124 | expect(log.checkOut(3)).toBe('e\ng\nf\n'); |
| 125 | |
| 126 | expect(log.checkOutLines(3, 2)).toMatchObject([ |
| 127 | {data: 'd\n', rev: 1, deleted: true}, |
| 128 | {data: 'e\n', rev: 1, deleted: false}, |
| 129 | {data: 'g\n', rev: 3, deleted: false}, |
| 130 | {data: 'f\n', rev: 2, deleted: false}, |
| 131 | {data: ''}, |
| 132 | ]); |
| 133 | }); |
| 134 | |
| 135 | it('bumps rev when recording the same content', () => { |
| 136 | let log = new LineLog(); |
| 137 | log = log.recordText('a\n'); |
| 138 | expect(log.maxRev).toBe(1); |
| 139 | log = log.recordText('a\n'); |
| 140 | expect(log.maxRev).toBe(2); |
| 141 | log = log.recordText('a\n'); |
| 142 | expect(log.maxRev).toBe(3); |
| 143 | }); |
| 144 | |
| 145 | it('avoids checkout/execute calls for common edits', () => { |
| 146 | const log = new LineLog().recordText('a\nb\nc\nd\ne\n', 1); |
| 147 | |
| 148 | // Modifies 3 chunks. This does not introduce new cache |
| 149 | // miss, because: |
| 150 | // - checkout (calls execute) used by recordText can |
| 151 | // reuse cache populated by the previous recordText. |
| 152 | // This contributes a cache hit. |
| 153 | // - checkout used by editChunk is skipped, because |
| 154 | // recordText passes in `aLinesCache`. This does not |
| 155 | // change cache miss or hit. |
| 156 | const stats = (executeCache.stats = {miss: 0, hit: 0}); |
| 157 | log.recordText('A\nb\nC\nd\nE\n', 3); |
| 158 | expect(stats).toMatchObject({miss: 0, hit: 1}); |
| 159 | }); |
| 160 | |
| 161 | it('works with immutable.is', () => { |
| 162 | const log1 = new LineLog().recordText('a').recordText('b'); |
| 163 | const log2 = new LineLog({code: log1.code, maxRev: log1.maxRev}); |
| 164 | const log3 = new LineLog().recordText('a').recordText('b'); |
| 165 | |
| 166 | expect(Object.is(log1, log2)).toBeFalsy(); |
| 167 | expect(Immutable.is(log1, log2)).toBeTruthy(); |
| 168 | expect(Immutable.is(log1, log3)).toBeTruthy(); |
| 169 | }); |
| 170 | |
| 171 | it('provides human readable instructions', () => { |
| 172 | const log = logFromTextList(['a\n', 'b\n']); |
| 173 | // The instructions are internal details. For example, an |
| 174 | // optimization pass might remove unconditional jumps. |
| 175 | // Shall the output change, just update the test here. |
| 176 | expect(log.code.describeHumanReadableInstructions()).toEqual([ |
| 177 | '0: J 1', |
| 178 | '1: JL 1 3', |
| 179 | '2: J 4', |
| 180 | '3: END', |
| 181 | '4: JL 2 6', |
| 182 | '5: LINE 2 "b"', |
| 183 | '6: JGE 2 3', |
| 184 | '7: LINE 1 "a"', |
| 185 | '8: J 3', |
| 186 | ]); |
| 187 | }); |
| 188 | |
| 189 | describe('provides human readable insertion and deletion stacks', () => { |
| 190 | const show = (texts: string[]) => |
| 191 | logFromTextList(texts).code.describeHumanReadableInsDelStacks(); |
| 192 | |
| 193 | it('interleaved insertion and deletion trees', () => { |
| 194 | // First 3 revs are from https://sapling-scm.com/docs/internals/linelog |
| 195 | expect(show(['a\nb\nc\n', 'a\nb\n1\n2\nc\n', 'a\n2\nc\n', 'c\n', ''])).toEqual([ |
| 196 | '+----Insert (rev 1) ', |
| 197 | '| Delete (rev 4) ----+', |
| 198 | '| Line: a |', |
| 199 | '| Delete (rev 3) ---+|', |
| 200 | '| Line: b ||', |
| 201 | '|+---Insert (rev 2) ||', |
| 202 | '|| Line: 1 ||', |
| 203 | '|| ---+|', |
| 204 | '|| Line: 2 |', |
| 205 | '|+--- |', |
| 206 | '| ----+', |
| 207 | '| Delete (rev 5) ----+', |
| 208 | '| Line: c |', |
| 209 | '| ----+', |
| 210 | '+---- ', |
| 211 | ]); |
| 212 | }); |
| 213 | |
| 214 | it('insertions at the beginning and end are not nested', () => { |
| 215 | expect(show(['b\n', 'a\nb\n', 'a\nb\nc\n'])).toEqual([ |
| 216 | '+---Insert (rev 2) ', |
| 217 | '| Line: a ', |
| 218 | '+--- ', |
| 219 | '+---Insert (rev 1) ', |
| 220 | '| Line: b ', |
| 221 | '+--- ', |
| 222 | '+---Insert (rev 3) ', |
| 223 | '| Line: c ', |
| 224 | '+--- ', |
| 225 | ]); |
| 226 | }); |
| 227 | |
| 228 | it('insertion between old new revs is not nested', () => { |
| 229 | expect(show(['a\n', 'a\nc\n', 'a\nb\nc\n'])).toEqual([ |
| 230 | '+---Insert (rev 1) ', |
| 231 | '| Line: a ', |
| 232 | '+--- ', |
| 233 | '+---Insert (rev 3) ', |
| 234 | '| Line: b ', |
| 235 | '+--- ', |
| 236 | '+---Insert (rev 2) ', |
| 237 | '| Line: c ', |
| 238 | '+--- ', |
| 239 | ]); |
| 240 | }); |
| 241 | |
| 242 | it('insertion between new old revs is not nested', () => { |
| 243 | expect(show(['c\n', 'a\nc\n', 'a\nb\nc\n'])).toEqual([ |
| 244 | '+---Insert (rev 2) ', |
| 245 | '| Line: a ', |
| 246 | '+--- ', |
| 247 | '+---Insert (rev 3) ', |
| 248 | '| Line: b ', |
| 249 | '+--- ', |
| 250 | '+---Insert (rev 1) ', |
| 251 | '| Line: c ', |
| 252 | '+--- ', |
| 253 | ]); |
| 254 | }); |
| 255 | }); |
| 256 | |
| 257 | describe('supports editing previous revisions', () => { |
| 258 | it('edits stack bottom', () => { |
| 259 | const textList = ['a\n', 'a\nb\n', 'z\na\nb\n']; |
| 260 | let log = logFromTextList(textList); |
| 261 | |
| 262 | log = log.recordText('1\n2\n', 1); // replace rev 1 from "a" to "1 2" |
| 263 | expect(log.checkOut(1)).toBe('1\n2\n'); |
| 264 | expect(log.checkOut(2)).toBe('1\n2\nb\n'); |
| 265 | expect(log.checkOut(3)).toBe('z\n1\n2\nb\n'); |
| 266 | |
| 267 | log = log.recordText('', 1); // replace rev 1 to "" |
| 268 | expect(log.checkOut(1)).toBe(''); |
| 269 | expect(log.checkOut(2)).toBe('b\n'); |
| 270 | expect(log.checkOut(3)).toBe('z\nb\n'); |
| 271 | }); |
| 272 | |
| 273 | it('edits stack middle', () => { |
| 274 | const textList = ['c\nd\ne\n', 'b\nc\nd\n', 'a\nb\nc\nz\n']; |
| 275 | let log = logFromTextList(textList); |
| 276 | |
| 277 | log = log.recordText('b\nd\n', 2); // remove "c" from "b c d" in rev 2 |
| 278 | expect(log.checkOut(1)).toBe('c\nd\ne\n'); // rev 1 is unchanged, despite "c" comes from rev 1 |
| 279 | expect(log.checkOut(2)).toBe('b\nd\n'); |
| 280 | expect(log.checkOut(3)).toBe('a\nb\nz\n'); // "c" in rev 3 is also removed |
| 281 | |
| 282 | log = logFromTextList(textList); |
| 283 | log = log.recordText('b\nc\ny\ny\n', 2); // change "d" to "y y" from rev 2. |
| 284 | expect(log.checkOut(3)).toBe('a\nb\nc\nz\n'); // rev 3 is unchanged, since "d" was deleted |
| 285 | |
| 286 | log = logFromTextList(textList); |
| 287 | log = log.recordText('k\n', 2); // replace rev 2 with "k", this is a tricky case |
| 288 | expect(log.checkOut(3)).toBe('a\nk\n'); // "a k" is the current implementation, "a k z" might be better |
| 289 | }); |
| 290 | }); |
| 291 | |
| 292 | it('calculates dependencies using linelog instructions', () => { |
| 293 | const deps = (textList: string[]): (number | number[])[][] => { |
| 294 | const insertEOL = (text: string): string => |
| 295 | text |
| 296 | .split('') |
| 297 | .map(c => `${c}\n`) |
| 298 | .join(''); |
| 299 | const log = logFromTextList(textList.map(insertEOL)); |
| 300 | return flattenDepMap(log.calculateDepMap()); |
| 301 | }; |
| 302 | |
| 303 | expect(deps([])).toEqual([]); |
| 304 | |
| 305 | // Insertions. |
| 306 | expect(deps(['a'])).toEqual([[1, [0]]]); |
| 307 | expect(deps(['a', 'b'])).toEqual([ |
| 308 | [1, [0]], |
| 309 | [2, [1]], |
| 310 | ]); |
| 311 | expect(deps(['a', 'ab'])).toEqual([ |
| 312 | [1, [0]], |
| 313 | [2, [0]], |
| 314 | ]); |
| 315 | expect(deps(['b', 'ab'])).toEqual([ |
| 316 | [1, [0]], |
| 317 | [2, [0]], |
| 318 | ]); |
| 319 | expect(deps(['ad', 'abd', 'abcd'])).toEqual([ |
| 320 | [1, [0]], |
| 321 | [2, [1]], |
| 322 | [3, [1]], |
| 323 | ]); |
| 324 | expect(deps(['ad', 'acd', 'abcd'])).toEqual([ |
| 325 | [1, [0]], |
| 326 | [2, [1]], |
| 327 | [3, [1]], |
| 328 | ]); |
| 329 | |
| 330 | // Deletions. |
| 331 | expect(deps(['abcd', 'abd', 'ad', 'a'])).toEqual([ |
| 332 | [1, [0]], |
| 333 | [2, [1]], |
| 334 | [3, [1]], |
| 335 | [4, [1]], |
| 336 | ]); |
| 337 | expect(deps(['abcd', 'acd', 'ad', 'd'])).toEqual([ |
| 338 | [1, [0]], |
| 339 | [2, [1]], |
| 340 | [3, [1]], |
| 341 | [4, [1]], |
| 342 | ]); |
| 343 | |
| 344 | // Multi-rev insertion, then delete. |
| 345 | expect(deps(['abc', 'abcdef', '']).at(-1)).toEqual([3, [1, 2]]); |
| 346 | expect(deps(['abc', 'abcdef', 'af']).at(-1)).toEqual([3, [1, 2]]); |
| 347 | expect(deps(['abc', 'abcdef', 'cd']).at(-1)).toEqual([3, [1, 2]]); |
| 348 | |
| 349 | // Another test case. |
| 350 | const textList = ['abc', 'abcd', 'zabcd', 'zad', 'ad', 'adef', 'ade', 'ad1e', 'xyz']; |
| 351 | expect(deps(textList)).toStrictEqual([ |
| 352 | [1, [0]], |
| 353 | [2, [0]], |
| 354 | [3, [0]], |
| 355 | // deletes "bc" added by rev 1 |
| 356 | [4, [1]], |
| 357 | // deletes "z" added by rev 3 |
| 358 | [5, [3]], |
| 359 | // appends after "d" added by rev 2, considered as independent |
| 360 | [6, [0]], |
| 361 | // deletes "f" added by rev 6 |
| 362 | [7, [6]], |
| 363 | // inserts "1" between "d" (rev 2) and "e" (rev 6), still considered as independent |
| 364 | [8, [0]], |
| 365 | // replaces all: "a" (rev 1), "d" (rev 2), "1" (rev 8), "e" (rev 6) |
| 366 | // rev 4 is also a dependent for nested deletions. |
| 367 | [9, [1, 2, 4, 6, 8]], |
| 368 | ]); |
| 369 | }); |
| 370 | |
| 371 | it('produces flatten lines', () => { |
| 372 | const textList = ['a\nb\nc\n', 'b\nc\nd\ne\n', 'a\nc\nd\nf\n']; |
| 373 | const log = logFromTextList(textList); |
| 374 | const lines = log.flatten(); |
| 375 | expect(lines.toJS()).toEqual( |
| 376 | [ |
| 377 | ['a', [1]], |
| 378 | ['a', [3]], |
| 379 | ['b', [1, 2]], |
| 380 | ['c', [1, 2, 3]], |
| 381 | ['d', [2, 3]], |
| 382 | ['f', [3]], |
| 383 | ['e', [2]], |
| 384 | ].map(([line, revs]) => ({revs, data: `${line}\n`})), |
| 385 | ); |
| 386 | // Verify the flatten lines against definition - if "revs" contains the rev, |
| 387 | // then the line is included in "rev". |
| 388 | for (let rev = 1; rev <= textList.length; rev++) { |
| 389 | const text = lines |
| 390 | .filter(line => line.revs.has(rev)) |
| 391 | .map(line => line.data) |
| 392 | .join(''); |
| 393 | expect(text).toBe(textList[rev - 1]); |
| 394 | } |
| 395 | }); |
| 396 | |
| 397 | it('supports truncation', () => { |
| 398 | const textList = ['a\nb\nc\n', 'b\nc\nd\n', 'b\nd\ne\n', 'f\n']; |
| 399 | const log = logFromTextList(textList); |
| 400 | // Note: rev 0 is empty. |
| 401 | for (let truncateRev = 0; truncateRev < textList.length; truncateRev++) { |
| 402 | const truncatedLog = log.truncate(truncateRev); |
| 403 | expect(truncatedLog.maxRev).toBe(Math.max(0, truncateRev - 1)); |
| 404 | for (let rev = 0; rev < textList.length; rev++) { |
| 405 | const text = truncatedLog.checkOut(rev); |
| 406 | if (rev < truncateRev) { |
| 407 | expect(text).toBe(rev < 1 ? '' : textList[rev - 1]); |
| 408 | } else { |
| 409 | // By contract, rev >= truncateRev are removed, so their content should be the same as truncateRev - 1. |
| 410 | expect(text).toBe(truncateRev <= 1 ? '' : log.checkOut(truncateRev - 1)); |
| 411 | } |
| 412 | } |
| 413 | // We can still append content to the truncatedLog. |
| 414 | const text = 'a\nc\ne\n'; |
| 415 | const modifiedLog = truncatedLog.recordText(text); |
| 416 | expect(modifiedLog.checkOut(modifiedLog.maxRev)).toBe(text); |
| 417 | for (let rev = 0; rev < truncateRev; rev++) { |
| 418 | expect(modifiedLog.checkOut(rev)).toBe(log.checkOut(rev)); |
| 419 | } |
| 420 | } |
| 421 | }); |
| 422 | |
| 423 | // Ported from test-linelog-edits.py (D3709431) |
| 424 | // Compare LineLog.editChunk against List<string>.splice edits. |
| 425 | it('stress tests against random edits', () => { |
| 426 | const maxDeltaA = 10; // max(a2 - a1) |
| 427 | const maxDeltaB = 10; // max(b2 - b1) |
| 428 | const maxB1 = 0xffffff; |
| 429 | |
| 430 | function randInt(min: number, max: number): number { |
| 431 | return Math.floor(Math.random() * (max - min + 1) + min); |
| 432 | } |
| 433 | |
| 434 | function randBool(): boolean { |
| 435 | return Math.random() < 0.5; |
| 436 | } |
| 437 | |
| 438 | function* generateCases( |
| 439 | endRev = 1000, |
| 440 | ): Generator<[Immutable.List<string>, Rev, LineIdx, LineIdx, LineIdx, LineIdx, string[]]> { |
| 441 | // Maintain `lines` as an alternative to LineLog |
| 442 | let lines: Immutable.List<string> = Immutable.List(); |
| 443 | for (let rev = 0; rev <= endRev; ++rev) { |
| 444 | const n = lines.size; |
| 445 | const a1 = randInt(0, n); |
| 446 | const a2 = randInt(a1, Math.min(n, a1 + maxDeltaA)); |
| 447 | const b1 = randInt(0, maxB1); |
| 448 | const b2 = randInt(b1, b1 + maxDeltaB); |
| 449 | const bLines: string[] = []; |
| 450 | for (let bIdx = b1; bIdx < b2; bIdx++) { |
| 451 | const line = randBool() ? '\n' : `${rev}:${bIdx}\n`; |
| 452 | bLines.push(line); |
| 453 | } |
| 454 | lines = lines.splice(a1, a2 - a1, ...bLines); |
| 455 | yield [lines, rev, a1, a2, b1, b2, bLines]; |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | const cases = [...generateCases()]; |
| 460 | let log = new LineLog(); |
| 461 | |
| 462 | // The use of aLines cache prevents cache miss. |
| 463 | // It can reduce editChunk time for 100 revs from 240ms to 8ms. |
| 464 | const aLines = [...log.checkOutLines(0)]; |
| 465 | executeCache.stats = {miss: 0}; |
| 466 | cases.forEach(([_lines, rev, a1, a2, _b1, _b2, bLines]) => { |
| 467 | log = log.editChunk(log.maxRev, a1, a2, rev, bLines, aLines); |
| 468 | }); |
| 469 | expect(executeCache.stats).toMatchObject({miss: 0}); |
| 470 | |
| 471 | // Check that every rev can be checked out fine. |
| 472 | cases.forEach(([lines, rev, _a1, _a2, _b1, _b2, _bLines]) => { |
| 473 | expect(log.checkOut(rev)).toBe(lines.join('')); |
| 474 | }); |
| 475 | }); |
| 476 | |
| 477 | describe('supports remapping revisions', () => { |
| 478 | it('updates maxRev up', () => { |
| 479 | const log = logFromTextList(['a', 'b']).remapRevs(new Map([[1, 10]])); |
| 480 | expect(log.maxRev).toBe(10); |
| 481 | }); |
| 482 | |
| 483 | it('updates maxRev down', () => { |
| 484 | const log = new LineLog().recordText('a\n', 10).remapRevs(new Map([[10, 5]])); |
| 485 | expect(log.maxRev).toBe(5); |
| 486 | }); |
| 487 | |
| 488 | it('invalidates previous checkout', () => { |
| 489 | let log = logFromTextList(['b\n', 'b\nc\n', 'a\nb\nc\n']); |
| 490 | expect(log.checkOut(2)).toBe('b\nc\n'); |
| 491 | log = log.remapRevs( |
| 492 | new Map([ |
| 493 | [2, 3], |
| 494 | [3, 2], |
| 495 | ]), |
| 496 | ); |
| 497 | expect(log.checkOut(2)).not.toBe('b\nc\n'); |
| 498 | }); |
| 499 | |
| 500 | describe('can reorder changes', () => { |
| 501 | const defaultSwap: [Rev, Rev] = [2, 3]; |
| 502 | |
| 503 | /** Follow the swap. For example, mapSwap(2, [2, 3]) is 3. */ |
| 504 | const mapSwap = (rev: Rev, swap: [Rev, Rev] = defaultSwap) => |
| 505 | rev === swap[0] ? swap[1] : rev === swap[1] ? swap[0] : rev; |
| 506 | |
| 507 | /** |
| 508 | * Swap revs from revs to newRevs. All "line"s are added, by different revs. |
| 509 | * For example, when lines = ['a\n', 'b\n', 'c\n'], lineAddedOrder = [1, 3, 2], |
| 510 | * it means 'a' was added by rev 1, 'b' by rev 3, 'c' by rev 2, so the 3 revs |
| 511 | * are ['a\n', 'a\nc\n', 'a\nb\n']. |
| 512 | * |
| 513 | * By default, this test takes 3 revs and swap rev 2 and 3. |
| 514 | */ |
| 515 | function testReorderInsertions( |
| 516 | lines: string[], |
| 517 | lineAddedOrder: Rev[], |
| 518 | opts?: { |
| 519 | swap?: [Rev, Rev] /* 2 revs to swap, default: [2, 3] */; |
| 520 | join?: string /* join character, default: '' */; |
| 521 | expectedDepMapOverride?: [Rev, Rev[]][] /* override the expected depMap */; |
| 522 | expectedTextOverride?: string[] /* override the expected texts after swapping */; |
| 523 | }, |
| 524 | ) { |
| 525 | expect(lines.length).toBe(lineAddedOrder.length); |
| 526 | const revs = Array.from({length: lines.length}, (_, i) => i + 1); /* ex. [1, 2, 3] */ |
| 527 | const swap = opts?.swap ?? defaultSwap; |
| 528 | const optJoin = opts?.join ?? ''; |
| 529 | const getTexts = (revOrder: Rev[], join = optJoin): string[] => { |
| 530 | const revSet = new Set<Rev>(); |
| 531 | return revOrder.map(rev => { |
| 532 | revSet.add(rev); |
| 533 | return lines.filter((_l, i) => revSet.has(lineAddedOrder[i])).join(join); |
| 534 | }); |
| 535 | }; |
| 536 | const texts = getTexts(revs); |
| 537 | const log = logFromTextList(texts); |
| 538 | |
| 539 | // Nothing depends on each other. |
| 540 | expect(flattenDepMap(log.calculateDepMap())).toEqual( |
| 541 | opts?.expectedDepMapOverride ?? revs.map(r => [r, [0]]), |
| 542 | ); |
| 543 | |
| 544 | // Reorder. |
| 545 | const reorderedLog = log.remapRevs(new Map([swap, [swap[1], swap[0]]])); |
| 546 | |
| 547 | // Check reorder result. |
| 548 | // Remove "join" for easier comparison. |
| 549 | const removeJoin = (text: string) => |
| 550 | splitLines(text) |
| 551 | .filter(t => t !== optJoin) |
| 552 | .join(''); |
| 553 | const reorderedRevs = revs.map(r => mapSwap(r, swap)); |
| 554 | const expectedReorderedText = opts?.expectedTextOverride ?? getTexts(reorderedRevs, ''); |
| 555 | revs.forEach((rev, i) => { |
| 556 | expect(removeJoin(reorderedLog.checkOut(rev))).toBe(expectedReorderedText[i]); |
| 557 | }); |
| 558 | |
| 559 | return reorderedLog; |
| 560 | } |
| 561 | |
| 562 | const threeRevPermutations = [ |
| 563 | [1, 2, 3], |
| 564 | [1, 3, 2], |
| 565 | [2, 1, 3], |
| 566 | [2, 3, 1], |
| 567 | [3, 1, 2], |
| 568 | [3, 2, 1], |
| 569 | ]; |
| 570 | |
| 571 | /** Make the test a bit more interesting with multi-line input. */ |
| 572 | const charToFunction = (c: string): string => `function ${c} () {\n return '${c}';\n}\n`; |
| 573 | |
| 574 | const abcTexts = ['a\n', 'b\n', 'c\n']; |
| 575 | const functionTexts = ['a', 'b', 'c'].map(charToFunction); |
| 576 | |
| 577 | threeRevPermutations.forEach(revOrder => { |
| 578 | it(`insert 'a','b','c' in ${revOrder} order`, () => { |
| 579 | const log = testReorderInsertions(abcTexts, revOrder); |
| 580 | expect(log.checkOutLines(3)).toMatchObject([ |
| 581 | {data: 'a\n', rev: mapSwap(revOrder[0])}, |
| 582 | {data: 'b\n', rev: mapSwap(revOrder[1])}, |
| 583 | {data: 'c\n', rev: mapSwap(revOrder[2])}, |
| 584 | {data: '', rev: 0}, |
| 585 | ]); |
| 586 | }); |
| 587 | |
| 588 | it(`insert 3 functions in ${revOrder} order`, () => { |
| 589 | testReorderInsertions(functionTexts, revOrder); |
| 590 | }); |
| 591 | |
| 592 | it(`insert 3 functions with new line join, in ${revOrder} order`, () => { |
| 593 | testReorderInsertions(abcTexts, revOrder, {join: '\n'}); |
| 594 | }); |
| 595 | }); |
| 596 | }); |
| 597 | |
| 598 | it('takes a mapping function', () => { |
| 599 | const log = logFromTextList(['a\n', 'a\nb\n']).remapRevs(r => |
| 600 | r === 2 ? 1 : r === 1 ? 2 : r, |
| 601 | ); |
| 602 | expect(log.checkOut(1)).toBe('b\n'); |
| 603 | expect(log.checkOut(2)).toBe('a\nb\n'); |
| 604 | }); |
| 605 | |
| 606 | it('can merge changes', () => { |
| 607 | const log = logFromTextList(['b\n', 'b\nc\n', 'a\nb\nc\n']).remapRevs(new Map([[2, 1]])); |
| 608 | expect(log.checkOut(1)).toBe('b\nc\n'); |
| 609 | expect(log.checkOut(2)).toBe('b\nc\n'); |
| 610 | expect(log.checkOut(3)).toBe('a\nb\nc\n'); |
| 611 | }); |
| 612 | |
| 613 | it('can insert changes', () => { |
| 614 | const log = logFromTextList(['b\n', 'b\nc\n']) |
| 615 | .remapRevs(new Map([[2, 3]])) |
| 616 | .recordText('a\nb\n', 2); |
| 617 | expect(log.checkOut(3)).toBe('a\nb\nc\n'); |
| 618 | }); |
| 619 | |
| 620 | it('does not check dependencies or conflicts', () => { |
| 621 | // rev 2: +b between a and c. rev 2 depends on rev 1. |
| 622 | const log = logFromTextList(['a\nc\n', 'a\nb\nc\n']).remapRevs( |
| 623 | new Map([ |
| 624 | [1, 2], |
| 625 | [2, 1], |
| 626 | ]), |
| 627 | ); |
| 628 | // rev 1 is now empty, not 'b'. |
| 629 | expect(log.checkOut(1)).toBe(''); |
| 630 | expect(log.checkOut(2)).toBe('a\nb\nc\n'); |
| 631 | }); |
| 632 | }); |
| 633 | }); |
| 634 | |
| 635 | function logFromTextList(textList: string[]): LineLog { |
| 636 | let log = new LineLog(); |
| 637 | textList.forEach(text => (log = log.recordText(text))); |
| 638 | return log; |
| 639 | } |
| 640 | |
| 641 | function flattenDepMap(depMap: Map<Rev, Set<Rev>>): [Rev, Rev[]][] { |
| 642 | return [...depMap.entries()].map(([rev, set]) => [rev, [...set].sort()] as [Rev, Rev[]]).sort(); |
| 643 | } |
| 644 | |