diff --git a/Build/SilVersions.props b/Build/SilVersions.props index 551972abe1..3de6c4fb0d 100644 --- a/Build/SilVersions.props +++ b/Build/SilVersions.props @@ -12,7 +12,7 @@ ============================================================= --> - 11.0.0-beta0178 + 11.0.0-beta0180 18.0.0-beta0030 18.0.0-beta0012 6.0.0-beta0065 diff --git a/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs b/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs index 3f9627c211..89772c5428 100644 --- a/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs +++ b/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs @@ -205,6 +205,21 @@ public void Initialize(LcmCache cache, IVwStylesheet stylesheet, Mediator mediat ResumeLayout(false); } + /// + /// Swap the active search engine (e.g. when toggling substring-match mode). The caller + /// owns the lifetime of both engines; this only re-hooks the SearchCompleted event. + /// + public void SetSearchEngine(SearchEngine searchEngine) + { + CheckDisposed(); + if (ReferenceEquals(m_searchEngine, searchEngine)) + return; + if (m_searchEngine != null) + m_searchEngine.SearchCompleted -= m_searchEngine_SearchCompleted; + m_searchEngine = searchEngine; + m_searchEngine.SearchCompleted += m_searchEngine_SearchCompleted; + } + private void m_searchEngine_SearchCompleted(object sender, SearchCompletedEventArgs e) { UpdateResults(e.Fields.FirstOrDefault(), e.Results); diff --git a/Src/LexText/LexTextControls/EntryGoDlg.cs b/Src/LexText/LexTextControls/EntryGoDlg.cs index 1edac295ad..fae6c87271 100644 --- a/Src/LexText/LexTextControls/EntryGoDlg.cs +++ b/Src/LexText/LexTextControls/EntryGoDlg.cs @@ -41,6 +41,55 @@ protected override string PersistenceLabel get { return "EntryGo"; } } + /// + /// The default engine: full-text (word-prefix) matching. Cached in the property table. + /// Overridable so a subclass can supply a specialized engine (for example one that + /// excludes a starting entry). + /// + protected virtual SearchEngine FullTextSearchEngine + { + get + { + return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine", + () => new EntryGoSearchEngine(m_cache, SearchType.FullText)); + } + } + + /// + /// The substring (match-anywhere) engine, used once a query is long enough + /// (see ). A subclass can override it to supply a + /// specialized engine that also filters substring results. + /// + protected virtual SearchEngine SubstringSearchEngine + { + get + { + return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSubstringSearchEngine", + () => new EntryGoSearchEngine(m_cache, SearchType.Substring)); + } + } + + /// + /// True when this query should use substring matching. The decision (minimum query + /// length) lives in . Kept separate from + /// so callers can test the mode without instantiating the + /// substring engine. + /// + private bool UseSubstringFor(string searchKey) + { + return SubstringSearchPolicy.UseSubstring(searchKey); + } + + /// + /// Choose the engine for a given search key: the substring engine only when + /// is true; otherwise the default full-text engine + /// (which still returns its normal results for short keys). + /// + private SearchEngine SearchEngineFor(string searchKey) + { + return UseSubstringFor(searchKey) ? SubstringSearchEngine : FullTextSearchEngine; + } + /// /// Get/Set the starting entry object. This will not be displayed in the list of /// matching entries. @@ -85,10 +134,8 @@ protected override void InitializeMatchingObjects(LcmCache cache) var xnWindow = m_propertyTable.GetValue("WindowConfiguration"); XmlNode configNode = xnWindow.SelectSingleNode("controls/parameters/guicontrol[@id=\"matchingEntries\"]/parameters"); - SearchEngine searchEngine = SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine", () => new EntryGoSearchEngine(cache)); - m_matchingObjectsBrowser.Initialize(cache, FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable), m_mediator, m_propertyTable, configNode, - searchEngine); + SearchEngineFor(string.Empty)); m_matchingObjectsBrowser.ColumnsChanged += m_matchingObjectsBrowser_ColumnsChanged; @@ -97,6 +144,7 @@ protected override void InitializeMatchingObjects(LcmCache cache) if (selectedWs != null) m_matchingObjectsBrowser.SearchAsync(GetFields(string.Empty, selectedWs.Handle)); } + #endregion Construction and Destruction #region Other methods @@ -158,6 +206,9 @@ protected override void ResetMatches(string searchKey) m_oldSearchKey = searchKey; m_oldSearchWs = wsSelHvo; + // Select the engine for this query: substring once the key is long enough + // (>= MinQueryLength), otherwise full-text so short keys behave like the original. + m_matchingObjectsBrowser.SetSearchEngine(SearchEngineFor(searchKey)); m_matchingObjectsBrowser.SearchAsync(GetFields(searchKey, wsSelHvo)); } diff --git a/Src/LexText/LexTextControls/EntryGoSearchEngine.cs b/Src/LexText/LexTextControls/EntryGoSearchEngine.cs index a47d66ad00..3a4a9cce66 100644 --- a/Src/LexText/LexTextControls/EntryGoSearchEngine.cs +++ b/Src/LexText/LexTextControls/EntryGoSearchEngine.cs @@ -20,8 +20,8 @@ internal class EntryGoSearchEngine : SearchEngine { private readonly Virtuals m_virtuals; - public EntryGoSearchEngine(LcmCache cache) - : base(cache, SearchType.FullText) + public EntryGoSearchEngine(LcmCache cache, SearchType searchType = SearchType.FullText) + : base(cache, searchType) { m_virtuals = Cache.ServiceLocator.GetInstance(); } diff --git a/Src/LexText/LexTextControls/LexTextControls.Designer.cs b/Src/LexText/LexTextControls/LexTextControls.Designer.cs index 8611971f6b..e581cf3f2f 100644 --- a/Src/LexText/LexTextControls/LexTextControls.Designer.cs +++ b/Src/LexText/LexTextControls/LexTextControls.Designer.cs @@ -1149,7 +1149,7 @@ internal static string ksFindLexEntry { return ResourceManager.GetString("ksFindLexEntry", resourceCulture); } } - + /// /// Looks up a localized string similar to Find Record. /// diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/MergeEntrySearchEngineTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/MergeEntrySearchEngineTests.cs new file mode 100644 index 0000000000..bb2d3484d7 --- /dev/null +++ b/Src/LexText/LexTextControls/LexTextControlsTests/MergeEntrySearchEngineTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.LexText.Controls; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.DomainServices; + +namespace LexTextControlsTests +{ + /// + /// The Merge Entry search engine must exclude the starting entry from its matches in BOTH + /// full-text and substring modes, so an entry can never be offered as a target for merging + /// into itself. The substring case matters because a typical multi-character merge-target + /// query runs under substring matching (see ). + /// + [TestFixture] + public class MergeEntrySearchEngineTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private ILexEntry _language; + private ILexEntry _languor; + + // The base opens a UOW in TestSetup and calls CreateTestData() inside it, so data is + // created directly here with no UOW wrapper (a nested task would throw). + protected override void CreateTestData() + { + base.CreateTestData(); + _language = MakeEntry("language", "speech"); + _languor = MakeEntry("languor", "weariness"); + } + + private ILexEntry MakeEntry(string lexemeForm, string gloss) + { + var components = new LexEntryComponents + { + MorphType = Cache.ServiceLocator.GetInstance() + .GetObject(MoMorphTypeTags.kguidMorphStem) + }; + components.LexemeFormAlternatives.Add(TsStringUtils.MakeString(lexemeForm, Cache.DefaultVernWs)); + components.GlossAlternatives.Add(TsStringUtils.MakeString(gloss, Cache.DefaultAnalWs)); + return Cache.ServiceLocator.GetInstance().Create(components); + } + + private IEnumerable LexemeFormQuery(string query) + { + var tss = TsStringUtils.MakeString(query, Cache.DefaultVernWs); + return new[] { new SearchField(LexEntryTags.kflidLexemeForm, tss) }; + } + + [Test] + public void FullTextEngine_ExcludesTheStartingEntry() + { + using (var engine = new MergeEntryDlg.MergeEntrySearchEngine(Cache, SearchType.FullText)) + { + // "langu" is a word-prefix shared by both entries, so full-text matches both. + var withoutExclusion = engine.Search(LexemeFormQuery("langu")).ToList(); + Assert.That(withoutExclusion, Does.Contain(_language.Hvo).And.Contain(_languor.Hvo), + "both entries match the shared prefix before any entry is excluded"); + + engine.CurrentEntryHvo = _language.Hvo; + var withExclusion = engine.Search(LexemeFormQuery("langu")).ToList(); + Assert.That(withExclusion, Does.Not.Contain(_language.Hvo), + "the starting entry is never offered as a target for merging into itself"); + Assert.That(withExclusion, Does.Contain(_languor.Hvo), + "the other matching entry still appears"); + } + } + + [Test] + public void SubstringEngine_ExcludesTheStartingEntry() + { + using (var engine = new MergeEntryDlg.MergeEntrySearchEngine(Cache, SearchType.Substring)) + { + // "angu" is an interior substring of both entries and a prefix of neither, so it + // exercises the substring (match-anywhere) path specifically. + var withoutExclusion = engine.Search(LexemeFormQuery("angu")).ToList(); + Assert.That(withoutExclusion, Does.Contain(_language.Hvo).And.Contain(_languor.Hvo), + "both entries match the interior substring before any entry is excluded"); + + engine.CurrentEntryHvo = _language.Hvo; + var withExclusion = engine.Search(LexemeFormQuery("angu")).ToList(); + Assert.That(withExclusion, Does.Not.Contain(_language.Hvo), + "substring results also exclude the starting entry (the self-merge guard)"); + Assert.That(withExclusion, Does.Contain(_languor.Hvo), + "the other interior-substring match still appears"); + } + } + } +} diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs new file mode 100644 index 0000000000..1e870e076a --- /dev/null +++ b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using NUnit.Framework; +using SIL.FieldWorks.LexText.Controls; + +namespace LexTextControlsTests +{ + /// + /// Verifies the query-length gate that decides when Find Lexical Entry uses substring + /// (match-anywhere) matching. + /// + [TestFixture] + public class SubstringSearchPolicyTests + { + // A base letter followed by a combining acute accent (U+0301): 2 UTF-16 units that + // compose to a single character under FormC normalization. + private static readonly string ComposedAcuteE = "e" + (char)0x0301; + + [TestCase("", ExpectedResult = false)] + [TestCase("l", ExpectedResult = false)] + [TestCase("la", ExpectedResult = false)] // below MinQueryLength + [TestCase("lan", ExpectedResult = true)] // exactly MinQueryLength + [TestCase("language", ExpectedResult = true)] + public bool UseSubstring_gatesOnLength(string key) + { + return SubstringSearchPolicy.UseSubstring(key); + } + + [Test] + public void UseSubstring_nullKey_isFalse() + { + Assert.That(SubstringSearchPolicy.UseSubstring(null), Is.False); + } + + [Test] + public void UseSubstring_countsComposedCharacters_notUtf16Units() + { + // Each ComposedAcuteE is 2 UTF-16 units but 1 character after FormC. Counting raw + // Length would see 4 and 6 (both >= 3) and wrongly enable substring; composed + // characters count as 2 and 3. + string twoComposed = ComposedAcuteE + ComposedAcuteE; // raw Length 4 -> 2 + string threeComposed = ComposedAcuteE + ComposedAcuteE + ComposedAcuteE; // raw Length 6 -> 3 + + Assert.That(SubstringSearchPolicy.UseSubstring(twoComposed), Is.False, + "two composed characters should count as length 2, below the threshold"); + Assert.That(SubstringSearchPolicy.UseSubstring(threeComposed), Is.True, + "three composed characters should count as length 3, at the threshold"); + } + + [Test] + public void MinQueryLength_hasExpectedDefault() + { + Assert.That(SubstringSearchPolicy.MinQueryLength, Is.EqualTo(3)); + } + } +} diff --git a/Src/LexText/LexTextControls/MergeEntryDlg.cs b/Src/LexText/LexTextControls/MergeEntryDlg.cs index 3fb55eadb2..0661121691 100644 --- a/Src/LexText/LexTextControls/MergeEntryDlg.cs +++ b/Src/LexText/LexTextControls/MergeEntryDlg.cs @@ -187,35 +187,44 @@ protected override void SetBottomMessage() m_fwTextBoxBottomMsg.Tss = tsb.GetString(); } - protected override void InitializeMatchingObjects(LcmCache cache) + /// + /// The full-text engine, specialized to drop the starting entry so an entry can never be + /// offered as a merge target for itself. + /// + protected override SearchEngine FullTextSearchEngine { - var xnWindow = m_propertyTable.GetValue("WindowConfiguration"); - XmlNode configNode = xnWindow.SelectSingleNode("controls/parameters/guicontrol[@id=\"matchingEntries\"]/parameters"); - - var searchEngine = (MergeEntrySearchEngine)SearchEngine.Get(m_mediator, m_propertyTable, "MergeEntrySearchEngine", () => new MergeEntrySearchEngine(cache)); - searchEngine.CurrentEntryHvo = m_startingEntry.Hvo; + get { return MergeSearchEngine("MergeEntrySearchEngine", SearchType.FullText); } + } - m_matchingObjectsBrowser.Initialize(cache, FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable), m_mediator, m_propertyTable, configNode, - searchEngine); + /// + /// The substring engine, specialized to drop the starting entry. + /// + protected override SearchEngine SubstringSearchEngine + { + get { return MergeSearchEngine("MergeEntrySubstringSearchEngine", SearchType.Substring); } + } - // start building index - var selectedWs = (CoreWritingSystemDefinition) m_cbWritingSystems.SelectedItem; - if(selectedWs != null) - m_matchingObjectsBrowser.SearchAsync(GetFields(string.Empty, selectedWs.Handle)); + private SearchEngine MergeSearchEngine(string cacheKey, SearchType searchType) + { + var searchEngine = (MergeEntrySearchEngine)SearchEngine.Get(m_mediator, m_propertyTable, + cacheKey, () => new MergeEntrySearchEngine(m_cache, searchType)); + searchEngine.CurrentEntryHvo = m_startingEntry.Hvo; + return searchEngine; } /// - /// A search engine that excludes the current entry (you can't merge an entry with its self + /// A search engine that excludes the current entry, since you cannot merge an entry + /// with itself. /// - private class MergeEntrySearchEngine : EntryGoSearchEngine + internal class MergeEntrySearchEngine : EntryGoSearchEngine { public int CurrentEntryHvo { private get; set; } - public MergeEntrySearchEngine(LcmCache cache) : base(cache) + public MergeEntrySearchEngine(LcmCache cache, SearchType searchType) : base(cache, searchType) { } - protected override IEnumerable FilterResults(IEnumerable results) + protected override IEnumerable FilterResults(IEnumerable results) { return results == null ? null : results.Where(hvo => hvo != CurrentEntryHvo); } diff --git a/Src/LexText/LexTextControls/SubstringSearchPolicy.cs b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs new file mode 100644 index 0000000000..22fb7cfef8 --- /dev/null +++ b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Text; + +namespace SIL.FieldWorks.LexText.Controls +{ + /// + /// UI-independent decision logic for the Find Lexical Entry "match anywhere" (substring) + /// search mode: when substring matching applies, plus its tuning knobs. Kept out of + /// so it can be unit-tested without driving the dialog. + /// + internal static class SubstringSearchPolicy + { + /// + /// Substring matching engages only once the query is at least this many characters; + /// shorter keys fall back to the default full-text engine, so short queries behave like + /// the original search (a 1-2 char substring in a large project would otherwise match + /// most entries). + /// + public const int MinQueryLength = 3; + + /// + /// True when a query should use substring matching: the key is at least + /// characters. Length is counted after FormC normalization, + /// so a base character plus a combining diacritic counts as one character. Shorter keys + /// fall back to the default full-text search. + /// + /// The (already trimmed) search key. + public static bool UseSubstring(string searchKey) + { + return !string.IsNullOrEmpty(searchKey) + && searchKey.Normalize(NormalizationForm.FormC).Length >= MinQueryLength; + } + } +}