From 1194b903b5a913b98ecedfad92bc2b41ea40d5e9 Mon Sep 17 00:00:00 2001 From: Robin MacPherson Date: Thu, 22 Feb 2024 08:38:00 -0800 Subject: [PATCH 1/3] Refactor various utils and types into dedicated files --- packages/web/resolvers/utils/badges.ts | 13 ++ packages/web/resolvers/utils/index.ts | 284 +------------------------ packages/web/resolvers/utils/slate.ts | 262 +++++++++++++++++++++++ 3 files changed, 277 insertions(+), 282 deletions(-) diff --git a/packages/web/resolvers/utils/badges.ts b/packages/web/resolvers/utils/badges.ts index 056a4584..2f6fddd8 100644 --- a/packages/web/resolvers/utils/badges.ts +++ b/packages/web/resolvers/utils/badges.ts @@ -42,4 +42,17 @@ const assignCountBadges = ( return db.$executeRaw(query) } +export const assignBadge = async ( + db: PrismaClient, + userId: number, + badge: BadgeType, +): Promise => { + await db.userBadge.createMany({ + data: [{ type: badge, userId }], + skipDuplicates: true, + }) + + return +} + export { assignCountBadges } diff --git a/packages/web/resolvers/utils/index.ts b/packages/web/resolvers/utils/index.ts index d63cf9b5..c54b7ffb 100644 --- a/packages/web/resolvers/utils/index.ts +++ b/packages/web/resolvers/utils/index.ts @@ -1,13 +1,4 @@ -import { diffChars } from 'diff' -import escapeHTML from 'escape-html' -import { - PrismaClient, - User, - Thread, - Post, - BadgeType, -} from '@journaly/j-db-client' -import { NodeType, extractText, isEmptyParagraph } from './slate' +import { User } from '@journaly/j-db-client' export const assertUnreachable = (x: never): never => { throw new Error(`Didn't expect to get here ${x}`) @@ -15,264 +6,6 @@ export const assertUnreachable = (x: never): never => { type AuthoredObject = { authorId: number } -const typeToElStrMap: { [key: string]: string } = { - 'heading-one': 'h1', - 'heading-two': 'h2', - 'block-quote': 'blockquote', - 'bulleted-list': 'ul', - 'numbered-list': 'ol', - 'list-item': 'li', - link: 'a', - paragraph: 'p', - p: 'p', - table: 'table', - td: 'td', - tr: 'tr', - th: 'th', -} - -type textNodeFormatType = 'italic' | 'bold' | 'underline' - -// Iterate this array rather than the following object for a consistent order. -const textNodeFormats: textNodeFormatType[] = ['italic', 'bold', 'underline'] - -const textNodeFormatEls: { [T in textNodeFormatType]: string } = { - italic: 'em', - bold: 'strong', - underline: 'u', -} - -const nonBodyTypes = new Set([ - 'heading-one', - 'heading-two', - 'block-quote', - 'table', - 'td', - 'tr', - 'th', -]) - -const breakCharacters = new Set([ - ' ', // Good ole ASCII space - '.', - ';', - ' ', // Full width space (Japanese, Chinese?) - '。', // Ideographic full stop -]) - -const getNodeTagName = (node: NodeType): string => { - if (!node.type) { - return 'span' - } - - if (!(node.type in typeToElStrMap)) { - return 'span' - } - - return typeToElStrMap[node.type] -} - -const htmlifyEditorNode = (node: NodeType): string => { - if (!node.type && typeof node.text === 'string') { - // Special case for empty p tags. Slate renders these as a para with a br - // inside, so we follow suit here for consistency between editor and view - if (isEmptyParagraph(node)) { - return '


' - } - - // Wrap text node in each applicable element - return textNodeFormats.reduce((textNodeMarkup, format) => { - if (node[format]) { - const El = textNodeFormatEls[format] - return `<${El}>${textNodeMarkup}` - } else { - return textNodeMarkup - } - }, escapeHTML(node.text)) - } - - if (node.type === 'image') { - // Images are a special case since they have no closer nor children - return `` - } else { - const tagName = getNodeTagName(node) - let content = (node.children || []).map(htmlifyEditorNode).join('') - const attributes: string[] = [] - - if (node.type === 'link' && node.url) { - attributes.push(`href="${node.url}" target="_blank" rel="noopener noreferrer"`) - } else if (node.type === 'tr' && node.size) { - attributes.push(`style="height: ${node.size}px;"`) - } - - if (node.type === 'table' && node.colSizes) { - attributes.push('style="table-layout:fixed;"') - content = ` - - ${node.colSizes - .map((size) => ``) - .join('\n')} - - - ${content} - - ` - } - - return `<${tagName} ${attributes.join(' ')}>${content}` - } -} - -export const htmlifyEditorNodes = (value: NodeType[]): string => { - return value.map(htmlifyEditorNode).join('') -} - -export const generateExcerpt = (document: NodeType[], length = 200, tolerance = 20): string => { - // `length` is the max number of characters (codepoints) in the excerpt, - // tolerance is the number of characters we'll back-track looking for a word - // or sentence break to cut off at - const bodyText = extractText(document, nonBodyTypes) - - if (bodyText.length <= length) { - return bodyText - } - - let end = Math.min(length, bodyText.length) - 1 - let breakFound = false - - while (length - end < tolerance) { - if (breakCharacters.has(bodyText[end])) { - breakFound = true - break // heh! - } - end-- - } - - if (!breakFound) { - end += tolerance - } - - return bodyText.substr(0, end) -} - -export const readTime = (text: string): number => { - const numWords = text.split(' ').length - - return Math.round(numWords / 200) -} - -export const updatedThreadPositions = ( - oldDoc: NodeType[], - newDoc: NodeType[], - threads: Thread[], -): Thread[] => { - const oldStr = extractText(oldDoc) - const newStr = extractText(newDoc) - const changes = diffChars(oldStr, newStr) - const threadsRepr = threads.map( - (t) => [t.startIndex, t.endIndex, t.id] as [number, number, number], - ) - - // Move thread fenceposts according to inserts and deletes. Inserts move all - // subsequent fenceposts forwards, deletes move them backwards. If a delete - // spans a thread's fencepost, mark that thread as archived (signaled by - // both fenceposts set to -1). If a delete perfectly spans a thread, also - // mark it as archived. - let idx = 0 - for (let ci = 0; ci < changes.length; ci++) { - const { count = 0, added, removed } = changes[ci] - const changeEnd = idx + count - - if (added) { - for (let ti = 0; ti < threadsRepr.length; ti++) { - const t = threadsRepr[ti] - if (t[0] > idx) t[0] += count - if (t[1] > idx) t[1] += count - } - - idx += count - } else if (removed) { - for (let ti = 0; ti < threadsRepr.length; ti++) { - const t = threadsRepr[ti] - - if (t[0] > idx && t[0] < changeEnd) { - // Starting fencepost deleted - t[0] = -1 - t[1] = -1 - } else if (t[1] > idx && t[1] < changeEnd) { - // Ending fencepost deleted - t[0] = -1 - t[1] = -1 - } else if (t[0] === idx && t[1] === changeEnd) { - // Delete perfectly spans the thread - t[0] = -1 - t[1] = -1 - } else { - if (t[0] > idx) t[0] -= count - if (t[1] > idx) t[1] -= count - } - } - } else { - idx += count - } - } - - // Now look for threads which, after having been moved, are over illegal - // thread positions. - - // List of indicies a valid comment may not cross in the new doc - const breakPoints = new Set() - - idx = 0 - const recur = (tree: NodeType): void => { - if (tree.type !== undefined) { - breakPoints.add(idx) - } - - idx += tree.text?.length || 0 - - if (tree.type !== undefined) { - breakPoints.add(idx) - } - - ;(tree.children || []).map(recur) - } - - newDoc.map(recur) - - for (const breakPoint of breakPoints) { - for (let ti = 0; ti < threadsRepr.length; ti++) { - const t = threadsRepr[ti] - if (t[0] < breakPoint && t[1] > breakPoint) { - t[0] = -1 - t[1] = -1 - } - } - } - - return threads.map((thread) => { - const [startIndex, endIndex] = threadsRepr.find(([, , id]) => id === thread.id) || [0, 0, 0] - return { - ...thread, - startIndex, - endIndex, - } - }) -} - -export const processEditorDocument = ( - document: NodeType[], -): Pick => { - const bodyText = extractText(document) - - return { - body: htmlifyEditorNodes(document), - bodySrc: JSON.stringify(document), - excerpt: generateExcerpt(document), - readTime: readTime(bodyText), - } -} - // Takes in an original Post or Comment and a currently logged in User and checks that // the currentUser has permission to update or delete that Post/Comment export const hasAuthorPermissions = (original: AuthoredObject, currentUser: User): boolean => { @@ -285,19 +18,6 @@ export const hasAuthorPermissions = (original: AuthoredObject, currentUser: User return true } -export const assignBadge = async ( - db: PrismaClient, - userId: number, - badge: BadgeType, -): Promise => { - await db.userBadge.createMany({ - data: [{ type: badge, userId }], - skipDuplicates: true, - }) - - return -} - export * from './email' export * from './aws' export * from './resolverUtils' @@ -305,4 +25,4 @@ export * from './db' export * from './notifications' export * from './types' export * from './badges' -export type { NodeType } +export * from './slate' diff --git a/packages/web/resolvers/utils/slate.ts b/packages/web/resolvers/utils/slate.ts index 17ffad92..8760d8fb 100644 --- a/packages/web/resolvers/utils/slate.ts +++ b/packages/web/resolvers/utils/slate.ts @@ -1,3 +1,7 @@ +import { diffChars } from 'diff' +import { Post, Thread } from '@journaly/j-db-client' +import escapeHTML from 'escape-html' + export type NodeType = { text?: string | null italic?: boolean | null @@ -140,6 +144,63 @@ const swapNode = (doc: Doc, target: NodeType, replacement: NodeType) => { return docClone } +const typeToElStrMap: { [key: string]: string } = { + 'heading-one': 'h1', + 'heading-two': 'h2', + 'block-quote': 'blockquote', + 'bulleted-list': 'ul', + 'numbered-list': 'ol', + 'list-item': 'li', + link: 'a', + paragraph: 'p', + p: 'p', + table: 'table', + td: 'td', + tr: 'tr', + th: 'th', +} + +type textNodeFormatType = 'italic' | 'bold' | 'underline' + +// Iterate this array rather than the following object for a consistent order. +const textNodeFormats: textNodeFormatType[] = ['italic', 'bold', 'underline'] + +const textNodeFormatEls: { [T in textNodeFormatType]: string } = { + italic: 'em', + bold: 'strong', + underline: 'u', +} + +const nonBodyTypes = new Set([ + 'heading-one', + 'heading-two', + 'block-quote', + 'table', + 'td', + 'tr', + 'th', +]) + +const breakCharacters = new Set([ + ' ', // Good ole ASCII space + '.', + ';', + ' ', // Full width space (Japanese, Chinese?) + '。', // Ideographic full stop +]) + +const getNodeTagName = (node: NodeType): string => { + if (!node.type) { + return 'span' + } + + if (!(node.type in typeToElStrMap)) { + return 'span' + } + + return typeToElStrMap[node.type] +} + const extractTextFromNode = (node: NodeType, ignoreNodeTypes = emptySet): string => { if (!node.type && typeof node.text === 'string') { return node.text @@ -166,3 +227,204 @@ export const extractText = ( const text = document.map((node) => extractTextFromNode(node, ignoreNodeTypes)).join(separator) return removeDoubleSpace(text) } + +export const updatedThreadPositions = ( + oldDoc: NodeType[], + newDoc: NodeType[], + threads: Thread[], +): Thread[] => { + const oldStr = extractText(oldDoc) + const newStr = extractText(newDoc) + const changes = diffChars(oldStr, newStr) + const threadsRepr = threads.map( + (t) => [t.startIndex, t.endIndex, t.id] as [number, number, number], + ) + + // Move thread fenceposts according to inserts and deletes. Inserts move all + // subsequent fenceposts forwards, deletes move them backwards. If a delete + // spans a thread's fencepost, mark that thread as archived (signaled by + // both fenceposts set to -1). If a delete perfectly spans a thread, also + // mark it as archived. + let idx = 0 + for (let ci = 0; ci < changes.length; ci++) { + const { count = 0, added, removed } = changes[ci] + const changeEnd = idx + count + + if (added) { + for (let ti = 0; ti < threadsRepr.length; ti++) { + const t = threadsRepr[ti] + if (t[0] > idx) t[0] += count + if (t[1] > idx) t[1] += count + } + + idx += count + } else if (removed) { + for (let ti = 0; ti < threadsRepr.length; ti++) { + const t = threadsRepr[ti] + + if (t[0] > idx && t[0] < changeEnd) { + // Starting fencepost deleted + t[0] = -1 + t[1] = -1 + } else if (t[1] > idx && t[1] < changeEnd) { + // Ending fencepost deleted + t[0] = -1 + t[1] = -1 + } else if (t[0] === idx && t[1] === changeEnd) { + // Delete perfectly spans the thread + t[0] = -1 + t[1] = -1 + } else { + if (t[0] > idx) t[0] -= count + if (t[1] > idx) t[1] -= count + } + } + } else { + idx += count + } + } + + // Now look for threads which, after having been moved, are over illegal + // thread positions. + + // List of indicies a valid comment may not cross in the new doc + const breakPoints = new Set() + + idx = 0 + const recur = (tree: NodeType): void => { + if (tree.type !== undefined) { + breakPoints.add(idx) + } + + idx += tree.text?.length || 0 + + if (tree.type !== undefined) { + breakPoints.add(idx) + } + + ;(tree.children || []).map(recur) + } + + newDoc.map(recur) + + for (const breakPoint of breakPoints) { + for (let ti = 0; ti < threadsRepr.length; ti++) { + const t = threadsRepr[ti] + if (t[0] < breakPoint && t[1] > breakPoint) { + t[0] = -1 + t[1] = -1 + } + } + } + + return threads.map((thread) => { + const [startIndex, endIndex] = threadsRepr.find(([, , id]) => id === thread.id) || [0, 0, 0] + return { + ...thread, + startIndex, + endIndex, + } + }) +} + +export const processEditorDocument = ( + document: NodeType[], +): Pick => { + const bodyText = extractText(document) + + return { + body: htmlifyEditorNodes(document), + bodySrc: JSON.stringify(document), + excerpt: generateExcerpt(document), + readTime: readTime(bodyText), + } +} + +export const readTime = (text: string): number => { + const numWords = text.split(' ').length + + return Math.round(numWords / 200) +} + +export const htmlifyEditorNodes = (value: NodeType[]): string => { + return value.map(htmlifyEditorNode).join('') +} + +export const generateExcerpt = (document: NodeType[], length = 200, tolerance = 20): string => { + // `length` is the max number of characters (codepoints) in the excerpt, + // tolerance is the number of characters we'll back-track looking for a word + // or sentence break to cut off at + const bodyText = extractText(document, nonBodyTypes) + + if (bodyText.length <= length) { + return bodyText + } + + let end = Math.min(length, bodyText.length) - 1 + let breakFound = false + + while (length - end < tolerance) { + if (breakCharacters.has(bodyText[end])) { + breakFound = true + break // heh! + } + end-- + } + + if (!breakFound) { + end += tolerance + } + + return bodyText.substr(0, end) +} + +const htmlifyEditorNode = (node: NodeType): string => { + if (!node.type && typeof node.text === 'string') { + // Special case for empty p tags. Slate renders these as a para with a br + // inside, so we follow suit here for consistency between editor and view + if (isEmptyParagraph(node)) { + return '


' + } + + // Wrap text node in each applicable element + return textNodeFormats.reduce((textNodeMarkup, format) => { + if (node[format]) { + const El = textNodeFormatEls[format] + return `<${El}>${textNodeMarkup}` + } else { + return textNodeMarkup + } + }, escapeHTML(node.text)) + } + + if (node.type === 'image') { + // Images are a special case since they have no closer nor children + return `` + } else { + const tagName = getNodeTagName(node) + let content = (node.children || []).map(htmlifyEditorNode).join('') + const attributes: string[] = [] + + if (node.type === 'link' && node.url) { + attributes.push(`href="${node.url}" target="_blank" rel="noopener noreferrer"`) + } else if (node.type === 'tr' && node.size) { + attributes.push(`style="height: ${node.size}px;"`) + } + + if (node.type === 'table' && node.colSizes) { + attributes.push('style="table-layout:fixed;"') + content = ` + + ${node.colSizes + .map((size) => ``) + .join('\n')} + + + ${content} + + ` + } + + return `<${tagName} ${attributes.join(' ')}>${content}` + } +} From ff1bbb535e9ce55f2bcf7388664b9d81d3d2db72 Mon Sep 17 00:00:00 2001 From: Robin MacPherson Date: Thu, 22 Feb 2024 08:38:23 -0800 Subject: [PATCH 2/3] Scaffold out new test setup & write custom matcher utils --- packages/web/resolvers/utils/slate.test.ts | 136 ++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/web/resolvers/utils/slate.test.ts b/packages/web/resolvers/utils/slate.test.ts index 2e8c5a20..1c7357ec 100644 --- a/packages/web/resolvers/utils/slate.test.ts +++ b/packages/web/resolvers/utils/slate.test.ts @@ -1,7 +1,113 @@ -import { isEmptyParagraph, applySuggestion, Doc, findCommonAncestor } from './slate' +import { Thread } from '@prisma/client' +import { + isEmptyParagraph, + applySuggestion, + extractText, + Doc, + findCommonAncestor, + updatedThreadPositions, +} from './slate' + +type Chunks = [number, string][] + +const insertChunk = (chunks: Chunks, insertedContent: string, position: number): Chunks => { + // [[0, "I "]. [2, "am"], [4, " the goat"]] + for (let i = 0; i < chunks.length; i++) { + const [start, content] = chunks[i] + if (start >= 0 && position >= start && position < start + content.length) { + const left = content.substring(0, position - start) + const right = content.substring(position - start) + + // -1 represents inserted content that doesn't affect the indices. + return [ + ...chunks.slice(0, i), + [start, left], + [-1, insertedContent], + [start + left.length, right], + ...chunks.slice(i), + ] + } + } + + throw new Error('We did not find a place to insert anything.') +} + +const serializeThreads = (threads: Thread[], document: Doc) => { + // Use [] to indicate where thread boundaries fall. + const serializedDoc = extractText(document) + // An array of tuples, with each tuple representing a chunk of text and its original index. + // The text will be "split" at the index of each thread so we can insert text + // while tracking the original index of each chunk. + + // 0123456789 + // "I am the goat" + // 0123456789 + // "I [am] the goat" + + // We want to keep track of the original indices before the matching "fence posts" were inserted. + let chunks: [number, string][] = [[0, serializedDoc]] + for (const thread of threads) { + chunks = insertChunk(chunks, '[', thread.startIndex) + chunks = insertChunk(chunks, ']', thread.endIndex) + } + return chunks.map((chunk) => chunk[1]).join('') +} +// TODO NEXT TIME: write test case for ... +// take in expected val, doc, threads, then call serialize thing on doc + threads, then just assert result = expexted val + +expect.extend({ + toMatchTodo(received, expected) { + // define Todo object structure with objectContaining + const expectTodoObject = (todo?: Todo) => + expect.objectContaining({ + id: todo?.id || expect.any(Number), + userId: todo?.userId || expect.any(Number), + title: todo?.title || expect.any(String), + completed: todo?.completed || expect.any(Boolean), + }) + + // define Todo array with arrayContaining and re-use expectTodoObject + const expectTodoArray = (todos: Array) => + todos.length === 0 + ? // in case an empty array is passed + expect.arrayContaining([expectTodoObject()]) + : // in case an array of Todos is passed + expect.arrayContaining(todos.map(expectTodoObject)) + + // expected can either be an array or an object + const expectedResult = Array.isArray(expected) + ? expectTodoArray(expected) + : expectTodoObject(expected) + + // equality check for received todo and expected todo + const pass = this.equals(received, expectedResult) + + if (pass) { + return { + message: () => + `Expected: ${this.utils.printExpected( + expectedResult, + )}\nReceived: ${this.utils.printReceived(received)}`, + pass: true, + } + } + return { + message: () => + `Expected: ${this.utils.printExpected( + expectedResult, + )}\nReceived: ${this.utils.printReceived(received)}\n\n${this.utils.diff( + expectedResult, + received, + )}`, + pass: false, + } + }, +}) const simpleDocument: Doc = [{ type: 'paragraph', children: [{ text: 'The quick brown fox.' }] }] +const cinemaDocument: Doc = [{ type: 'paragraph', children: [{ text: 'I am at the cinema' }] }] + const highlyStructuredDocument: Doc = [ { type: 'paragraph', @@ -66,4 +172,32 @@ describe('Slate Utils', () => { expect(findCommonAncestor(highlyStructuredDocument, 0, 7)).toEqual([null, 0]) }) }) + + describe('updateThreadPositions', () => { + it('handles short deletions with common char', () => { + const threads = [ + { + id: 42, + startIndex: 5, + endIndex: 7, + }, + ] as Thread[] + + const updatedCinemaDocument: Doc = [ + { type: 'paragraph', children: [{ text: 'I am the cinema' }] }, + ] + + const updatedThreads = updatedThreadPositions(cinemaDocument, updatedCinemaDocument, threads) + + // TOOD: create custom matcher to express expected vs. actual thread positions + // expected "I am the cinema" to be "I am [t]he cinema" + expect(updatedThreads).toEqual([ + { + id: 42, + startIndex: -1, + endIndex: -1, + }, + ]) + }) + }) }) From be332fde9ebe85441bc91a7097b798d5ed2073a7 Mon Sep 17 00:00:00 2001 From: Robin MacPherson Date: Thu, 25 Apr 2024 07:52:37 -0700 Subject: [PATCH 3/3] Make test pass and leave comment for future work --- packages/web/resolvers/utils/slate.test.ts | 83 +++++++--------------- 1 file changed, 26 insertions(+), 57 deletions(-) diff --git a/packages/web/resolvers/utils/slate.test.ts b/packages/web/resolvers/utils/slate.test.ts index 1c7357ec..9107d586 100644 --- a/packages/web/resolvers/utils/slate.test.ts +++ b/packages/web/resolvers/utils/slate.test.ts @@ -24,7 +24,7 @@ const insertChunk = (chunks: Chunks, insertedContent: string, position: number): [start, left], [-1, insertedContent], [start + left.length, right], - ...chunks.slice(i), + ...chunks.slice(i + 1), ] } } @@ -55,58 +55,30 @@ const serializeThreads = (threads: Thread[], document: Doc) => { // TODO NEXT TIME: write test case for ... // take in expected val, doc, threads, then call serialize thing on doc + threads, then just assert result = expexted val -expect.extend({ - toMatchTodo(received, expected) { - // define Todo object structure with objectContaining - const expectTodoObject = (todo?: Todo) => - expect.objectContaining({ - id: todo?.id || expect.any(Number), - userId: todo?.userId || expect.any(Number), - title: todo?.title || expect.any(String), - completed: todo?.completed || expect.any(Boolean), - }) - - // define Todo array with arrayContaining and re-use expectTodoObject - const expectTodoArray = (todos: Array) => - todos.length === 0 - ? // in case an empty array is passed - expect.arrayContaining([expectTodoObject()]) - : // in case an array of Todos is passed - expect.arrayContaining(todos.map(expectTodoObject)) - - // expected can either be an array or an object - const expectedResult = Array.isArray(expected) - ? expectTodoArray(expected) - : expectTodoObject(expected) - - // equality check for received todo and expected todo - const pass = this.equals(received, expectedResult) - - if (pass) { - return { - message: () => - `Expected: ${this.utils.printExpected( - expectedResult, - )}\nReceived: ${this.utils.printReceived(received)}`, - pass: true, - } - } - return { - message: () => - `Expected: ${this.utils.printExpected( - expectedResult, - )}\nReceived: ${this.utils.printReceived(received)}\n\n${this.utils.diff( - expectedResult, - received, - )}`, - pass: false, - } - }, +describe('Serialize threads', () => { + it('Should handle a single thread', () => { + const threads: Thread[] = [ + { + startIndex: 2, + endIndex: 4, + } as Thread, + ] + const doc: Doc = [ + { + text: 'I am the goat', + }, + ] + expect(serializeThreads(threads, doc)).toBe('I [am] the goat') + }) }) const simpleDocument: Doc = [{ type: 'paragraph', children: [{ text: 'The quick brown fox.' }] }] const cinemaDocument: Doc = [{ type: 'paragraph', children: [{ text: 'I am at the cinema' }] }] +const cinemaDocument2: Doc = [{ type: 'paragraph', children: [{ text: "I'm the cinema" }] }] +const fragmentedDelectionDocument: Doc = [ + { type: 'paragraph', children: [{ text: "That's is cool" }] }, +] const highlyStructuredDocument: Doc = [ { @@ -173,6 +145,9 @@ describe('Slate Utils', () => { }) }) + // NOTE: This test passes but is not the ideal behavior. + // Current idea (3/21/24) is to bias the diff towards continuous deletions vs. fragmented ones + // Current challenge is to think of a case where this would NOT be desired describe('updateThreadPositions', () => { it('handles short deletions with common char', () => { const threads = [ @@ -183,21 +158,15 @@ describe('Slate Utils', () => { }, ] as Thread[] + expect(serializeThreads(threads, cinemaDocument)).toEqual('I am [at] the cinema') + const updatedCinemaDocument: Doc = [ { type: 'paragraph', children: [{ text: 'I am the cinema' }] }, ] const updatedThreads = updatedThreadPositions(cinemaDocument, updatedCinemaDocument, threads) - // TOOD: create custom matcher to express expected vs. actual thread positions - // expected "I am the cinema" to be "I am [t]he cinema" - expect(updatedThreads).toEqual([ - { - id: 42, - startIndex: -1, - endIndex: -1, - }, - ]) + expect(serializeThreads(updatedThreads, updatedCinemaDocument)).toEqual('I am [t]he cinema') }) }) })