diff --git a/src/SIL.XForge.Scripture/Models/TextData.cs b/src/SIL.XForge.Scripture/Models/TextData.cs index b14997fd37..74e20439a0 100644 --- a/src/SIL.XForge.Scripture/Models/TextData.cs +++ b/src/SIL.XForge.Scripture/Models/TextData.cs @@ -11,6 +11,9 @@ public class TextData : Delta, IIdentifiable public static string GetTextDocId(string projectId, int book, int chapter) => $"{projectId}:{Canon.BookNumberToId(book)}:{chapter}:target"; + public static string GetTextDocId(string projectId, string book, int chapter) => + $"{projectId}:{book}:{chapter}:target"; + public TextData() { } public TextData(Delta delta) diff --git a/src/SIL.XForge.Scripture/Services/ParatextService.cs b/src/SIL.XForge.Scripture/Services/ParatextService.cs index b586c5003f..c3ae3ca441 100644 --- a/src/SIL.XForge.Scripture/Services/ParatextService.cs +++ b/src/SIL.XForge.Scripture/Services/ParatextService.cs @@ -1910,8 +1910,8 @@ DateTime timestamp using ScrText scrText = GetScrText(userSecret, ptProjectId); VerseRef verseRef = new VerseRef($"{book} {chapter}:0"); - TextSnapshot ret = null; - string id = $"{sfProjectId}:{book}:{chapter}:target"; + TextSnapshot ret; + string id = TextData.GetTextDocId(sfProjectId, book, chapter); Snapshot snapshot = await connection.FetchSnapshotAsync(id, timestamp); if (snapshot.Data is null) @@ -1924,10 +1924,10 @@ DateTime timestamp HgRevisionCollection revisionCollection = HgRevisionCollection.Get(scrText); DateTimeOffset timeStampOffset = new DateTimeOffset(timestamp, TimeSpan.Zero); HgRevision? revision = revisionCollection - .MutableCollection.Where(r => r.CommitTimeStamp <= timeStampOffset) + .FilterRevisions(r => r.CommitTimeStamp <= timeStampOffset) .MaxBy(r => r.CommitTimeStamp); - // No revision was before than the timestamp, so get the first revision + // No revision was before the timestamp, so get the first revision revision ??= revisionCollection.MinBy(r => r.LocalRevisionNumber); if (revision is null) { @@ -1988,7 +1988,7 @@ int chapter throw new ForbiddenException(); } - string id = $"{sfProjectId}:{book}:{chapter}:target"; + string id = TextData.GetTextDocId(sfProjectId, book, chapter); Op[] ops = await connection.GetOpsAsync(id); // Iterate over the ops in reverse order, returning a milestone at least every 15 minutes @@ -2062,11 +2062,11 @@ int chapter // Note: The following code is not testable due to ParatextData limitations // Iterate over the Paratext commits earlier than the earliest MongoOp DateTimeOffset timeStampOffset = new DateTimeOffset(milestonePeriod, TimeSpan.Zero); - HgRevisionCollection revisionCollection = HgRevisionCollection.Get(scrText); + HgRevisionCollection revisionCollection = HgRevisionCollection + .Get(scrText) + .FilterRevisions(r => r.CommitTimeStamp <= timeStampOffset); int bookNum = Canon.BookIdToNumber(book); - foreach ( - HgRevision revision in revisionCollection.MutableCollection.Where(r => r.CommitTimeStamp <= timeStampOffset) - ) + foreach (HgRevision revision in revisionCollection) { // Get the revision summary to see if the book and chapter has changed RevisionChangeInfo revisionSummary = revisionCollection.GetSummaryFor(revision); diff --git a/src/SIL.XForge.Scripture/Services/SFScrTextCollection.cs b/src/SIL.XForge.Scripture/Services/SFScrTextCollection.cs index 8c79fee0b8..d9f986f0c9 100644 --- a/src/SIL.XForge.Scripture/Services/SFScrTextCollection.cs +++ b/src/SIL.XForge.Scripture/Services/SFScrTextCollection.cs @@ -21,6 +21,13 @@ public class SFScrTextCollection : ScrTextCollection /// public static string ResourcesByIdDirectory => Path.Combine(SettingsDirectory, "_resourcesById"); + /// + /// Adds a ScrText to the internal index. This should only be used for testing purposes! + /// + /// The scripture text to add to the index. + public void AddToInternalIndex(ScrText scrText) => + AddInternal(scrText, skipChangeNotify: false, checkAlreadyExists: false); + protected override string DictionariesDirectoryInternal => null; protected override void InitializeInternal(string settingsDir, bool allowMigration) diff --git a/src/SIL.XForge/DataAccess/MemoryRepository.cs b/src/SIL.XForge/DataAccess/MemoryRepository.cs index 5d3cd0e4e0..e9e4453bb3 100644 --- a/src/SIL.XForge/DataAccess/MemoryRepository.cs +++ b/src/SIL.XForge/DataAccess/MemoryRepository.cs @@ -8,6 +8,7 @@ using MongoDB.Bson; using Newtonsoft.Json; using SIL.XForge.Models; +using SIL.XForge.Realtime; using SIL.XForge.Utils; namespace SIL.XForge.DataAccess; @@ -23,7 +24,8 @@ public class MemoryRepository : IRepository Converters = [new BsonValueConverter()], }; - private readonly ConcurrentDictionary _entities; + private readonly ConcurrentDictionary _entities = []; + private readonly ConcurrentDictionary _entityOps = []; private readonly Func[] _uniqueKeySelectors; private readonly HashSet[] _uniqueKeys; @@ -37,7 +39,6 @@ public MemoryRepository(IEnumerable>? uniqueKeySelectors = null, for (int i = 0; i < _uniqueKeys.Length; i++) _uniqueKeys[i] = []; - _entities = new ConcurrentDictionary(); if (entities != null) Add(entities); } @@ -86,6 +87,10 @@ public void Replace(T entity) public T Get(string id) => DeserializeEntity(id, _entities[id]); + public Op[] GetOps(string id) => _entityOps.TryGetValue(id, out Op[] ops) ? ops : []; + + public void SetOps(string id, Op[] ops) => _entityOps[id] = ops; + public IQueryable Query() => _entities.Select(kvp => DeserializeEntity(kvp.Key, kvp.Value)).AsQueryable(); public Task InsertAsync(T entity) diff --git a/src/SIL.XForge/Realtime/MemoryConnection.cs b/src/SIL.XForge/Realtime/MemoryConnection.cs index 6d58d62660..a0113db1b4 100644 --- a/src/SIL.XForge/Realtime/MemoryConnection.cs +++ b/src/SIL.XForge/Realtime/MemoryConnection.cs @@ -103,39 +103,9 @@ public async Task> FetchSnapshotAsync(string id, DateTime timesta /// /// Gets the ops for a document. /// - /// A default Op array for test purposes. + /// An Op array. public Task GetOpsAsync(string id) - where T : IIdentifiable => - Task.FromResult( - new Op[] - { - new Op - { - Metadata = new OpMetadata { Timestamp = DateTime.UtcNow.AddMinutes(-30) }, - Version = 1, - }, - new Op - { - Metadata = new OpMetadata { Timestamp = DateTime.UtcNow.AddMinutes(-10) }, - Version = 2, - }, - new Op - { - Metadata = new OpMetadata { Timestamp = DateTime.UtcNow.AddMinutes(-1) }, - Version = 3, - }, - new Op - { - Metadata = new OpMetadata - { - Timestamp = DateTime.UtcNow, - UserId = "user01", - Source = OpSource.Draft, - }, - Version = 4, - }, - } - ); + where T : IIdentifiable => Task.FromResult(_realtimeService.GetRepository().GetOps(id)); public IDocument Get(string id) where T : IIdentifiable diff --git a/src/SIL.XForge/Realtime/MemoryRealtimeService.cs b/src/SIL.XForge/Realtime/MemoryRealtimeService.cs index 708167bfed..c46e200298 100644 --- a/src/SIL.XForge/Realtime/MemoryRealtimeService.cs +++ b/src/SIL.XForge/Realtime/MemoryRealtimeService.cs @@ -30,14 +30,8 @@ private static INodeJSService CreateNodeJSService() return sp.GetRequiredService(); } - private readonly Dictionary _repos; - private readonly Dictionary _docConfigs; - - public MemoryRealtimeService() - { - _repos = []; - _docConfigs = []; - } + private readonly Dictionary _repos = []; + private readonly Dictionary _docConfigs = []; /// /// Gets or sets the last modified user identifier. diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 6f81a56e85..f022dd2a23 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -1204,7 +1204,7 @@ public async Task GetPreTranslationDeltaAsync_Success() CancellationToken.None ); Assert.AreEqual(expected.Ops[0], actual.Data.Ops[0]); - Assert.AreEqual($"{Project01}:MAT:1:target", actual.Id); + Assert.AreEqual(TextData.GetTextDocId(Project01, "MAT", 1), actual.Id); } [Test] diff --git a/test/SIL.XForge.Scripture.Tests/Services/MockHg.cs b/test/SIL.XForge.Scripture.Tests/Services/MockHg.cs new file mode 100644 index 0000000000..527c3f156d --- /dev/null +++ b/test/SIL.XForge.Scripture.Tests/Services/MockHg.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using Paratext.Data.Repository; + +namespace SIL.XForge.Scripture.Services; + +internal class MockHg : Hg +{ + /// + /// A log containing a revision from Paratext + /// + public readonly List Log = + [ + new HgRevision + { + Id = "2", + CommitTimeStampString = "2024-11-13T11:43:01+13:00", + Filenames = "08RUT.SFM", + ParentsString = "1", + UserEscaped = "pt_username", + }, + ]; + + public override bool IsRepository(string repository) => true; + + public override List GetLog(string repository, string startRev, string endRev) => Log; + + public override List GetLogWithLimit(string repository, int limit) => Log; +} diff --git a/test/SIL.XForge.Scripture.Tests/Services/MockHgRunner.cs b/test/SIL.XForge.Scripture.Tests/Services/MockHgRunner.cs new file mode 100644 index 0000000000..13b8b8f207 --- /dev/null +++ b/test/SIL.XForge.Scripture.Tests/Services/MockHgRunner.cs @@ -0,0 +1,59 @@ +using Paratext.Data; + +namespace SIL.XForge.Scripture.Services; + +internal class MockHgRunner() : HgExeRunner(null, null, null) +{ + private bool _appendCallCount; + private int _callCount; + private string _standardOutput = string.Empty; + + /// + /// Mocks running a Mercurial command by incrementing a call count. + /// + /// + public override void RunHg(string args) => _callCount++; + + /// + /// Gets a success exit code. + /// + public override int ExitCode => 0; + + /// + /// Gets the standard error, which is always empty. + /// + public override string StandardError => string.Empty; + + /// + /// Gets the standard output. + /// + public override string StandardOutput => + _standardOutput + (_appendCallCount ? _callCount.ToString() : string.Empty); + + /// + /// Gets the standard output. + /// + public override string StandardOutputNoTrim => StandardOutput; + + /// + /// Initialize the HgRunner (i.e. do nothing) + /// + public override void Initialize() + { + // Do nothing + } + + /// + /// Sets the standard output + /// + /// The standard output. + /// + /// Adds the call count to the end of the output. + /// This is useful to simulate the last verse changing in hg cat output. + /// + public void SetStandardOutput(string standardOutput, bool appendCallCountToOutput) + { + _standardOutput = standardOutput; + _appendCallCount = appendCallCountToOutput; + } +} diff --git a/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs index cf58521cdb..3ed4a1cb8e 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/ParatextServiceTests.cs @@ -5302,6 +5302,56 @@ public void ClearParatextDataCaches_Success() env.Service.ClearParatextDataCaches(userSecret, ptProjectId); } + [Test] + public async Task GetRevisionHistoryAsync_DoesNotCrashWhenASyncUpdatesTheParatextRevisions() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + var associatedPtUser = new SFParatextUser(env.Username01); + env.SetupProject(env.Project01, associatedPtUser); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + env.AddTextDataOps(project.Id, "RUT", 1); + env.ProjectHgRunner.SetStandardOutput(env.RuthBookUsfm, true); + + // SUT + bool historyExists = false; + int count = 0; + await foreach ( + DocumentRevision revision in env.Service.GetRevisionHistoryAsync(userSecret, project.Id, "RUT", 1) + ) + { + // This will loop through the 4 valid ops in MemoryConnection.GetOpsAsync() then the 1 revision in MockHg._log + historyExists = true; + count++; + + // Check the op sources + if (count == 4) + { + // The last revision should be the one from MockHg._log + Assert.AreEqual(revision.Source, OpSource.Paratext); + } + else if (count == 1) + { + // The first revision has a valid source + Assert.AreEqual(revision.Source, OpSource.Draft); + } + else + { + // The others do not + Assert.IsNull(revision.Source); + } + + // Simulate a sync updating the revisions on disk + WriteLock writeLock = WriteLockManager.Default.ObtainLock(WriteScope.ProjectRepository(env.ProjectScrText)); + writeLock.ReleaseAndNotify(); + } + + Assert.AreEqual(4, count); + Assert.IsTrue(historyExists); + } + [Test] public void GetRevisionHistoryAsync_InsufficientPermissions() { @@ -5357,6 +5407,7 @@ public async Task GetRevisionHistoryAsync_MissingParatextDirectory() SFProject project = env.NewSFProject(); project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; env.AddProjectRepository(project); + env.AddTextDataOps(project.Id, "MAT", 1); env.MockScrTextCollection.FindById(Arg.Any(), Arg.Any()).ReturnsNull(); // SUT @@ -5369,6 +5420,90 @@ public async Task GetRevisionHistoryAsync_MissingParatextDirectory() Assert.IsTrue(historyExists); } + [Test] + public async Task GetRevisionHistoryAsync_NoBookChangesInMercurial() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + var associatedPtUser = new SFParatextUser(env.Username01); + env.SetupProject(env.Project01, associatedPtUser); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + env.AddTextDataOps(project.Id, "MAT", 1); + env.ProjectHgRunner.SetStandardOutput(string.Empty, false); + + // SUT + bool historyExists = false; + int count = 0; + await foreach ( + DocumentRevision revision in env.Service.GetRevisionHistoryAsync(userSecret, project.Id, "MAT", 1) + ) + { + // This will loop through the 4 valid ops (combined to 3) in MemoryConnection.GetOpsAsync() + // and skip the 1 revision in MockHg._log + historyExists = true; + count++; + + // Check the op sources + if (count == 1) + { + // The first revision has a valid source + Assert.AreEqual(revision.Source, OpSource.Draft); + } + else + { + // The others do not + Assert.IsNull(revision.Source); + } + } + + Assert.AreEqual(3, count); + Assert.IsTrue(historyExists); + } + + [Test] + public async Task GetRevisionHistoryAsync_NoChapterChangesInMercurial() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + var associatedPtUser = new SFParatextUser(env.Username01); + env.SetupProject(env.Project01, associatedPtUser); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + env.AddTextDataOps(project.Id, "RUT", 1); + env.ProjectHgRunner.SetStandardOutput(env.RuthBookUsfm, false); + + // SUT + bool historyExists = false; + int count = 0; + await foreach ( + DocumentRevision revision in env.Service.GetRevisionHistoryAsync(userSecret, project.Id, "RUT", 1) + ) + { + // This will loop through the 4 valid ops (combined to 3) in MemoryConnection.GetOpsAsync() + // and skip the 1 revision in MockHg._log + historyExists = true; + count++; + + // Check the op sources + if (count == 1) + { + // The first revision has a valid source + Assert.AreEqual(revision.Source, OpSource.Draft); + } + else + { + // The others do not + Assert.IsNull(revision.Source); + } + } + + Assert.AreEqual(3, count); + Assert.IsTrue(historyExists); + } + [Test] public async Task GetRevisionHistoryAsync_Success() { @@ -5379,6 +5514,7 @@ public async Task GetRevisionHistoryAsync_Success() SFProject project = env.NewSFProject(); project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; env.AddProjectRepository(project); + env.AddTextDataOps(project.Id, "MAT", 1); // SUT bool historyExists = false; @@ -5398,7 +5534,67 @@ DocumentRevision revision in env.Service.GetRevisionHistoryAsync(userSecret, pro } [Test] - public async Task GetSnapshotAsync_FetchesSnapshot() + public async Task GetSnapshotAsync_FetchesEarliestSnapshotFromParatext() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + const string book = "RUT"; + const int chapter = 1; + env.RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository()); + env.ProjectHgRunner.SetStandardOutput(env.RuthBookUsfm, true); + TextData textData = env.GetTextDoc(Canon.BookIdToNumber(book), chapter); + + var associatedPtUser = new SFParatextUser(env.Username01); + string ptProjectId = env.SetupProject(env.Project01, associatedPtUser); + ScrText scrText = env.GetScrText(associatedPtUser, ptProjectId); + + env.MockScrTextCollection.FindById(Arg.Any(), Arg.Any()).Returns(_ => scrText); + env.MockDeltaUsxMapper.ToChapterDeltas(Arg.Any()) + .Returns([new ChapterDelta(chapter, 1, false, textData)]); + + // SUT + var actual = await env.Service.GetSnapshotAsync(userSecret, project.Id, book, chapter, DateTime.MinValue); + Assert.AreEqual(textData.Ops.First(), actual.Data.Ops.First()); + Assert.AreEqual(textData.Id, actual.Id); + Assert.AreEqual(0, actual.Version); + Assert.AreEqual(false, actual.IsValid); + } + + [Test] + public async Task GetSnapshotAsync_FetchesSpecifiedSnapshotFromParatext() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + const string book = "RUT"; + const int chapter = 1; + env.RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository()); + env.ProjectHgRunner.SetStandardOutput(env.RuthBookUsfm, true); + TextData textData = env.GetTextDoc(Canon.BookIdToNumber(book), chapter); + + var associatedPtUser = new SFParatextUser(env.Username01); + string ptProjectId = env.SetupProject(env.Project01, associatedPtUser); + ScrText scrText = env.GetScrText(associatedPtUser, ptProjectId); + + env.MockScrTextCollection.FindById(Arg.Any(), Arg.Any()).Returns(_ => scrText); + env.MockDeltaUsxMapper.ToChapterDeltas(Arg.Any()) + .Returns([new ChapterDelta(chapter, 1, false, textData)]); + + // SUT + var actual = await env.Service.GetSnapshotAsync(userSecret, project.Id, book, chapter, DateTime.UtcNow); + Assert.AreEqual(textData.Ops.First(), actual.Data.Ops.First()); + Assert.AreEqual(textData.Id, actual.Id); + Assert.AreEqual(0, actual.Version); + Assert.AreEqual(false, actual.IsValid); + } + + [Test] + public async Task GetSnapshotAsync_FetchesSnapshotFromRealtimeServer() { var env = new TestEnvironment(); UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); @@ -5467,6 +5663,29 @@ public void GetSnapshotAsync_MissingUser() ); } + [Test] + public void GetSnapshotAsync_NoParatextRevisions() + { + var env = new TestEnvironment(); + UserSecret userSecret = TestEnvironment.MakeUserSecret(env.User01, env.Username01, env.ParatextUserId01); + SFProject project = env.NewSFProject(); + project.UserRoles = new Dictionary { { env.User01, SFProjectRole.PTObserver } }; + env.AddProjectRepository(project); + env.RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository()); + env.ProjectHg.Log.Clear(); + + var associatedPtUser = new SFParatextUser(env.Username01); + string ptProjectId = env.SetupProject(env.Project01, associatedPtUser); + ScrText scrText = env.GetScrText(associatedPtUser, ptProjectId); + + env.MockScrTextCollection.FindById(Arg.Any(), Arg.Any()).Returns(_ => scrText); + + // SUT + Assert.ThrowsAsync( + () => env.Service.GetSnapshotAsync(userSecret, project.Id, "RUT", 1, DateTime.MinValue) + ); + } + [Test] public void GetDeltaFromUsfmAsync_MissingProject() { @@ -6080,15 +6299,22 @@ public TestEnvironment() .SearchForBestProjectUsersData(Arg.Any(), Arg.Any()) .Returns(args => args.ArgAt(1).Permissions); RegistryU.Implementation = new DotNetCoreRegistry(); - ScrTextCollection.Implementation = new SFScrTextCollection(); + ScrTextCollection.Implementation = ProjectScrTextCollection; AddProjectRepository(); AddUserRepository(); // Ensure that the SLDR is initialized for LanguageID.Code to be retrieved correctly if (!Sldr.IsInitialized) Sldr.Initialize(true); + + // Setup Mercurial for tests + Hg.DefaultRunnerCreationFunc = (_, _, _) => ProjectHgRunner; + Hg.Default = ProjectHg; } + public MockHgRunner ProjectHgRunner { get; } = new MockHgRunner(); + public MockHg ProjectHg { get; } = new MockHg(); + public SFScrTextCollection ProjectScrTextCollection { get; } = new SFScrTextCollection(); public MockScrText ProjectScrText { get; set; } public CommentManager ProjectCommentManager { get; set; } public ProjectFileManager ProjectFileManager { get; set; } @@ -6389,6 +6615,53 @@ public SFProject NewSFProject() => }, }; + public void AddTextDataOps(string projectId, string book, int chapter) + { + // Using the last hour will ensure that that ops are combined + // consistently in ParatextService.GetRevisionHistoryAsync() + DateTime thePreviousHour = new DateTime( + DateTime.UtcNow.Year, + DateTime.UtcNow.Month, + DateTime.UtcNow.Day, + DateTime.UtcNow.Hour, + 0, + 0, + DateTimeKind.Utc + ); + Op[] ops = + [ + new Op + { + Metadata = new OpMetadata { Timestamp = thePreviousHour.AddMinutes(-30) }, + Version = 1, + }, + new Op + { + Metadata = new OpMetadata { Timestamp = thePreviousHour.AddMinutes(-10) }, + Version = 2, + }, + new Op + { + // This op should be combined with the next + Metadata = new OpMetadata { Timestamp = thePreviousHour.AddMinutes(-1) }, + Version = 3, + }, + new Op + { + Metadata = new OpMetadata + { + Timestamp = thePreviousHour, + UserId = "user01", + Source = OpSource.Draft, + }, + Version = 4, + }, + ]; + string id = TextData.GetTextDocId(projectId, book, chapter); + RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository()); + RealtimeService.GetRepository().SetOps(id, ops); + } + public void AddProjectRepository(SFProject proj = null) { proj ??= NewSFProject(); @@ -6443,15 +6716,16 @@ public void AddTextDocs( public TextData AddTextDoc(int bookNum, int chapterNum) { - Delta chapterDelta = Delta.New().Insert("In the beginning"); - var textDoc = new TextData(chapterDelta) + TextData textDoc = GetTextDoc(bookNum, chapterNum); + RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository([textDoc])); + return textDoc; + } + + public TextData GetTextDoc(int bookNum, int chapterNum) => + new TextData(Delta.New().Insert("In the beginning")) { Id = TextData.GetTextDocId("sf_id_" + Project01, bookNum, chapterNum), }; - var texts = new TextData[] { textDoc }; - RealtimeService.AddRepository("texts", OTType.RichText, new MemoryRepository(texts)); - return textDoc; - } public void AddNoteThreadData(ThreadComponents[] threadComponents) { @@ -6648,6 +6922,7 @@ public string SetupProject(string baseId, ParatextUser associatedPtUser, bool ha { string ptProjectId = PTProjectIds[baseId].Id; ProjectScrText = GetScrText(associatedPtUser, ptProjectId, hasEditPermission); + ProjectScrTextCollection.AddToInternalIndex(ProjectScrText); // We set the file manager here so we can track file manager operations after // the ScrText object has been disposed in ParatextService. @@ -6801,6 +7076,7 @@ public MockScrText GetScrText(ParatextUser associatedPtUser, string projectId, b scrText.Data.Add("RUT", RuthBookUsfm); scrText.Settings.BooksPresentSet = new BookSet("RUT"); scrText.Settings.LanguageID = LanguageId.English; + scrText.Settings.FileNamePostPart = ".SFM"; if (!hasEditPermission) scrText.Permissions.SetPermission(null, 8, PermissionSet.Manual, false); return scrText;