文件
mynode/packages/lib/models/Revision.test.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

414 行
14 KiB
TypeScript

import { expectNotThrow, naughtyStrings, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Note from '../models/Note';
import Revision, { ObjectPatch } from '../models/Revision';
import { NoteEntity, RevisionEntity } from '../services/database/types';
import BaseModel from '../BaseModel';
const oneDayMs = 24 * 60 * 60 * 1000;
const halfDayMs = 12 * 60 * 60 * 1000;
// cSpell:disable
const testBody = `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text
ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five
centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset
sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.`;
// cSpell:enable
const createChangeAndRevision = async (note: NoteEntity, parentRevId: string = null) => {
const parentRev = parentRevId ? await Revision.load(parentRevId) : await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
note.title = `${note.title}a`;
note.body = `${note.body}a`;
jest.advanceTimersByTime(oneDayMs);
note.updated_time = Date.now();
const output: RevisionEntity = {
item_type: BaseModel.TYPE_NOTE,
item_id: note.id,
item_updated_time: note.updated_time,
encryption_applied: 0,
};
if (!parentRev) {
output.title_diff = Revision.createTextPatch('', note.title);
output.body_diff = Revision.createTextPatch('', note.body);
output.metadata_diff = Revision.createObjectPatch({}, {});
} else {
const merged = await Revision.mergeDiffs(parentRev);
output.parent_id = parentRev.id;
output.title_diff = Revision.createTextPatch(merged.title, note.title);
output.body_diff = Revision.createTextPatch(merged.body, note.body);
output.metadata_diff = Revision.createObjectPatch({}, {});
}
return Revision.save(output);
};
describe('models/Revision', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
jest.useFakeTimers({ advanceTimers: true });
});
it('should add lock defaults for old sync revisions', () => {
expect(Revision.filter({
is_locked: undefined,
})).toMatchObject({
is_locked: 0,
});
expect(Revision.filter({
is_locked: null,
})).toMatchObject({
is_locked: 0,
});
});
it('should create patches of text and apply it', (async () => {
const note1 = await Note.save({ body: 'my note\nsecond line' });
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
const merged = Revision.applyTextPatch(note1.body, patch);
expect(merged).toBe('my new note\nsecond line');
}));
it('should check if it is an empty revision', async () => {
const testCases = [
[false, {
title_diff: '',
body_diff: '',
metadata_diff: '{"new":{"id":"aaa"},"deleted":[]}',
}],
[true, {
title_diff: '',
body_diff: '',
metadata_diff: '',
}],
[true, {
title_diff: '[]',
body_diff: '',
metadata_diff: '{"new":{},"deleted":[]}',
}],
[true, {
title_diff: '',
body_diff: '[]',
metadata_diff: '{"new":{},"deleted":[]}',
}],
[false, {
title_diff: '[{"diffs":[[1,"hello"]],"start1":0,"start2":0,"length1":0,"length2":5}]',
body_diff: '[]',
metadata_diff: '{"new":{},"deleted":[]}',
}],
];
for (const t of testCases) {
const [expected, input] = t;
expect(Revision.isEmptyRevision(input as RevisionEntity)).toBe(expected);
}
});
it('should not fail to create revisions on naughty strings', (async () => {
// Previously this pattern would fail:
// - Create a patch between an empty string and smileys
// - Use that patch on the empty string to get back the smileys
// - Create a patch between those smileys and new smileys
// https://github.com/JackuB/diff-match-patch/issues/22
const nss = await naughtyStrings();
// First confirm that it indeed fails with the legacy approach.
let errorCount = 0;
for (let i = 0; i < nss.length - 1; i++) {
const ns1 = nss[i];
const ns2 = nss[i + 1];
try {
const patchText = Revision.createTextPatchLegacy('', ns1);
const patchedText = Revision.applyTextPatchLegacy('', patchText);
Revision.createTextPatchLegacy(patchedText, ns2);
} catch (error) {
errorCount++;
}
}
expect(errorCount).toBe(10);
// Now feed the naughty list again but using the new approach. In that
// case it should work fine.
await expectNotThrow(async () => {
for (let i = 0; i < nss.length - 1; i++) {
const ns1 = nss[i];
const ns2 = nss[i + 1];
const patchText = Revision.createTextPatch('', ns1);
const patchedText = Revision.applyTextPatch('', patchText);
Revision.createTextPatch(patchedText, ns2);
}
});
}));
it('should successfully handle legacy patches', async () => {
// The code should handle applying a series of new style patches and
// legacy patches, and the correct text should be recovered at the end.
const changes = [
'',
'one',
'one three',
'one two three',
];
const patches = [
Revision.createTextPatch(changes[0], changes[1]),
Revision.createTextPatchLegacy(changes[1], changes[2]),
Revision.createTextPatch(changes[2], changes[3]),
];
// Sanity check - verify that the patches are as expected
expect(patches[0].substr(0, 2)).toBe('[{'); // New
expect(patches[1].substr(0, 2)).toBe('@@'); // Legacy
expect(patches[2].substr(0, 2)).toBe('[{'); // New
let finalString = Revision.applyTextPatch(changes[0], patches[0]);
finalString = Revision.applyTextPatch(finalString, patches[1]);
finalString = Revision.applyTextPatch(finalString, patches[2]);
expect(finalString).toBe('one two three');
});
it('should create patches of objects and apply it', (async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const newObject = {
one: '123',
three: '999',
};
const patch = Revision.createObjectPatch(oldObject, newObject);
const merged = Revision.applyObjectPatch(oldObject, patch);
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
}));
it('should handle invalid object patch', (async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const brokenPatch = `{"new":{"four":"444
"},"deleted":["one"]}`;
const expected = {
two: '456',
three: '789',
four: '444',
};
const merged = Revision.applyObjectPatch(oldObject, brokenPatch);
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
}));
it('should not strip off newlines from object values', (async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const patch: ObjectPatch = {
'new': {
'four': 'one line\ntwo line',
},
'deleted': [],
};
const expected = {
one: '123',
two: '456',
three: '789',
four: 'one line\ntwo line',
};
const merged = Revision.applyObjectPatch(oldObject, JSON.stringify(patch));
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
}));
it('should move target revision to the top', (async () => {
const revs = [
{ id: '123' },
{ id: '456' },
{ id: '789' },
];
let newRevs;
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('789');
expect(newRevs[2].id).toBe('456');
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('456');
expect(newRevs[2].id).toBe('789');
}));
// cSpell:disable
it('should create patch stats', (async () => {
const tests = [
{
patch: `@@ -625,16 +625,48 @@
rrupted download
+%0A- %5B %5D Fix mobile screen options`,
expected: [-0, +32],
},
{
patch: `@@ -564,17 +564,17 @@
ages%0A- %5B
-
+x
%5D Check `,
expected: [-1, +1],
},
{
patch: `@@ -1022,56 +1022,415 @@
.%0A%0A#
- How to view a note history%0A%0AWhile all the apps
+%C2%A0How does it work?%0A%0AAll the apps save a version of the modified notes every 10 minutes.
%0A%0A# `,
expected: [-(19 + 27 + 2), 17 + 67 + 4],
},
{
patch: '',
expected: [-0, +0],
},
];
for (const test of tests) {
const stats = Revision.patchStats(test.patch);
expect(stats.removed).toBe(-test.expected[0]);
expect(stats.added).toBe(test.expected[1]);
}
}));
// cSpell:enable
test('deleteOldRevisions should merge 2 revisions when the revision chain has 2 branches', (async () => {
const revs = [];
const note = await Note.save({ title: 'test', body: testBody });
revs.push(await createChangeAndRevision(note)); // Rev 1
revs.push(await createChangeAndRevision(note)); // Rev 2a
revs.push(await createChangeAndRevision(note)); // Rev 3
revs.push(await createChangeAndRevision(note, revs[0].id)); // Rev 2b
revs.push(await createChangeAndRevision(note, revs[2].id)); // Rev 4
revs.push(await createChangeAndRevision(note)); // Rev 5
const timeBeforeCleaning = Date.now();
const ttl = (2 * oneDayMs) + halfDayMs; // Delete revs 1-3
await Revision.deleteOldRevisions(ttl);
expect(revs[3].body_diff.length).toBeLessThan(testBody.length);
expect(revs[4].body_diff.length).toBeLessThan(testBody.length);
expect(revs[5].body_diff.length).toBeLessThan(testBody.length);
const newRevPos3 = await Revision.load(revs[3].id);
const newRevPos4 = await Revision.load(revs[4].id);
const newRevPos5 = await Revision.load(revs[5].id);
// Ensure both revisions expected to be merged contain the full note contents
expect(newRevPos3.body_diff.length).toBeGreaterThan(testBody.length);
expect(newRevPos4.body_diff.length).toBeGreaterThan(testBody.length);
expect(newRevPos3.title_diff).not.toBe(revs[3].title_diff);
expect(newRevPos4.title_diff).not.toBe(revs[4].title_diff);
expect(revs[5].body_diff).toBe(newRevPos5.body_diff);
expect(revs[5].title_diff).toBe(newRevPos5.title_diff);
// Ensure the merged revisions will be synced
expect(newRevPos3.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
expect(newRevPos4.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
const remainingRevs = await Revision.allByType(BaseModel.TYPE_NOTE, note.id);
expect(remainingRevs.length).toBe(3);
}));
test('deleteOldRevisions should merge 3 revisions when the revision chain has 3 branches on 1 revision', (async () => {
const revs = [];
const note = await Note.save({ title: 'test', body: testBody });
revs.push(await createChangeAndRevision(note)); // Rev 1
revs.push(await createChangeAndRevision(note)); // Rev 2
revs.push(await createChangeAndRevision(note)); // Rev 2a
revs.push(await createChangeAndRevision(note, revs[1].id)); // Rev 2b
revs.push(await createChangeAndRevision(note, revs[1].id)); // Rev 2c
const timeBeforeCleaning = Date.now();
const ttl = (2 * oneDayMs) + halfDayMs; // Delete revs 1-2
await Revision.deleteOldRevisions(ttl);
expect(revs[2].body_diff.length).toBeLessThan(testBody.length);
expect(revs[3].body_diff.length).toBeLessThan(testBody.length);
expect(revs[4].body_diff.length).toBeLessThan(testBody.length);
const newRevPos2 = await Revision.load(revs[2].id);
const newRevPos3 = await Revision.load(revs[3].id);
const newRevPos4 = await Revision.load(revs[4].id);
// Ensure the 3 revisions expected to be merged contain the full note contents
expect(newRevPos2.body_diff.length).toBeGreaterThan(testBody.length);
expect(newRevPos3.body_diff.length).toBeGreaterThan(testBody.length);
expect(newRevPos4.body_diff.length).toBeGreaterThan(testBody.length);
expect(newRevPos2.title_diff).not.toBe(revs[2].title_diff);
expect(newRevPos3.title_diff).not.toBe(revs[3].title_diff);
expect(newRevPos4.title_diff).not.toBe(revs[4].title_diff);
// Ensure the merged revisions will be synced
expect(newRevPos2.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
expect(newRevPos3.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
expect(newRevPos4.updated_time).toBeGreaterThanOrEqual(timeBeforeCleaning);
const remainingRevs = await Revision.allByType(BaseModel.TYPE_NOTE, note.id);
expect(remainingRevs.length).toBe(3);
}));
// cSpell:disable
test('mergeDiffs should stop traversal when parent_id is not set', (async () => {
const note = await Note.save({ title: 'test', body: testBody });
// Prove a broken revision state causes duplication in the evaluated revision, if a middle revision is rebased, but the parent_id
// still matches an existing revision
const rev1 = await createChangeAndRevision(note); // testa
const rev2 = await createChangeAndRevision(note); // testaa
const rev3 = await createChangeAndRevision(note); // testaaa
const mergedRev2 = await Revision.mergeDiffs(rev2, [rev1, rev2]);
expect(mergedRev2.title).toBe('testaa');
// Do not save - update in memory only for the assertion
rev2.title_diff = Revision.createTextPatch('', mergedRev2.title);
rev2.body_diff = Revision.createTextPatch('', mergedRev2.body);
const mergedRev3 = await Revision.mergeDiffs(rev3, [rev1, rev2, rev3]);
expect(mergedRev3.title).toBe('testaatestaa');
// Merge the middle revision using the real revision cleaning logic, but verify the end revision is not broken when evaluated, if the
// older revision is still available when merging the revision chain
const ttl = (1 * oneDayMs) + halfDayMs; // Delete rev 1
await Revision.deleteOldRevisions(ttl);
const newRev2 = await Revision.load(rev2.id);
const newMergedRev3 = await Revision.mergeDiffs(rev3, [rev1, newRev2, rev3]);
expect(newMergedRev3.title).toBe('testaaa');
expect(newRev2.title_diff).toBe(rev2.title_diff);
expect(newRev2.body_diff).toBe(rev2.body_diff);
expect(newRev2.parent_id).toBe('');
}));
// cSpell:enable
});