diff --git a/src/async-components/views/dialogs/eventindex/ConfirmTokenizerChangeDialog.tsx b/src/async-components/views/dialogs/eventindex/ConfirmTokenizerChangeDialog.tsx new file mode 100644 index 00000000000..14627d4b3a8 --- /dev/null +++ b/src/async-components/views/dialogs/eventindex/ConfirmTokenizerChangeDialog.tsx @@ -0,0 +1,60 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import { _t } from "../../../../languageHandler"; +import EventIndexPeg from "../../../../indexing/EventIndexPeg"; + +interface IProps { + onFinished: (confirmed?: boolean) => void; +} + +interface IState { + deleting: boolean; +} + +/* + * Confirmation dialog for deleting the database when tokenizer mode is changed. + */ +export default class ConfirmTokenizerChangeDialog extends React.Component { + public constructor(props: IProps) { + super(props); + this.state = { + deleting: false, + }; + } + + private readonly onConfirm = async (): Promise => { + this.setState({ + deleting: true, + }); + + await EventIndexPeg.deleteEventIndex(); + this.props.onFinished(true); + }; + + public render(): React.ReactNode { + return ( + + {_t("settings|security|tokenizer_mode_change_warning")} + {this.state.deleting ? :
} + + + ); + } +} diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index ad46048554a..f0712439b30 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -46,6 +46,12 @@ interface IState { /** Time to sleep between crawlwer passes, in milliseconds. */ crawlerSleepTime: number; + + /** Tokenizer mode for search indexing. */ + tokenizerMode: string; + + /** Initial tokenizer mode when dialog was opened. */ + initialTokenizerMode: string; } /* @@ -55,6 +61,7 @@ export default class ManageEventIndexDialog extends React.Component): void => { + this.setState({ tokenizerMode: e.target.value }); + // Don't save to settings yet - wait for Done button + }; + + private readonly onDone = async (): Promise => { + // Check if tokenizer mode has changed + if (this.state.tokenizerMode !== this.state.initialTokenizerMode) { + // Show confirmation dialog + const ConfirmTokenizerChangeDialog = (await import("./ConfirmTokenizerChangeDialog")).default; + Modal.createDialog( + ConfirmTokenizerChangeDialog, + { + onFinished: async (confirmed?: boolean) => { + if (confirmed) { + // Save the tokenizer mode setting + SettingsStore.setValue( + "tokenizerMode", + null, + SettingLevel.DEVICE, + this.state.tokenizerMode, + ); + } else { + // User cancelled - revert tokenizer mode to initial value + this.setState((prevState) => ({ tokenizerMode: prevState.initialTokenizerMode })); + SettingsStore.setValue( + "tokenizerMode", + null, + SettingLevel.DEVICE, + this.state.initialTokenizerMode, + ); + } + this.props.onFinished(); + }, + }, + undefined, + /* priority = */ false, + /* static = */ true, + ); + } else { + // No change, just close the dialog + this.props.onFinished(); + } + }; + public render(): React.ReactNode { const brand = SdkConfig.get().brand; @@ -165,6 +219,18 @@ export default class ManageEventIndexDialog extends React.Component + + + + +
+ {_t("settings|security|tokenizer_mode_description")} +
); @@ -178,7 +244,7 @@ export default class ManageEventIndexDialog extends React.Component%(brand)s Desktop for encrypted messages to appear in search results.", "record_session_details": "Record the client name, version, and url to recognise sessions more easily in session manager", "send_analytics": "Send analytics data", - "strict_encryption": "Only send messages to verified users" + "strict_encryption": "Only send messages to verified users", + "tokenizer_mode": "Search tokenizer mode", + "tokenizer_mode_change_warning": "Changing the tokenizer mode requires deleting the existing search index. The index will be rebuilt automatically. Do you want to continue?", + "tokenizer_mode_description": "Language-based: Single language support fixed to UI language. Only works with some languages (English, German, etc.). N-gram: Supports all languages including those without word boundaries. Works with mixed languages. Changing this setting will rebuild your search index.", + "tokenizer_mode_language": "Language-based (single language support based on UI settings)", + "tokenizer_mode_ngram": "N-gram (Multi-language support)" }, "send_read_receipts": "Send read receipts", "send_read_receipts_unsupported": "Your server doesn't support disabling sending read receipts.", diff --git a/src/indexing/BaseEventIndexManager.ts b/src/indexing/BaseEventIndexManager.ts index 8f0e5a1651f..c1668358b15 100644 --- a/src/indexing/BaseEventIndexManager.ts +++ b/src/indexing/BaseEventIndexManager.ts @@ -87,11 +87,16 @@ export default abstract class BaseEventIndexManager { * * @param {string} userId The event that should be added to the index. * @param {string} deviceId The profile of the event sender at the + * @param {string} tokenizerMode The tokenizer mode to use ("ngram" or "language") * * @return {Promise} A promise that will resolve when the event index is - * initialized. + * initialized. Returns { wasRecreated: true } if the database was recreated. */ - public async initEventIndex(userId: string, deviceId: string): Promise { + public async initEventIndex( + userId: string, + deviceId: string, + tokenizerMode?: string, + ): Promise<{ wasRecreated?: boolean } | void> { throw new Error("Unimplemented"); } diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index ff066026872..61dae96f96b 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -74,6 +74,8 @@ export default class EventIndex extends EventEmitter { * The current checkpoint that the crawler is working on. */ private currentCheckpoint: ICrawlerCheckpoint | null = null; + // Flag to force adding initial checkpoints (e.g., after database recreation) + private forceAddInitialCheckpoints = false; /** * True if we need to add the initial checkpoints for encrypted rooms, once we've completed a sync. @@ -106,6 +108,14 @@ export default class EventIndex extends EventEmitter { this.registerListeners(); } + /** + * Mark that initial checkpoints should be added on next sync. + * This is used when the database is recreated (e.g., schema change). + */ + public setForceAddInitialCheckpoints(force: boolean): void { + this.forceAddInitialCheckpoints = force; + } + /** * Register event listeners that are necessary for the event index to work. */ @@ -204,8 +214,10 @@ export default class EventIndex extends EventEmitter { if (!indexManager) return; // If the index was empty when we first started up, add the initial checkpoints, to back-populate the index. - if (this.needsInitialCheckpoints) { + // Also check forceAddInitialCheckpoints flag (used when database is recreated, e.g., schema change) + if (this.needsInitialCheckpoints || this.forceAddInitialCheckpoints) { await this.addInitialCheckpoints(); + this.forceAddInitialCheckpoints = false; } // Start the crawler if it's not already running. @@ -1017,7 +1029,7 @@ export default class EventIndex extends EventEmitter { }; const encryptedRooms = rooms.filter(isRoomEncrypted); - encryptedRooms.forEach((room, index) => { + encryptedRooms.forEach((room) => { totalRooms.add(room.roomId); }); diff --git a/src/indexing/EventIndexPeg.ts b/src/indexing/EventIndexPeg.ts index f33cbcc74ca..e2c8ef40183 100644 --- a/src/indexing/EventIndexPeg.ts +++ b/src/indexing/EventIndexPeg.ts @@ -54,7 +54,8 @@ export class EventIndexPeg { return false; } - if (!SettingsStore.getValueAt(SettingLevel.DEVICE, "enableEventIndexing")) { + const enableEventIndexing = SettingsStore.getValueAt(SettingLevel.DEVICE, "enableEventIndexing"); + if (!enableEventIndexing) { logger.log("EventIndex: Event indexing is disabled, not initializing"); return false; } @@ -78,20 +79,31 @@ export class EventIndexPeg { const userId = client.getUserId()!; const deviceId = client.getDeviceId()!; + const tokenizerMode = SettingsStore.getValueAt(SettingLevel.DEVICE, "tokenizerMode"); try { - await indexManager.initEventIndex(userId, deviceId); + const initResult = await indexManager.initEventIndex(userId, deviceId, tokenizerMode); + + // If the database was recreated (e.g., due to schema change), force re-adding checkpoints + if (initResult && typeof initResult === "object" && initResult.wasRecreated) { + logger.log("EventIndex: Database was recreated, will force add initial checkpoints"); + index.setForceAddInitialCheckpoints(true); + } const userVersion = await indexManager.getUserVersion(); const eventIndexIsEmpty = await indexManager.isEventIndexEmpty(); if (eventIndexIsEmpty) { await indexManager.setUserVersion(INDEX_VERSION); + // Force adding initial checkpoints because limited timeline events + // may add checkpoints before onSync is called + logger.log("EventIndex: Index is empty, will force add initial checkpoints"); + index.setForceAddInitialCheckpoints(true); } else if (userVersion === 0 && !eventIndexIsEmpty) { await indexManager.closeEventIndex(); await this.deleteEventIndex(); - await indexManager.initEventIndex(userId, deviceId); + await indexManager.initEventIndex(userId, deviceId, tokenizerMode); await indexManager.setUserVersion(INDEX_VERSION); } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fe57708dfa1..5180a1a9fef 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -337,6 +337,7 @@ export interface Settings { "RightPanel.phases": IBaseSetting; "enableEventIndexing": IBaseSetting; "crawlerSleepTime": IBaseSetting; + "tokenizerMode": IBaseSetting; "showCallButtonsInComposer": IBaseSetting; "ircDisplayNameWidth": IBaseSetting; "layout": IBaseSetting; @@ -1235,6 +1236,11 @@ export const SETTINGS: Settings = { displayName: _td("settings|security|message_search_sleep_time"), default: 3000, }, + "tokenizerMode": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + displayName: _td("settings|security|tokenizer_mode"), + default: "language", + }, "showCallButtonsInComposer": { // Dev note: This is no longer "in composer" but is instead "in room header". // TODO: Rename with settings v3 diff --git a/src/vector/platform/SeshatIndexManager.ts b/src/vector/platform/SeshatIndexManager.ts index 88a8889b722..16a972b7341 100644 --- a/src/vector/platform/SeshatIndexManager.ts +++ b/src/vector/platform/SeshatIndexManager.ts @@ -28,8 +28,12 @@ export class SeshatIndexManager extends BaseEventIndexManager { return this.ipc.call("supportsEventIndexing"); } - public async initEventIndex(userId: string, deviceId: string): Promise { - return this.ipc.call("initEventIndex", userId, deviceId); + public async initEventIndex( + userId: string, + deviceId: string, + tokenizerMode?: string, + ): Promise<{ wasRecreated?: boolean } | void> { + return this.ipc.call("initEventIndex", userId, deviceId, tokenizerMode); } public async addEventToIndex(ev: IMatrixEvent, profile: IMatrixProfile): Promise { diff --git a/test/unit-tests/async-components/dialogs/eventindex/ConfirmTokenizerChangeDialog-test.tsx b/test/unit-tests/async-components/dialogs/eventindex/ConfirmTokenizerChangeDialog-test.tsx new file mode 100644 index 00000000000..9d43d64e6cd --- /dev/null +++ b/test/unit-tests/async-components/dialogs/eventindex/ConfirmTokenizerChangeDialog-test.tsx @@ -0,0 +1,33 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { fireEvent, render, screen } from "jest-matrix-react"; + +import ConfirmTokenizerChangeDialog from "../../../../../src/async-components/views/dialogs/eventindex/ConfirmTokenizerChangeDialog"; +import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg"; +import { flushPromises } from "../../../../test-utils"; + +describe("", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("deletes the event index and finishes when confirmed", async () => { + const onFinished = jest.fn(); + jest.spyOn(EventIndexPeg, "deleteEventIndex").mockResolvedValue(undefined); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /ok/i })); + await flushPromises(); + + expect(EventIndexPeg.deleteEventIndex).toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledWith(true); + }); +}); diff --git a/test/unit-tests/async-components/dialogs/eventindex/ManageEventIndexDialog-test.tsx b/test/unit-tests/async-components/dialogs/eventindex/ManageEventIndexDialog-test.tsx new file mode 100644 index 00000000000..9d562dd9374 --- /dev/null +++ b/test/unit-tests/async-components/dialogs/eventindex/ManageEventIndexDialog-test.tsx @@ -0,0 +1,102 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { fireEvent, render, screen } from "jest-matrix-react"; + +import ManageEventIndexDialog from "../../../../../src/async-components/views/dialogs/eventindex/ManageEventIndexDialog"; +import Modal from "../../../../../src/Modal"; +import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../../src/settings/SettingLevel"; +import SdkConfig from "../../../../../src/SdkConfig"; +import { flushPromises } from "../../../../test-utils"; + +describe("", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockEventIndex = { + getStats: jest.fn().mockResolvedValue({ size: 1234, eventCount: 12, roomCount: 2 }), + crawlingRooms: jest.fn().mockReturnValue({ + crawlingRooms: new Set(["!room1:example.org"]), + totalRooms: new Set(["!room1:example.org", "!room2:example.org"]), + }), + currentRoom: jest.fn().mockReturnValue({ name: "Room A" }), + on: jest.fn(), + removeListener: jest.fn(), + }; + + function setUpDefaults(tokenizerMode: "ngram" | "language" = "language"): void { + jest.spyOn(SdkConfig, "get").mockReturnValue({ brand: "Element" } as any); + jest.spyOn(EventIndexPeg, "get").mockReturnValue(mockEventIndex as any); + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return tokenizerMode; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined as any); + } + + it("closes directly when tokenizer mode is unchanged", async () => { + setUpDefaults("language"); + const onFinished = jest.fn(); + const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any); + + render(); + await flushPromises(); + + fireEvent.click(screen.getByRole("button", { name: /done/i })); + + expect(createDialogSpy).not.toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalled(); + }); + + it("opens confirm dialog and saves tokenizer mode when confirmed", async () => { + setUpDefaults("language"); + const onFinished = jest.fn(); + const setValueSpy = jest.spyOn(SettingsStore, "setValue"); + + jest.spyOn(Modal, "createDialog").mockImplementation((_Component, props?: any) => { + props?.onFinished(true); + return {} as any; + }); + + render(); + await flushPromises(); + + fireEvent.change(screen.getByRole("combobox"), { target: { value: "ngram" } }); + fireEvent.click(screen.getByRole("button", { name: /done/i })); + await flushPromises(); + + expect(setValueSpy).toHaveBeenCalledWith("tokenizerMode", null, SettingLevel.DEVICE, "ngram"); + expect(onFinished).toHaveBeenCalled(); + }); + + it("opens confirm dialog and reverts tokenizer mode when cancelled", async () => { + setUpDefaults("language"); + const onFinished = jest.fn(); + const setValueSpy = jest.spyOn(SettingsStore, "setValue"); + + jest.spyOn(Modal, "createDialog").mockImplementation((_Component, props?: any) => { + props?.onFinished(false); + return {} as any; + }); + + render(); + await flushPromises(); + + fireEvent.change(screen.getByRole("combobox"), { target: { value: "ngram" } }); + fireEvent.click(screen.getByRole("button", { name: /done/i })); + await flushPromises(); + + expect(setValueSpy).toHaveBeenCalledWith("tokenizerMode", null, SettingLevel.DEVICE, "language"); + expect(onFinished).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/indexing/BaseEventIndexManager-test.ts b/test/unit-tests/indexing/BaseEventIndexManager-test.ts new file mode 100644 index 00000000000..da12723b94e --- /dev/null +++ b/test/unit-tests/indexing/BaseEventIndexManager-test.ts @@ -0,0 +1,19 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import BaseEventIndexManager from "../../../src/indexing/BaseEventIndexManager"; + +describe("BaseEventIndexManager", () => { + it("initEventIndex throws unimplemented error", async () => { + // BaseEventIndexManager is abstract but has no abstract methods, so we can instantiate a trivial subclass. + class TestManager extends BaseEventIndexManager {} + const mgr = new TestManager(); + + await expect(mgr.initEventIndex("@user:example.org", "DEVICE", "ngram")).rejects.toThrow("Unimplemented"); + }); +}); diff --git a/test/unit-tests/indexing/EventIndexPeg-test.ts b/test/unit-tests/indexing/EventIndexPeg-test.ts new file mode 100644 index 00000000000..075a823f555 --- /dev/null +++ b/test/unit-tests/indexing/EventIndexPeg-test.ts @@ -0,0 +1,195 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Mocked } from "jest-mock"; + +import { EventIndexPeg } from "../../../src/indexing/EventIndexPeg"; +import { mockPlatformPeg } from "../../test-utils"; +import type BaseEventIndexManager from "../../../src/indexing/BaseEventIndexManager"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import EventIndex from "../../../src/indexing/EventIndex"; + +jest.mock("../../../src/indexing/EventIndex"); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("EventIndexPeg", () => { + describe("initEventIndex", () => { + it("passes tokenizerMode to initEventIndex", async () => { + const mockIndexingManager = { + initEventIndex: jest.fn().mockResolvedValue(undefined), + getUserVersion: jest.fn().mockResolvedValue(1), + isEventIndexEmpty: jest.fn().mockResolvedValue(false), + } as any as Mocked; + mockPlatformPeg({ getEventIndexingManager: () => mockIndexingManager }); + + jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE123", + on: jest.fn(), + removeListener: jest.fn(), + } as any); + + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return "ngram"; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + + const peg = new EventIndexPeg(); + await peg.initEventIndex(); + + expect(mockIndexingManager.initEventIndex).toHaveBeenCalledWith("@user:example.org", "DEVICE123", "ngram"); + }); + + it("passes language tokenizer mode by default", async () => { + const mockIndexingManager = { + initEventIndex: jest.fn().mockResolvedValue(undefined), + getUserVersion: jest.fn().mockResolvedValue(1), + isEventIndexEmpty: jest.fn().mockResolvedValue(false), + } as any as Mocked; + mockPlatformPeg({ getEventIndexingManager: () => mockIndexingManager }); + + jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE123", + on: jest.fn(), + removeListener: jest.fn(), + } as any); + + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return "language"; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + + const peg = new EventIndexPeg(); + await peg.initEventIndex(); + + expect(mockIndexingManager.initEventIndex).toHaveBeenCalledWith( + "@user:example.org", + "DEVICE123", + "language", + ); + }); + + it("sets forceAddInitialCheckpoints when database was recreated", async () => { + const mockIndex = { + setForceAddInitialCheckpoints: jest.fn(), + init: jest.fn().mockResolvedValue(undefined), + }; + (EventIndex as unknown as jest.Mock).mockImplementation(() => mockIndex); + + const mockIndexingManager = { + initEventIndex: jest.fn().mockResolvedValue({ wasRecreated: true }), + getUserVersion: jest.fn().mockResolvedValue(1), + isEventIndexEmpty: jest.fn().mockResolvedValue(false), + } as any as Mocked; + mockPlatformPeg({ getEventIndexingManager: () => mockIndexingManager }); + + jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE123", + on: jest.fn(), + removeListener: jest.fn(), + } as any); + + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return "ngram"; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + + const peg = new EventIndexPeg(); + await peg.initEventIndex(); + + expect(mockIndex.setForceAddInitialCheckpoints).toHaveBeenCalledWith(true); + }); + + it("sets forceAddInitialCheckpoints when index is empty", async () => { + const mockIndex = { + setForceAddInitialCheckpoints: jest.fn(), + init: jest.fn().mockResolvedValue(undefined), + }; + (EventIndex as unknown as jest.Mock).mockImplementation(() => mockIndex); + + const mockIndexingManager = { + initEventIndex: jest.fn().mockResolvedValue(undefined), + getUserVersion: jest.fn().mockResolvedValue(1), + isEventIndexEmpty: jest.fn().mockResolvedValue(true), + setUserVersion: jest.fn().mockResolvedValue(undefined), + } as any as Mocked; + mockPlatformPeg({ getEventIndexingManager: () => mockIndexingManager }); + + jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE123", + on: jest.fn(), + removeListener: jest.fn(), + } as any); + + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return "ngram"; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + + const peg = new EventIndexPeg(); + await peg.initEventIndex(); + + expect(mockIndexingManager.setUserVersion).toHaveBeenCalledWith(1); + expect(mockIndex.setForceAddInitialCheckpoints).toHaveBeenCalledWith(true); + }); + + it("reinitializes index when userVersion is 0 and index is not empty", async () => { + const mockIndex = { + setForceAddInitialCheckpoints: jest.fn(), + init: jest.fn().mockResolvedValue(undefined), + }; + (EventIndex as unknown as jest.Mock).mockImplementation(() => mockIndex); + + const mockIndexingManager = { + initEventIndex: jest.fn().mockResolvedValue(undefined), + getUserVersion: jest.fn().mockResolvedValue(0), + isEventIndexEmpty: jest.fn().mockResolvedValue(false), + closeEventIndex: jest.fn().mockResolvedValue(undefined), + deleteEventIndex: jest.fn().mockResolvedValue(undefined), + setUserVersion: jest.fn().mockResolvedValue(undefined), + } as any as Mocked; + mockPlatformPeg({ getEventIndexingManager: () => mockIndexingManager }); + + jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE123", + on: jest.fn(), + removeListener: jest.fn(), + } as any); + + jest.spyOn(SettingsStore, "getValueAt").mockImplementation((_level, settingName): any => { + if (settingName === "tokenizerMode") return "ngram"; + if (settingName === "crawlerSleepTime") return 3000; + return undefined; + }); + + const peg = new EventIndexPeg(); + await peg.initEventIndex(); + + expect(mockIndexingManager.closeEventIndex).toHaveBeenCalled(); + expect(mockIndexingManager.deleteEventIndex).toHaveBeenCalled(); + expect(mockIndexingManager.initEventIndex).toHaveBeenCalledTimes(2); + expect(mockIndexingManager.initEventIndex).toHaveBeenNthCalledWith( + 2, + "@user:example.org", + "DEVICE123", + "ngram", + ); + }); + }); +}); diff --git a/test/unit-tests/vector/platform/SeshatIndexManager-test.ts b/test/unit-tests/vector/platform/SeshatIndexManager-test.ts new file mode 100644 index 00000000000..425ed2dabf4 --- /dev/null +++ b/test/unit-tests/vector/platform/SeshatIndexManager-test.ts @@ -0,0 +1,31 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { IPCManager } from "../../../../src/vector/platform/IPCManager"; +import { SeshatIndexManager } from "../../../../src/vector/platform/SeshatIndexManager"; + +describe("SeshatIndexManager", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("passes tokenizerMode to initEventIndex IPC call", async () => { + // IPCManager requires window.electron to exist. + window.electron = { + on: jest.fn(), + send: jest.fn(), + } as unknown as Electron; + + const ipcCallSpy = jest.spyOn(IPCManager.prototype, "call").mockResolvedValue(undefined); + const mgr = new SeshatIndexManager(); + + await mgr.initEventIndex("@user:example.org", "DEVICE123", "ngram"); + + expect(ipcCallSpy).toHaveBeenCalledWith("initEventIndex", "@user:example.org", "DEVICE123", "ngram"); + }); +});