Timeline: repair oldest/newest metrics if we fetch nothing
This commit is contained in:
parent
56ae4a41eb
commit
6832b8acca
47 changed files with 579 additions and 173 deletions
442
ts/test-node/quill/emoji/completion_test.tsx
Normal file
442
ts/test-node/quill/emoji/completion_test.tsx
Normal file
|
@ -0,0 +1,442 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { EmojiCompletion } from '../../../quill/emoji/completion';
|
||||
import { EmojiData } from '../../../components/emoji/lib';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globalAsAny = global as any;
|
||||
|
||||
describe('emojiCompletion', () => {
|
||||
let emojiCompletion: EmojiCompletion;
|
||||
const mockOnPickEmoji = sinon.spy();
|
||||
const mockSetEmojiPickerElement = sinon.spy();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockQuill: any;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
this.oldDocument = globalAsAny.document;
|
||||
globalAsAny.document = {
|
||||
body: {
|
||||
appendChild: () => null,
|
||||
},
|
||||
createElement: () => null,
|
||||
};
|
||||
|
||||
mockQuill = {
|
||||
getLeaf: sinon.stub(),
|
||||
getSelection: sinon.stub(),
|
||||
keyboard: {
|
||||
addBinding: sinon.stub(),
|
||||
},
|
||||
on: sinon.stub(),
|
||||
setSelection: sinon.stub(),
|
||||
updateContents: sinon.stub(),
|
||||
};
|
||||
const options = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onPickEmoji: mockOnPickEmoji as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setEmojiPickerElement: mockSetEmojiPickerElement as any,
|
||||
skinTone: 0,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
emojiCompletion = new EmojiCompletion(mockQuill as any, options);
|
||||
|
||||
// Stub rendering to avoid missing DOM until we bring in Enzyme
|
||||
emojiCompletion.render = sinon.stub();
|
||||
});
|
||||
|
||||
afterEach(function afterEach() {
|
||||
mockOnPickEmoji.resetHistory();
|
||||
mockSetEmojiPickerElement.resetHistory();
|
||||
(emojiCompletion.render as sinon.SinonStub).resetHistory();
|
||||
|
||||
if (this.oldDocument === undefined) {
|
||||
delete globalAsAny.document;
|
||||
} else {
|
||||
globalAsAny.document = this.oldDocument;
|
||||
}
|
||||
});
|
||||
|
||||
describe('getCurrentLeafTextPartitions', () => {
|
||||
it('returns left and right text', () => {
|
||||
mockQuill.getSelection.returns({ index: 0, length: 0 });
|
||||
const blot = {
|
||||
text: ':smile:',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 2]);
|
||||
const [
|
||||
leftLeafText,
|
||||
rightLeafText,
|
||||
] = emojiCompletion.getCurrentLeafTextPartitions();
|
||||
assert.equal(leftLeafText, ':s');
|
||||
assert.equal(rightLeafText, 'mile:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTextChange', () => {
|
||||
let insertEmojiStub: sinon.SinonStub<
|
||||
[EmojiData, number, number, (boolean | undefined)?],
|
||||
void
|
||||
>;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
emojiCompletion.results = [{ short_name: 'joy' } as any];
|
||||
emojiCompletion.index = 5;
|
||||
insertEmojiStub = sinon
|
||||
.stub(emojiCompletion, 'insertEmoji')
|
||||
.callThrough();
|
||||
});
|
||||
|
||||
afterEach(function afterEach() {
|
||||
insertEmojiStub.restore();
|
||||
});
|
||||
|
||||
describe('given an emoji is not starting (no colon)', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 3,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text: 'smi',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 3]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a colon in a string (but not an emoji)', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 5,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text: '10:30',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 5]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an emoji is starting but does not have 2 characters', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 2,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text: ':s',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 2]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an emoji is starting but does not match a short name', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 4,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text: ':smy',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 4]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an emoji is starting and matches short names', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 4,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text: ':smi',
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 4]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('stores the results and renders', () => {
|
||||
assert.equal(emojiCompletion.results.length, 10);
|
||||
assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an emoji was just completed', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 7,
|
||||
length: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it matches a short name', () => {
|
||||
const text = ':smile:';
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 7]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('inserts the emoji at the current cursor position', () => {
|
||||
const [emoji, index, range] = insertEmojiStub.args[0];
|
||||
|
||||
assert.equal(emoji.short_name, 'smile');
|
||||
assert.equal(index, 0);
|
||||
assert.equal(range, 7);
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it matches a short name inside a larger string', () => {
|
||||
const text = 'have a :smile: nice day';
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getSelection.returns({
|
||||
index: 13,
|
||||
length: 0,
|
||||
});
|
||||
mockQuill.getLeaf.returns([blot, 13]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('inserts the emoji at the current cursor position', () => {
|
||||
const [emoji, index, range] = insertEmojiStub.args[0];
|
||||
|
||||
assert.equal(emoji.short_name, 'smile');
|
||||
assert.equal(index, 7);
|
||||
assert.equal(range, 7);
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
|
||||
it('sets the quill selection to the right cursor position', () => {
|
||||
const [index, range] = mockQuill.setSelection.args[0];
|
||||
|
||||
assert.equal(index, 8);
|
||||
assert.equal(range, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it does not match a short name', () => {
|
||||
const text = ':smyle:';
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 7]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an emoji was just completed from inside the colons', () => {
|
||||
const validEmoji = ':smile:';
|
||||
const invalidEmoji = ':smyle:';
|
||||
const middleCursorIndex = validEmoji.length - 3;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: middleCursorIndex,
|
||||
length: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it matches a short name', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text: validEmoji,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, middleCursorIndex]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('inserts the emoji at the current cursor position', () => {
|
||||
const [emoji, index, range] = insertEmojiStub.args[0];
|
||||
|
||||
assert.equal(emoji.short_name, 'smile');
|
||||
assert.equal(index, 0);
|
||||
assert.equal(range, validEmoji.length);
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it does not match a short name', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text: invalidEmoji,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, middleCursorIndex]);
|
||||
|
||||
emojiCompletion.onTextChange();
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a completeable emoji and colon was just pressed', () => {
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index: 6,
|
||||
length: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('and given it matches a short name', () => {
|
||||
const text = ':smile';
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, 6]);
|
||||
|
||||
emojiCompletion.onTextChange(true);
|
||||
});
|
||||
|
||||
it('inserts the emoji at the current cursor position', () => {
|
||||
const [emoji, index, range] = insertEmojiStub.args[0];
|
||||
|
||||
assert.equal(emoji.short_name, 'smile');
|
||||
assert.equal(index, 0);
|
||||
assert.equal(range, 6);
|
||||
});
|
||||
|
||||
it('does not show results', () => {
|
||||
assert.equal(emojiCompletion.results.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeEmoji', () => {
|
||||
let insertEmojiStub: sinon.SinonStub<
|
||||
[EmojiData, number, number, (boolean | undefined)?],
|
||||
void
|
||||
>;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
emojiCompletion.results = [
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ short_name: 'smile' } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ short_name: 'smile_cat' } as any,
|
||||
];
|
||||
emojiCompletion.index = 1;
|
||||
insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
|
||||
});
|
||||
|
||||
describe('given a valid token', () => {
|
||||
const text = ':smi';
|
||||
const index = text.length;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, index]);
|
||||
|
||||
emojiCompletion.completeEmoji();
|
||||
});
|
||||
|
||||
it('inserts the currently selected emoji at the current cursor position', () => {
|
||||
const [emoji, insertIndex, range] = insertEmojiStub.args[0];
|
||||
|
||||
assert.equal(emoji.short_name, 'smile_cat');
|
||||
assert.equal(insertIndex, 0);
|
||||
assert.equal(range, text.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a valid token is not present', () => {
|
||||
const text = 'smi';
|
||||
const index = text.length;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection.returns({
|
||||
index,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf.returns([blot, index]);
|
||||
|
||||
emojiCompletion.completeEmoji();
|
||||
});
|
||||
|
||||
it('does not insert anything', () => {
|
||||
assert.equal(insertEmojiStub.called, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
137
ts/test-node/quill/memberRepository_test.ts
Normal file
137
ts/test-node/quill/memberRepository_test.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
import { MemberRepository } from '../../quill/memberRepository';
|
||||
|
||||
const memberMahershala: ConversationType = {
|
||||
id: '555444',
|
||||
uuid: 'abcdefg',
|
||||
title: 'Pal',
|
||||
firstName: 'Mahershala',
|
||||
profileName: 'Mr Ali',
|
||||
name: 'Friend',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
const memberShia: ConversationType = {
|
||||
id: '333222',
|
||||
uuid: 'hijklmno',
|
||||
title: 'Buddy',
|
||||
firstName: 'Shia',
|
||||
profileName: 'Sr LaBeouf',
|
||||
name: 'Duder',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
const members: Array<ConversationType> = [memberMahershala, memberShia];
|
||||
|
||||
const singleMember: ConversationType = {
|
||||
id: '666777',
|
||||
uuid: 'pqrstuv',
|
||||
title: 'The Guy',
|
||||
firstName: 'Jeff',
|
||||
profileName: 'Jr Klaus',
|
||||
name: 'Him',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
describe('MemberRepository', () => {
|
||||
describe('#updateMembers', () => {
|
||||
it('updates with given members', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.deepEqual(memberRepository.getMembers(), members);
|
||||
|
||||
const updatedMembers = [...members, singleMember];
|
||||
memberRepository.updateMembers(updatedMembers);
|
||||
assert.deepEqual(memberRepository.getMembers(), updatedMembers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getMemberById', () => {
|
||||
it('returns undefined when there is no search id', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isUndefined(memberRepository.getMemberById());
|
||||
});
|
||||
|
||||
it('returns a matched member', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isDefined(memberRepository.getMemberById('555444'));
|
||||
});
|
||||
|
||||
it('returns undefined when it does not have the member', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isUndefined(memberRepository.getMemberById('nope'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getMemberByUuid', () => {
|
||||
it('returns undefined when there is no search uuid', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isUndefined(memberRepository.getMemberByUuid());
|
||||
});
|
||||
|
||||
it('returns a matched member', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isDefined(memberRepository.getMemberByUuid('abcdefg'));
|
||||
});
|
||||
|
||||
it('returns undefined when it does not have the member', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
assert.isUndefined(memberRepository.getMemberByUuid('nope'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#search', () => {
|
||||
describe('given a prefix-matching string on last name', () => {
|
||||
it('returns the match', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
const results = memberRepository.search('a');
|
||||
assert.deepEqual(results, [memberMahershala]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a prefix-matching string on first name', () => {
|
||||
it('returns the match', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
const results = memberRepository.search('ma');
|
||||
assert.deepEqual(results, [memberMahershala]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a prefix-matching string on profile name', () => {
|
||||
it('returns the match', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
const results = memberRepository.search('sr');
|
||||
assert.deepEqual(results, [memberShia]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a prefix-matching string on title', () => {
|
||||
it('returns the match', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
const results = memberRepository.search('d');
|
||||
assert.deepEqual(results, [memberShia]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a match in the middle of a name', () => {
|
||||
it('returns zero matches', () => {
|
||||
const memberRepository = new MemberRepository(members);
|
||||
const results = memberRepository.search('e');
|
||||
assert.deepEqual(results, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
269
ts/test-node/quill/mentions/completion_test.tsx
Normal file
269
ts/test-node/quill/mentions/completion_test.tsx
Normal file
|
@ -0,0 +1,269 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import Delta from 'quill-delta';
|
||||
import sinon, { SinonStub } from 'sinon';
|
||||
import Quill, { KeyboardStatic } from 'quill';
|
||||
|
||||
import { MutableRefObject } from 'react';
|
||||
import {
|
||||
MentionCompletion,
|
||||
MentionCompletionOptions,
|
||||
} from '../../../quill/mentions/completion';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { MemberRepository } from '../../../quill/memberRepository';
|
||||
|
||||
const me: ConversationType = {
|
||||
id: '666777',
|
||||
uuid: 'pqrstuv',
|
||||
title: 'Fred Savage',
|
||||
firstName: 'Fred',
|
||||
profileName: 'Fred S.',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
const members: Array<ConversationType> = [
|
||||
{
|
||||
id: '555444',
|
||||
uuid: 'abcdefg',
|
||||
title: 'Mahershala Ali',
|
||||
firstName: 'Mahershala',
|
||||
profileName: 'Mahershala A.',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
},
|
||||
{
|
||||
id: '333222',
|
||||
uuid: 'hijklmno',
|
||||
title: 'Shia LaBeouf',
|
||||
firstName: 'Shia',
|
||||
profileName: 'Shia L.',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
},
|
||||
me,
|
||||
];
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
document: {
|
||||
body: {
|
||||
appendChild: unknown;
|
||||
};
|
||||
createElement: unknown;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MentionCompletion', () => {
|
||||
const mockSetMentionPickerElement = sinon.spy();
|
||||
|
||||
let mockQuill: Omit<
|
||||
Partial<{ [K in keyof Quill]: SinonStub }>,
|
||||
'keyboard'
|
||||
> & {
|
||||
keyboard: Partial<{ [K in keyof KeyboardStatic]: SinonStub }>;
|
||||
};
|
||||
let mentionCompletion: MentionCompletion;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
global.document = {
|
||||
body: {
|
||||
appendChild: sinon.spy(),
|
||||
},
|
||||
createElement: sinon.spy(),
|
||||
};
|
||||
|
||||
const memberRepositoryRef: MutableRefObject<MemberRepository> = {
|
||||
current: new MemberRepository(members),
|
||||
};
|
||||
|
||||
const options: MentionCompletionOptions = {
|
||||
i18n: sinon.stub(),
|
||||
me,
|
||||
memberRepositoryRef,
|
||||
setMentionPickerElement: mockSetMentionPickerElement,
|
||||
};
|
||||
|
||||
mockQuill = {
|
||||
getContents: sinon.stub(),
|
||||
getLeaf: sinon.stub(),
|
||||
getSelection: sinon.stub(),
|
||||
keyboard: { addBinding: sinon.stub() },
|
||||
on: sinon.stub(),
|
||||
setSelection: sinon.stub(),
|
||||
updateContents: sinon.stub(),
|
||||
};
|
||||
|
||||
mentionCompletion = new MentionCompletion(
|
||||
(mockQuill as unknown) as Quill,
|
||||
options
|
||||
);
|
||||
|
||||
sinon.stub(mentionCompletion, 'render');
|
||||
});
|
||||
|
||||
describe('onTextChange', () => {
|
||||
let possiblyShowMemberResultsStub: sinon.SinonStub<[], ConversationType[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
possiblyShowMemberResultsStub = sinon.stub(
|
||||
mentionCompletion,
|
||||
'possiblyShowMemberResults'
|
||||
);
|
||||
});
|
||||
|
||||
describe('given a change that should show members', () => {
|
||||
const newContents = new Delta().insert('@a');
|
||||
|
||||
beforeEach(() => {
|
||||
mockQuill.getContents?.returns(newContents);
|
||||
|
||||
possiblyShowMemberResultsStub.returns(members);
|
||||
});
|
||||
|
||||
it('shows member results', () => {
|
||||
mentionCompletion.onTextChange();
|
||||
|
||||
assert.equal(mentionCompletion.results, members);
|
||||
assert.equal(mentionCompletion.index, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a change that should clear results', () => {
|
||||
const newContents = new Delta().insert('foo ');
|
||||
|
||||
let clearResultsStub: SinonStub<[], void>;
|
||||
|
||||
beforeEach(() => {
|
||||
mentionCompletion.results = members;
|
||||
|
||||
mockQuill.getContents?.returns(newContents);
|
||||
|
||||
possiblyShowMemberResultsStub.returns([]);
|
||||
|
||||
clearResultsStub = sinon.stub(mentionCompletion, 'clearResults');
|
||||
});
|
||||
|
||||
it('clears member results', () => {
|
||||
mentionCompletion.onTextChange();
|
||||
|
||||
assert.equal(clearResultsStub.called, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeMention', () => {
|
||||
describe('given a completable mention', () => {
|
||||
let insertMentionStub: SinonStub<
|
||||
[ConversationType, number, number, (boolean | undefined)?],
|
||||
void
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mentionCompletion.results = members;
|
||||
mockQuill.getSelection?.returns({ index: 5 });
|
||||
mockQuill.getLeaf?.returns([{ text: '@shia' }, 5]);
|
||||
|
||||
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
|
||||
});
|
||||
|
||||
it('inserts the currently selected mention at the current cursor position', () => {
|
||||
mentionCompletion.completeMention(1);
|
||||
|
||||
const [
|
||||
member,
|
||||
distanceFromCursor,
|
||||
adjustCursorAfterBy,
|
||||
withTrailingSpace,
|
||||
] = insertMentionStub.getCall(0).args;
|
||||
|
||||
assert.equal(member, members[1]);
|
||||
assert.equal(distanceFromCursor, 0);
|
||||
assert.equal(adjustCursorAfterBy, 5);
|
||||
assert.equal(withTrailingSpace, true);
|
||||
});
|
||||
|
||||
it('can infer the member to complete with', () => {
|
||||
mentionCompletion.index = 1;
|
||||
mentionCompletion.completeMention();
|
||||
|
||||
const [
|
||||
member,
|
||||
distanceFromCursor,
|
||||
adjustCursorAfterBy,
|
||||
withTrailingSpace,
|
||||
] = insertMentionStub.getCall(0).args;
|
||||
|
||||
assert.equal(member, members[1]);
|
||||
assert.equal(distanceFromCursor, 0);
|
||||
assert.equal(adjustCursorAfterBy, 5);
|
||||
assert.equal(withTrailingSpace, true);
|
||||
});
|
||||
|
||||
describe('from the middle of a string', () => {
|
||||
beforeEach(() => {
|
||||
mockQuill.getSelection?.returns({ index: 9 });
|
||||
mockQuill.getLeaf?.returns([{ text: 'foo @shia bar' }, 9]);
|
||||
});
|
||||
|
||||
it('inserts correctly', () => {
|
||||
mentionCompletion.completeMention(1);
|
||||
|
||||
const [
|
||||
member,
|
||||
distanceFromCursor,
|
||||
adjustCursorAfterBy,
|
||||
withTrailingSpace,
|
||||
] = insertMentionStub.getCall(0).args;
|
||||
|
||||
assert.equal(member, members[1]);
|
||||
assert.equal(distanceFromCursor, 4);
|
||||
assert.equal(adjustCursorAfterBy, 5);
|
||||
assert.equal(withTrailingSpace, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a completable mention starting with a capital letter', () => {
|
||||
const text = '@Sh';
|
||||
const index = text.length;
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
mockQuill.getSelection?.returns({ index });
|
||||
|
||||
const blot = {
|
||||
text,
|
||||
};
|
||||
mockQuill.getLeaf?.returns([blot, index]);
|
||||
|
||||
mentionCompletion.completeMention(1);
|
||||
});
|
||||
|
||||
it('inserts the currently selected mention at the current cursor position', () => {
|
||||
const [
|
||||
member,
|
||||
distanceFromCursor,
|
||||
adjustCursorAfterBy,
|
||||
withTrailingSpace,
|
||||
] = insertMentionStub.getCall(0).args;
|
||||
|
||||
assert.equal(member, members[1]);
|
||||
assert.equal(distanceFromCursor, 0);
|
||||
assert.equal(adjustCursorAfterBy, 3);
|
||||
assert.equal(withTrailingSpace, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
189
ts/test-node/quill/mentions/matchers_test.ts
Normal file
189
ts/test-node/quill/mentions/matchers_test.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { RefObject } from 'react';
|
||||
import Delta from 'quill-delta';
|
||||
|
||||
import { matchMention } from '../../../quill/mentions/matchers';
|
||||
import { MemberRepository } from '../../../quill/memberRepository';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
|
||||
class FakeTokenList<T> extends Array<T> {
|
||||
constructor(elements: Array<T>) {
|
||||
super();
|
||||
elements.forEach(element => this.push(element));
|
||||
}
|
||||
|
||||
contains(searchElement: T) {
|
||||
return this.includes(searchElement);
|
||||
}
|
||||
}
|
||||
|
||||
const createMockElement = (
|
||||
className: string,
|
||||
dataset: Record<string, string>
|
||||
): HTMLElement =>
|
||||
(({
|
||||
classList: new FakeTokenList([className]),
|
||||
dataset,
|
||||
} as unknown) as HTMLElement);
|
||||
|
||||
const createMockAtMentionElement = (
|
||||
dataset: Record<string, string>
|
||||
): HTMLElement => createMockElement('module-message-body__at-mention', dataset);
|
||||
|
||||
const createMockMentionBlotElement = (
|
||||
dataset: Record<string, string>
|
||||
): HTMLElement => createMockElement('mention-blot', dataset);
|
||||
|
||||
const memberMahershala: ConversationType = {
|
||||
id: '555444',
|
||||
uuid: 'abcdefg',
|
||||
title: 'Mahershala Ali',
|
||||
firstName: 'Mahershala',
|
||||
profileName: 'Mahershala A.',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
const memberShia: ConversationType = {
|
||||
id: '333222',
|
||||
uuid: 'hijklmno',
|
||||
title: 'Shia LaBeouf',
|
||||
firstName: 'Shia',
|
||||
profileName: 'Shia L.',
|
||||
type: 'direct',
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
areWeAdmin: false,
|
||||
};
|
||||
|
||||
const members: Array<ConversationType> = [memberMahershala, memberShia];
|
||||
|
||||
const memberRepositoryRef: RefObject<MemberRepository> = {
|
||||
current: new MemberRepository(members),
|
||||
};
|
||||
|
||||
const matcher = matchMention(memberRepositoryRef);
|
||||
|
||||
interface Mention {
|
||||
uuid: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface MentionInsert {
|
||||
mention: Mention;
|
||||
}
|
||||
|
||||
const isMention = (insert?: unknown): insert is MentionInsert => {
|
||||
if (insert) {
|
||||
if (Object.getOwnPropertyNames(insert).includes('mention')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const EMPTY_DELTA = new Delta();
|
||||
|
||||
describe('matchMention', () => {
|
||||
it('handles an AtMentionify from clipboard', () => {
|
||||
const result = matcher(
|
||||
createMockAtMentionElement({
|
||||
id: memberMahershala.id,
|
||||
title: memberMahershala.title,
|
||||
}),
|
||||
EMPTY_DELTA
|
||||
);
|
||||
const { ops } = result;
|
||||
|
||||
assert.isNotEmpty(ops);
|
||||
|
||||
const [op] = ops;
|
||||
const { insert } = op;
|
||||
|
||||
if (isMention(insert)) {
|
||||
const { title, uuid } = insert.mention;
|
||||
|
||||
assert.equal(title, memberMahershala.title);
|
||||
assert.equal(uuid, memberMahershala.uuid);
|
||||
} else {
|
||||
assert.fail('insert is invalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles an MentionBlot from clipboard', () => {
|
||||
const result = matcher(
|
||||
createMockMentionBlotElement({
|
||||
uuid: memberMahershala.uuid || '',
|
||||
title: memberMahershala.title,
|
||||
}),
|
||||
EMPTY_DELTA
|
||||
);
|
||||
const { ops } = result;
|
||||
|
||||
assert.isNotEmpty(ops);
|
||||
|
||||
const [op] = ops;
|
||||
const { insert } = op;
|
||||
|
||||
if (isMention(insert)) {
|
||||
const { title, uuid } = insert.mention;
|
||||
|
||||
assert.equal(title, memberMahershala.title);
|
||||
assert.equal(uuid, memberMahershala.uuid);
|
||||
} else {
|
||||
assert.fail('insert is invalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('converts a missing AtMentionify to string', () => {
|
||||
const result = matcher(
|
||||
createMockAtMentionElement({
|
||||
id: 'florp',
|
||||
title: 'Nonexistent',
|
||||
}),
|
||||
EMPTY_DELTA
|
||||
);
|
||||
const { ops } = result;
|
||||
|
||||
assert.isNotEmpty(ops);
|
||||
|
||||
const [op] = ops;
|
||||
const { insert } = op;
|
||||
|
||||
if (isMention(insert)) {
|
||||
assert.fail('insert is invalid');
|
||||
} else {
|
||||
assert.equal(insert, '@Nonexistent');
|
||||
}
|
||||
});
|
||||
|
||||
it('converts a missing MentionBlot to string', () => {
|
||||
const result = matcher(
|
||||
createMockMentionBlotElement({
|
||||
uuid: 'florp',
|
||||
title: 'Nonexistent',
|
||||
}),
|
||||
EMPTY_DELTA
|
||||
);
|
||||
const { ops } = result;
|
||||
|
||||
assert.isNotEmpty(ops);
|
||||
|
||||
const [op] = ops;
|
||||
const { insert } = op;
|
||||
|
||||
if (isMention(insert)) {
|
||||
assert.fail('insert is invalid');
|
||||
} else {
|
||||
assert.equal(insert, '@Nonexistent');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes other clipboard elements through', () => {
|
||||
const result = matcher(createMockElement('ignore', {}), EMPTY_DELTA);
|
||||
assert.equal(result, EMPTY_DELTA);
|
||||
});
|
||||
});
|
241
ts/test-node/quill/util_test.ts
Normal file
241
ts/test-node/quill/util_test.ts
Normal file
|
@ -0,0 +1,241 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import {
|
||||
getDeltaToRemoveStaleMentions,
|
||||
getTextAndMentionsFromOps,
|
||||
getDeltaToRestartMention,
|
||||
} from '../../quill/util';
|
||||
|
||||
describe('getDeltaToRemoveStaleMentions', () => {
|
||||
const memberUuids = ['abcdef', 'ghijkl'];
|
||||
|
||||
describe('given text', () => {
|
||||
it('retains the text', () => {
|
||||
const originalOps = [
|
||||
{
|
||||
insert: 'whoa, nobody here',
|
||||
},
|
||||
];
|
||||
|
||||
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
|
||||
|
||||
assert.deepEqual(ops, [{ retain: 17 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given stale and valid mentions', () => {
|
||||
it('retains the valid and replaces the stale', () => {
|
||||
const originalOps = [
|
||||
{
|
||||
insert: {
|
||||
mention: { uuid: '12345', title: 'Klaus' },
|
||||
},
|
||||
},
|
||||
{ insert: { mention: { uuid: 'abcdef', title: 'Werner' } } },
|
||||
];
|
||||
|
||||
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
|
||||
|
||||
assert.deepEqual(ops, [
|
||||
{ delete: 1 },
|
||||
{ insert: '@Klaus' },
|
||||
{ retain: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given emoji embeds', () => {
|
||||
it('retains the embeds', () => {
|
||||
const originalOps = [
|
||||
{
|
||||
insert: {
|
||||
emoji: '😂',
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: {
|
||||
emoji: '🍋',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
|
||||
|
||||
assert.deepEqual(ops, [{ retain: 1 }, { retain: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given other ops', () => {
|
||||
it('passes them through', () => {
|
||||
const originalOps = [
|
||||
{
|
||||
delete: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
|
||||
|
||||
assert.deepEqual(ops, originalOps);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTextAndMentionsFromOps', () => {
|
||||
describe('given only text', () => {
|
||||
it('returns only text trimmed', () => {
|
||||
const ops = [{ insert: ' The ' }, { insert: ' text \n' }];
|
||||
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
|
||||
assert.equal(resultText, 'The text');
|
||||
assert.equal(resultMentions.length, 0);
|
||||
});
|
||||
|
||||
it('returns trimmed of trailing newlines', () => {
|
||||
const ops = [{ insert: ' The\ntext\n\n\n' }];
|
||||
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
|
||||
assert.equal(resultText, 'The\ntext');
|
||||
assert.equal(resultMentions.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given text, emoji, and mentions', () => {
|
||||
it('returns the trimmed text with placeholders and mentions', () => {
|
||||
const ops = [
|
||||
{
|
||||
insert: {
|
||||
emoji: '😂',
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ' wow, funny, ',
|
||||
},
|
||||
{
|
||||
insert: {
|
||||
mention: {
|
||||
uuid: 'abcdef',
|
||||
title: '@fred',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
|
||||
assert.equal(resultText, '😂 wow, funny, \uFFFC');
|
||||
assert.deepEqual(resultMentions, [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: 'abcdef',
|
||||
replacementText: '@fred',
|
||||
start: 15,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given only mentions', () => {
|
||||
it('returns the trimmed text with placeholders and mentions', () => {
|
||||
const ops = [
|
||||
{
|
||||
insert: {
|
||||
mention: {
|
||||
uuid: 'abcdef',
|
||||
title: '@fred',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
|
||||
assert.equal(resultText, '\uFFFC');
|
||||
assert.deepEqual(resultMentions, [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: 'abcdef',
|
||||
replacementText: '@fred',
|
||||
start: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not trim newlines padding mentions', () => {
|
||||
const ops = [
|
||||
{ insert: 'test \n' },
|
||||
{
|
||||
insert: {
|
||||
mention: {
|
||||
uuid: 'abcdef',
|
||||
title: '@fred',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ insert: '\n test' },
|
||||
];
|
||||
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
|
||||
assert.equal(resultText, 'test \n\uFFFC\n test');
|
||||
assert.deepEqual(resultMentions, [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: 'abcdef',
|
||||
replacementText: '@fred',
|
||||
start: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeltaToRestartMention', () => {
|
||||
describe('given text and emoji', () => {
|
||||
it('returns the correct retains, a delete, and an @', () => {
|
||||
const originalOps = [
|
||||
{
|
||||
insert: {
|
||||
emoji: '😂',
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: {
|
||||
mention: {
|
||||
uuid: 'ghijkl',
|
||||
title: '@sam',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ' wow, funny, ',
|
||||
},
|
||||
{
|
||||
insert: {
|
||||
mention: {
|
||||
uuid: 'abcdef',
|
||||
title: '@fred',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { ops } = getDeltaToRestartMention(originalOps);
|
||||
|
||||
assert.deepEqual(ops, [
|
||||
{
|
||||
retain: 1,
|
||||
},
|
||||
{
|
||||
retain: 1,
|
||||
},
|
||||
{
|
||||
retain: 13,
|
||||
},
|
||||
{
|
||||
retain: 1,
|
||||
},
|
||||
{
|
||||
delete: 1,
|
||||
},
|
||||
{
|
||||
insert: '@',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue