diff --git a/.github/skills/adding-a-new-class/SKILL.md b/.github/skills/adding-a-new-class/SKILL.md new file mode 100644 index 00000000..324ee832 --- /dev/null +++ b/.github/skills/adding-a-new-class/SKILL.md @@ -0,0 +1,164 @@ +--- +name: adding-a-new-class +description: Add an entirely new class to MasterLCModel.xml, including module placement, class numbering, owner property, version bump, data migration, code regeneration, and tests. Use when the user asks to add a new class, entity, or object type to the LCM data model. +--- + +# Adding a New Class to the Model + +This guide covers adding an entirely new class to `MasterLCModel.xml`. This is less common than adding properties and more involved -- it touches the model, requires a migration, may need an owner property on an existing class, and may need hand-written partial class logic. + +## Prerequisites + +- Read the Critical Rules section in AGENTS.md +- Know the parent (base) class (typically `CmObject` for simple classes) +- Know whether the class requires an owner and which class will own it +- Know the properties the new class needs + +## Steps + +### 1. Determine Class Placement + +Classes live inside `` elements in `MasterLCModel.xml`. The main modules are: + +| Module | id | num | Contains | +|--------|----|-----|----------| +| Cellar | CellarModule | 0 | Core classes (CmObject, CmPossibility, StText, etc.) | +| Scripture | Scripture | 3 | Scripture classes | +| LangProj | LangProj | 6 | LangProject and related | +| Ling | Ling | 5 | Linguistic classes (LexEntry, LexSense, Morph*, Wfi*, etc.) | +| Notebook | Notebook | 24 | Notebook classes | + +Choose the module that best fits your class. Most new classes go in `Ling` (module 5). + +### 2. Determine the Class Number + +Within the module, find the highest existing `num` attribute on `` elements and use the next integer. + +The class ID (used in code as `kClassId`) is formed by combining the module number and the class number. For example, in module `5` (Ling), class number `134` would have class ID `5134`. + +### 3. Add the Class to MasterLCModel.xml + +File: `src/SIL.LCModel/MasterLCModel.xml` + +```xml + + + Description of the new class. No newlines inside para elements. + + + + + + + +``` + +Key attributes: +- `abstract`: Set to `true` if only subclasses should be instantiated +- `base`: Parent class. Use `CmObject` unless inheriting from something more specific +- `depth`: Depth in the inheritance tree from `CmObject` (0 = direct child of CmObject) +- `abbr`: Short abbreviation for the class +- `owner`: `required` (default), `optional`, or `none`. Use `none` for unowned classes like `LexEntry` + +### 4. Add an Owning Property to the Owner Class + +Unless `owner="none"`, you need a property on the owning class that references the new class. Find the owner class in the XML and add an owning property: + +```xml + + + Owns instances of NewClassName. + + +``` + +### 5. Increment the Model Version + +Update the `version` attribute on `` and add a change history entry. + +### 6. Write the Data Migration + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +For a new class that doesn't exist in any data yet, a minimal migration suffices: + +```csharp +using System.Xml.Linq; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + // New class added to model. No existing data to migrate. + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +If the new class needs default instances created (e.g., a new possibility list), create them in the migration using `DataMigrationServices.CreatePossibilityList()` or raw XML construction. See `DataMigration7000069.cs` for examples of creating new lists and objects. + +### 7. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +### 8. Rebuild + +``` +dotnet build --configuration Release +``` + +The code generator will produce: +- A `NewClassNameTags` constants class (class ID, field IDs) +- An `INewClassName` interface +- An `INewClassNameFactory` factory interface and implementation +- An `INewClassNameRepository` repository interface and implementation +- A concrete `NewClassName` class in `DomainImpl/GeneratedClasses.cs` +- StructureMap registrations in `GeneratedServiceLocatorBootstrapper.cs` + +### 9. Add Hand-Written Extensions (if needed) + +If the class needs business logic, create a partial class in `src/SIL.LCModel/DomainImpl/`: + +```csharp +namespace SIL.LCModel.DomainImpl +{ + internal partial class NewClassName + { + // Virtual properties, convenience methods, overrides, etc. + } +} +``` + +Place it in the appropriate `Overrides*.cs` file or create a new one if it doesn't fit existing files. + +### 10. Update BootstrapNewLanguageProject (if needed) + +If the new class needs default instances in every new project, update `src/SIL.LCModel/DomainServices/BootstrapNewLanguageProject.cs` to create them. + +### 11. Write Tests + +Create migration tests (use the `writing-a-data-migration` skill) and API tests using `MemoryOnlyBackendProviderTestBase` (use the `writing-tests` skill). + +## Checklist + +- [ ] Class added to correct `` in `MasterLCModel.xml` +- [ ] Unique `num` within the module +- [ ] `base` class set correctly +- [ ] `owner` attribute set (or left as default `required`) +- [ ] Owning property added to the owner class (unless `owner="none"`) +- [ ] `version` attribute incremented on `` +- [ ] Change history entry added +- [ ] Migration class created and registered in `LcmDataMigrationManager` +- [ ] Build succeeds and code regenerates correctly +- [ ] Hand-written partial class added if business logic needed +- [ ] `BootstrapNewLanguageProject` updated if default instances needed +- [ ] Tests written +- [ ] Generated files NOT manually edited diff --git a/.github/skills/adding-a-property/SKILL.md b/.github/skills/adding-a-property/SKILL.md new file mode 100644 index 00000000..650f7bf5 --- /dev/null +++ b/.github/skills/adding-a-property/SKILL.md @@ -0,0 +1,194 @@ +--- +name: adding-a-property +description: Add a new persisted property to an existing class in MasterLCModel.xml, including model version bump, data migration, code regeneration, and tests. Use when the user asks to add a field, property, or attribute to an LCM model class. +--- + +# Adding a Property to an Existing Class + +This guide covers adding a new persisted property to an existing class in the LCM model. This is one of the most common and most dangerous changes -- it touches the XML model, requires a data migration, and triggers code regeneration. + +If you need a computed/derived property that is NOT persisted, use the `adding-a-virtual-property` skill instead. + +## Prerequisites + +- Read the Critical Rules section in AGENTS.md +- Know the target class name (e.g., `LexSense`) +- Know the property type (`basic`, `owning`, or `rel`) and signature + +## Steps + +### 1. Edit MasterLCModel.xml + +File: `src/SIL.LCModel/MasterLCModel.xml` + +Find the target class and add the property inside its `` element. Choose the next available `num` for that class (check existing properties). + +**Basic property example** (adding a `MultiString` field): +```xml + + + Description of the field. No newlines inside para elements. + + +``` + +**Owning property example** (adding an owning sequence): +```xml + + + Description of owned objects. + + +``` + +**Reference property example** (adding a reference collection): +```xml + + + Description of referenced objects. + + +``` + +Property type signatures for ``: +- `Integer`, `Boolean`, `String`, `Unicode`, `MultiString`, `MultiUnicode` +- `Time`, `GenDate`, `Binary`, `Guid`, `TextPropBinary` + +Cardinality values for `` and ``: +- `atomic` -- zero or one target +- `seq` -- ordered list +- `col` -- unordered collection + +### 2. Increment the Model Version + +In the same file (`MasterLCModel.xml`), update the `version` attribute on the root `` element: + +```xml + +``` + +Add a change history entry just below the existing ones: + +```xml + DD Month YYYY (7000073): Added NewFieldName to ClassName. Brief description. +``` + +### 3. Write the Data Migration + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +**For new optional properties with safe defaults (most common case)**, existing data doesn't need modification. But you still need the migration class: + +```csharp +// Copyright (c) YYYY SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + // New optional property with safe default; no data changes needed. + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +**IMPORTANT**: If you are adding a C# value type property (int, bool, GenDate, DateTime), you MUST add an explicit XML element with the default value to every existing instance. See the WARNING at the top of `MasterLCModel.xml`. Example migration that adds a default value: + +```csharp +public void PerformMigration(IDomainObjectDTORepository repoDto) +{ + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + + foreach (var dto in repoDto.AllInstancesWithSubclasses("TargetClass")) + { + var element = XElement.Parse(dto.Xml); + if (element.Element("NewBoolField") == null) + { + element.Add(new XElement("NewBoolField", new XAttribute("val", "False"))); + DataMigrationServices.UpdateDTO(repoDto, dto, element.ToString()); + } + } + + DataMigrationServices.IncrementVersionNumber(repoDto); +} +``` + +### 4. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +Add a line in the constructor, after the last existing entry: + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +If no data changes are needed, you can use `m_bumpNumberOnlyMigration` instead: +```csharp +m_individualMigrations.Add(7000073, m_bumpNumberOnlyMigration); +``` +In this case you do NOT need to create a `DataMigration7000073.cs` file. + +### 5. Rebuild to Regenerate Code + +``` +dotnet build --configuration Release +``` + +This triggers `GenerateModel` which regenerates all 9 `Generated*.cs` files from the updated `MasterLCModel.xml`. The new property will appear in the generated constants, interfaces, class implementations, etc. + +### 6. Add Hand-Written Logic (if needed) + +If the property needs custom logic beyond what the generator provides (computed side effects, validation, etc.), add it to the appropriate `Overrides*.cs` partial class in `src/SIL.LCModel/DomainImpl/`. + +### 7. Write Tests + +**Migration test**: Create `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigration7000073Tests.cs` + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + // Parse test data XML (create a matching .xml file in the test data directory) + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + // Assert the migration results + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + // Add specific assertions for your migration... + } + } +} +``` + +**API test**: For testing property access via the LCM API, inherit from `MemoryOnlyBackendProviderTestBase`. See the `writing-tests` skill. + +## Checklist + +- [ ] Property added to `MasterLCModel.xml` with correct `num`, `id`, `sig`, and (if relational) `card` +- [ ] `version` attribute incremented on `` +- [ ] Change history comment added +- [ ] Migration class created OR `m_bumpNumberOnlyMigration` used +- [ ] Migration registered in `LcmDataMigrationManager` constructor +- [ ] If adding a C# value type: migration writes explicit defaults to all existing instances +- [ ] Build succeeds (`dotnet build --configuration Release`) +- [ ] Migration test written +- [ ] Generated files NOT manually edited diff --git a/.github/skills/adding-a-virtual-property/SKILL.md b/.github/skills/adding-a-virtual-property/SKILL.md new file mode 100644 index 00000000..c05aed3b --- /dev/null +++ b/.github/skills/adding-a-virtual-property/SKILL.md @@ -0,0 +1,159 @@ +--- +name: adding-a-virtual-property +description: Add a computed or derived virtual property to an LCM class using the VirtualProperty attribute. No model version change, no migration, and no XML editing required. Use when the user asks to add a computed, derived, or virtual property that is not persisted. +--- + +# Adding a Virtual Property + +Virtual properties are computed/derived properties that are NOT persisted in the data store. They are discovered automatically via reflection from the `[VirtualProperty]` attribute. No model version change, no migration, and no XML editing is required. + +Use this when you need a property that: +- Computes a value from other persisted data +- Provides a back-reference (e.g., "all senses that reference this semantic domain") +- Exposes a convenience accessor + +If the property needs to be persisted, use the `adding-a-property` skill instead. + +## Steps + +### 1. Choose the Target File + +Virtual properties are added to partial class definitions in `src/SIL.LCModel/DomainImpl/`. Find the appropriate `Overrides*.cs` file: + +| File | Classes | +|------|---------| +| `OverridesLing_Lex.cs` | LexDb, LexEntry, LexSense, LexEntryRef, LexExampleSentence, etc. | +| `OverridesCellar.cs` | CmObject, CmPossibility, CmSemanticDomain, StText, StPara, etc. | +| `OverridesLing_Wfi.cs` | WfiWordform, WfiAnalysis, WfiGloss, WfiMorphBundle | +| `OverridesLing_MoClasses.cs` | MoForm, MoStemAllomorph, MoAffixAllomorph, MoMorphSynAnalysis, etc. | +| `OverridesLangProj.cs` | LangProject | +| `OverridesLing_Disc.cs` | DsConstChart, ConstChartRow, etc. | +| `OverridesNotebk.cs` | RnGenericRec | + +If the class doesn't have a partial class in any of these files yet, add a new `partial class` block to the appropriate file. + +### 2. Add the Property + +Add a public property with the `[VirtualProperty]` attribute inside the partial class. + +**Required imports:** +```csharp +using SIL.LCModel.Core.Cellar; // CellarPropertyType +using SIL.LCModel.Infrastructure; // VirtualPropertyAttribute +``` + +### 3. Choose the Right Pattern + +**Simple value type** (Integer, Boolean): +```csharp +[VirtualProperty(CellarPropertyType.Boolean)] +public bool IsSpecialCase +{ + get { return /* computed boolean expression */; } +} +``` + +**Reference collection** (back-references or computed lists): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceCollection, "LexSense")] +public IEnumerable RelatedSenses +{ + get + { + // Compute and return the collection + return Services.GetInstance() + .AllInstances() + .Where(s => /* filter condition */); + } +} +``` + +The second parameter to `VirtualProperty` is the **signature** -- the unqualified class name of the target type. Required for all object-type properties (Reference*, Owning*). + +**Reference sequence** (ordered list): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceSequence, "LexEntry")] +public IEnumerable OrderedEntries +{ + get { return /* computed ordered sequence */; } +} +``` + +**MultiUnicode** (computed multi-writing-system string): +```csharp +[VirtualProperty(CellarPropertyType.MultiUnicode)] +public IMultiAccessorBase ComputedTitle +{ + get + { + if (m_titleFlid == 0) + m_titleFlid = Cache.MetaDataCache.GetFieldId("ClassName", "ComputedTitle", false); + return new VirtualStringAccessor(this, m_titleFlid, ComputedTitleForWs); + } +} +private int m_titleFlid; + +private ITsString ComputedTitleForWs(int ws) +{ + // Return a TsString for the given writing system + return TsStringUtils.MakeString("computed value", ws); +} +``` + +**Reference atomic** (single computed reference): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceAtomic, "CmPossibility")] +public ICmPossibility ComputedCategory +{ + get { return /* single object or null */; } +} +``` + +### 4. Property Registration + +No registration is needed. The `LcmMetaDataCache` automatically discovers properties with `[VirtualProperty]` via reflection during initialization. FLIDs are auto-assigned starting at 20,000,000. + +### 5. Accessing Virtual Properties + +**From C# code** -- use the property directly: +```csharp +var senses = semanticDomain.ReferringSenses; +``` + +**From the SilDataAccess layer** (for views/UI integration): +```csharp +int flid = cache.MetaDataCache.GetFieldId("ClassName", "PropertyName", false); +var value = cache.DomainDataByFlid.get_Prop(obj.Hvo, flid); +``` + +### 6. Optional: Expose on the Interface + +If the virtual property should be accessible via the public interface (e.g., `ILexEntry`), add it to the hand-written partial interface. The generated interfaces are partial, so you can extend them: + +```csharp +// In a file like src/SIL.LCModel/ILexEntryExtensions.cs or similar +namespace SIL.LCModel +{ + public partial interface ILexEntry + { + IEnumerable ComputedProperty { get; } + } +} +``` + +Check existing patterns -- many interfaces already have partial extensions. + +### 7. Write Tests + +Test virtual properties using `MemoryOnlyBackendProviderTestBase`. See the `writing-tests` skill. + +## Checklist + +- [ ] Property added to the correct partial class in `DomainImpl/Overrides*.cs` +- [ ] `[VirtualProperty]` attribute applied with correct `CellarPropertyType` +- [ ] Signature parameter provided for object-type properties +- [ ] Property is public and read-only (getter only) +- [ ] Interface extended if the property needs to be part of the public API +- [ ] No changes to `MasterLCModel.xml` (virtual properties are not persisted) +- [ ] No data migration needed +- [ ] Tests written diff --git a/.github/skills/writing-a-data-migration/SKILL.md b/.github/skills/writing-a-data-migration/SKILL.md new file mode 100644 index 00000000..afe096a0 --- /dev/null +++ b/.github/skills/writing-a-data-migration/SKILL.md @@ -0,0 +1,202 @@ +--- +name: writing-a-data-migration +description: Write a data migration class that transforms existing persisted XML data when the LCM model version changes. Covers migration structure, common operations (find, modify, create, remove objects), registration, version bumping, and tests. Use when the user asks to write a data migration, bump the model version, or transform existing persisted data. +--- + +# Writing a Data Migration + +Data migrations transform existing persisted data when the model version changes. They operate on raw XML via `DomainObjectDTO` objects — live `CmObject` instances are NOT available during migration. + +**Where things live:** Migration classes go in `src/SIL.LCModel/DomainServices/DataMigration/`. Each implements `IDataMigration` with a single `PerformMigration(IDomainObjectDTORepository)` method, uses `XElement.Parse()` for XML manipulation, and must call `DataMigrationServices.CheckVersionNumber` first and `DataMigrationServices.IncrementVersionNumber` last. Migrations are registered in `LcmDataMigrationManager`'s constructor (dictionary of version number to migration instance). The repository tracks changes in three sets: **newbies** (created), **dirtballs** (modified), **goners** (deleted). + +## When a Migration is Needed + +- Adding a C# value-type property (int, bool, GenDate, DateTime) that needs explicit defaults +- Removing a property or class (must clean up existing XML) +- Renaming a property or class +- Changing a property's type or cardinality +- Restructuring ownership or references +- Any change that requires existing persisted data to be transformed + +If the model change is purely additive (new optional reference/string property with no data to transform), you can use `m_bumpNumberOnlyMigration` in the manager instead. See step 4 in the `adding-a-property` skill. + +## Steps + +### 1. Determine the Next Version Number + +Check `src/SIL.LCModel/MasterLCModel.xml` for the current version in ``. Your migration file number is the next integer. + +### 2. Create the Migration Class + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +```csharp +// Copyright (c) YYYY 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.Xml.Linq; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + /// + /// Brief description of what this migration does. + /// + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + + // --- Your migration logic here --- + + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +**Mandatory structure:** +1. First line: `DataMigrationServices.CheckVersionNumber(repoDto, previousVersion)` +2. Middle: your data transformation +3. Last line: `DataMigrationServices.IncrementVersionNumber(repoDto)` + +### 3. Common Migration Operations + +**Finding objects:** +```csharp +// All instances of a class (including subclasses) +var entries = repoDto.AllInstancesWithSubclasses("LexEntry"); + +// All instances of exact class (no subclasses) +var entries = repoDto.AllInstancesSansSubclasses("LexEntry"); + +// Single object by GUID +var dto = repoDto.GetDTO("guid-string-here"); + +// Owner of an object +var ownerDto = repoDto.GetOwningDTO(dto); + +// Directly owned children +var children = repoDto.GetDirectlyOwnedDTOs(dto.Guid); +``` + +**Modifying XML:** +```csharp +var element = XElement.Parse(dto.Xml); + +// Add an element +element.Add(new XElement("NewProperty", new XAttribute("val", "False"))); + +// Remove an element +element.Element("OldProperty")?.Remove(); + +// Add an objsur (owning reference) +var container = new XElement("OwnedThings"); +container.Add(new XElement("objsur", + new XAttribute("guid", targetGuid), + new XAttribute("t", "o"))); // "o" for owning, "r" for reference +element.Add(container); + +// Save changes +DataMigrationServices.UpdateDTO(repoDto, dto, element.ToString()); +``` + +**Creating new objects:** +```csharp +var newGuid = Guid.NewGuid().ToString().ToLowerInvariant(); +var sb = new StringBuilder(); +sb.AppendFormat("", newGuid, ownerGuid); +sb.Append(""); +sb.Append("value"); +sb.Append(""); +sb.Append(""); +repoDto.Add(new DomainObjectDTO(newGuid, "ClassName", sb.ToString())); +``` + +**Removing objects:** +```csharp +// Remove object, its owned children, and clean up owner's objsur +DataMigrationServices.RemoveIncludingOwnedObjects(repoDto, dto, removeFromOwner: true); +``` + +**Creating possibility lists** (use the helper): +```csharp +DataMigrationServices.CreatePossibilityList(repoDto, listGuid, ownerGuid, + new[] { Tuple.Create("en", "Abbr", "List Name") }, + DateTime.Now, WritingSystemServices.kwsAnals); +``` + +### 4. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +Add at the end of the constructor's registration block: + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +### 5. Update MasterLCModel.xml Version + +File: `src/SIL.LCModel/MasterLCModel.xml` + +Update the version attribute and add a change history entry: +```xml + +``` + +### 6. Write Tests + +Create: `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigration7000073Tests.cs` + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + // Add assertions verifying data was transformed correctly + } + } +} +``` + +**Test data file**: Create a minimal `.xml` file with sample `` elements in the test data directory used by `DataMigrationTestServices.ParseProjectFile()`. Look at existing test data files like `DataMigration7000072.xml` for the expected format. + +## Key Rules + +- Migrations operate on raw XML strings via `DomainObjectDTO`. You cannot use `ICmObject` or any live LCM API. +- Use `XElement.Parse()` / `.ToString()` for XML manipulation. Do not use string replacement on XML. +- Always call `CheckVersionNumber` first and `IncrementVersionNumber` last. +- Handle null checks: optional elements may not exist in all objects. +- The repository tracks changes automatically. `UpdateDTO` marks as modified. `Add` marks as new. `Remove` marks as deleted. +- Use `AllInstancesWithSubclasses` when the property could be on subclasses too. +- GUIDs in the data are lowercase. Use `.ToLowerInvariant()` when comparing. +- For large migrations, organize logic into private helper methods (see `DataMigration7000072.cs` for this pattern). + +## Checklist + +- [ ] Migration class created with correct version number +- [ ] `CheckVersionNumber` called with previous version (N-1) +- [ ] `IncrementVersionNumber` called at the end +- [ ] Migration registered in `LcmDataMigrationManager` constructor +- [ ] `MasterLCModel.xml` version attribute updated +- [ ] Change history entry added to `MasterLCModel.xml` +- [ ] Test class created extending `DataMigrationTestsBase` +- [ ] Test data XML file created +- [ ] Build succeeds diff --git a/.github/skills/writing-tests/SKILL.md b/.github/skills/writing-tests/SKILL.md new file mode 100644 index 00000000..b26e134a --- /dev/null +++ b/.github/skills/writing-tests/SKILL.md @@ -0,0 +1,190 @@ +--- +name: writing-tests +description: Write NUnit tests for LCM model classes, domain services, data migrations, or API behavior. Covers test base classes, UnitOfWork patterns, object creation, string properties, repositories, and custom fields. Use when the user asks to write tests for LCM model classes, domain services, data migrations, or API behavior. +--- + +# Writing Tests + +Tests in liblcm use NUnit. The test infrastructure provides base classes that set up an `LcmCache` with the appropriate backend provider. + +## Test Project Structure + +Tests live in `tests/`: +- `SIL.LCModel.Tests/` -- Main library tests (model, domain services, infrastructure) +- `SIL.LCModel.Core.Tests/` -- Core utility tests +- `SIL.LCModel.Utils.Tests/` -- Utility tests +- `SIL.LCModel.FixData.Tests/` -- FixData tests + +## Base Classes + +### MemoryOnlyBackendProviderTestBase + +**Use for**: Testing the LCM public API (properties, factories, repositories, domain services). + +Located in `tests/SIL.LCModel.Tests/LcmTestBase.cs`. + +This creates a fresh in-memory `LcmCache` with a blank language project per test fixture. No file I/O. This is the most common base class. + +```csharp +using NUnit.Framework; +using SIL.LCModel.Infrastructure; + +namespace SIL.LCModel.SomeArea +{ + [TestFixture] + public class MyFeatureTests : MemoryOnlyBackendProviderTestBase + { + [Test] + public void MyTest() + { + // Cache is available via the Cache property + var lp = Cache.LanguageProject; + + // All data changes must be in a UnitOfWork + UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, () => + { + // Create objects via factories + var entry = Cache.ServiceLocator.GetInstance().Create(); + + // Set properties + var ws = Cache.DefaultVernWs; + entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("test", ws); + + // Assert + Assert.IsNotNull(entry); + }); + } + } +} +``` + +Key points: +- `Cache` property gives you the `LcmCache` +- `m_actionHandler` is the `IActionHandler` for UnitOfWork operations +- Default writing systems: `Cache.DefaultAnalWs` (English), `Cache.DefaultVernWs` (French) +- Use `UndoableUnitOfWorkHelper.Do()` or `NonUndoableUnitOfWorkHelper.Do()` for data changes + +### MemoryOnlyBackendProviderRestoredForEachTestTestBase + +**Use for**: Tests that need a clean state for each test method (not just each fixture). + +Same as above but disposes and recreates the cache before each `[Test]`. + +### DataMigrationTestsBase + +**Use for**: Testing data migrations. + +Located in `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigrationTests.cs`. + +Provides `m_dataMigrationManager` (an `IDataMigrationManager` instance). + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + // 1. Parse test data + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + + // 2. Create repository at the PREVIOUS version + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + // 3. Run the migration + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + // 4. Verify version + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + + // 5. Verify data transformations + var dto = dtoRepos.GetDTO("some-guid-from-test-data"); + var element = XElement.Parse(dto.Xml); + Assert.IsNotNull(element.Element("ExpectedNewElement")); + } + } +} +``` + +**Test data files**: Migration tests use XML files containing sample `` elements. These live in the test data directory and are parsed by `DataMigrationTestServices.ParseProjectFile()`. Look at existing files like `DataMigration7000072.xml` for the format. The file should contain a minimal set of `` elements that exercise the migration logic. + +## Common Test Patterns + +### Creating Test Objects + +```csharp +UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, () => +{ + // Factories are accessed via ServiceLocator + var entryFactory = Cache.ServiceLocator.GetInstance(); + var senseFactory = Cache.ServiceLocator.GetInstance(); + + var entry = entryFactory.Create(); + var sense = senseFactory.Create(); + entry.SensesOS.Add(sense); +}); +``` + +### Setting String Properties + +```csharp +int vernWs = Cache.DefaultVernWs; +int analWs = Cache.DefaultAnalWs; + +// MultiUnicode +entry.CitationForm.set_String(vernWs, TsStringUtils.MakeString("word", vernWs)); + +// MultiString +sense.Definition.set_String(analWs, TsStringUtils.MakeString("a definition", analWs)); +``` + +### Accessing Repositories + +```csharp +var entryRepo = Cache.ServiceLocator.GetInstance(); +var allEntries = entryRepo.AllInstances(); +var count = entryRepo.Count; +``` + +### Testing with Custom Fields + +```csharp +using (var customField = new CustomFieldForTest( + Cache, "My Field", "MyField", + LexEntryTags.kClassId, + CellarPropertyType.MultiUnicode, + Guid.Empty)) +{ + // customField.Flid gives you the field ID + // Test using the custom field... +} +// Custom field is automatically removed on Dispose +``` + +## Running Tests + +``` +dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release +``` + +Or run individual test classes/methods from your IDE. + +Tests must NOT run in parallel (`ParallelizeAssembly=false`) due to shared state in the ICU and writing system subsystems. + +## Checklist + +- [ ] Test class inherits from the appropriate base class +- [ ] `[TestFixture]` attribute on the class +- [ ] `[Test]` attribute on test methods +- [ ] All data changes wrapped in `UndoableUnitOfWorkHelper.Do()` or `NonUndoableUnitOfWorkHelper.Do()` +- [ ] Test assertions verify the expected behavior +- [ ] For migration tests: test data XML file created, repository initialized at previous version +- [ ] Tests pass: `dotnet test --configuration Release` diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e63d507..cfa74b01 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "dotnet.unitTestDebuggingOptions": { "type": "clr" - } + }, + "chat.agentSkillsLocations": [ + ".github/skills" + ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b32e91b8..5a38d9d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,127 +1,165 @@ # AGENTS: liblcm (LCM) ## Summary -liblcm (LCM) is the core FieldWorks Language & Culture Model library for linguistic analyses. It provides the data model, serialization, utilities, and tooling for linguistic, anthropological, and text corpus data. It is a multi-project .NET solution with code generation steps and multi-targeting for legacy .NET Framework and modern .NET. - -## High-level repo facts -- Type: .NET solution (multi-project class libraries + build tasks + tools + tests). -- Languages: C# (.cs), MSBuild (.proj/.csproj/.props/.targets), XML, shell/batch scripts. -- Target frameworks: net462, netstandard2.0, net8.0 (see .csproj files in src/ and tests/). -- Build tools: MSBuild, dotnet SDK, GitVersion.MsBuild, NUnit. -- Output: artifacts/ (NuGet packages and binaries by configuration/TFM). - -## Build and validation (validated commands and observations) - -### What CI runs (GitHub Actions) -CI runs on Windows and Ubuntu. See .github/workflows/ci-cd.yml: -1) Install .NET SDK 8.x. -2) Ubuntu: install mono-devel and icu-fw packages. -3) Windows: remove c:\tools\php\icuuc*.dll; install .NET Framework 4.6.1 targeting pack. -4) Build: dotnet build --configuration Release -5) Test: - - Linux: . environ && dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release - - Windows: dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release -6) Pack: dotnet pack --include-symbols --no-restore --no-build -p:SymbolPackageFormat=snupkg --configuration Release - -Always mirror this sequence when validating a change locally. - -### Local build scripts (not validated here) -- Windows: build.cmd [Debug|Release] [Target] (uses MSBuild on LCM.sln). -- Linux: build.sh [Debug|Release] [Target] (sources environ, uses msbuild on LCM.sln). -These scripts call build/LCM.proj targets (Build/Test/Pack). If you use them, always run from repo root. - -### Tests per README (not validated here) -- Windows, ReSharper: open LCM.sln and “Run Unit Tests”. -- Windows, no ReSharper: use MSBuild, then run nunit3-console.exe from artifacts/Debug/net462. -- Linux terminal: source environ, then run mono with nunit3-console.exe on *Tests.dll in artifacts/Debug/net462. - -### Commands actually run during onboarding -- dotnet test .\LCM.sln → FAILED -- dotnet build --configuration Release → FAILED -Failure signature (both commands): GitVersion.MsBuild (netcoreapp3.1 gitversion.dll) exited with code 1. This blocks build/test in this environment. CI uses fetch-depth 0, so ensure a full git history is available. If GitVersion still fails, check GitVersion prerequisites and local .NET runtime compatibility. - -No command timeouts were observed. - -### Known prerequisites and gotchas -- GitVersion.MsBuild is used across projects; it requires git metadata. CI checks out with fetch-depth 0. -- net462 builds on Windows require the .NET Framework 4.6.1 targeting pack (CI installs it). -- ICU data generation requires ICU binaries (CI installs icu-fw on Ubuntu). -- Some projects warn on NU1701; treat as warnings unless build breaks. -- The build prohibits references to System.Windows.Forms (CheckWinForms target). - -## Project layout and architecture - -### Key solution and build files -- LCM.sln: solution entry point. -- build.cmd / build.sh: wrapper scripts for MSBuild. -- build/LCM.proj: orchestrated build/test/pack, uses NUnit console on output/ for legacy builds. -- Directory.Build.props / Directory.Build.targets: repo-wide build settings and packaging. -- Directory.Solution.props / Directory.Solution.targets: solution-level defaults. -- GitVersion.yml: GitVersion configuration. -- global.json: SDK roll-forward config. -- .editorconfig: formatting rules. - -### Major source projects (src/) -- src/SIL.LCModel: main LCM library (net462; netstandard2.0). -- src/SIL.LCModel.Core: core utilities and ICU data generation (netstandard2.0; net462; net8.0). -- src/SIL.LCModel.Utils: shared utilities (net462; netstandard2.0). -- src/SIL.LCModel.Build.Tasks: MSBuild tasks used for code generation. -- src/SIL.LCModel.FixData: data-fix utilities. -- src/CSTools: auxiliary tools (pg/lg/Tools). - -Code generation targets to know about: -- SIL.LCModel: GenerateModel (MasterLCModel.xml → Generated*.cs). -- SIL.LCModel.Core: GenerateKernelCs, GenerateIcuData. - -### Tests (tests/) -- SIL.LCModel.Tests -- SIL.LCModel.Core.Tests -- SIL.LCModel.Utils.Tests -- SIL.LCModel.FixData.Tests -- TestHelper (support project) - -### CI/validation checks -- GitHub Actions: .github/workflows/ci-cd.yml (build, test, pack, publish). -- Tests run with dotnet test and ParallelizeAssembly=false. -- Packaging uses dotnet pack with symbol packages. - -### Dependencies not obvious from layout -- ICU data and binaries (icu-fw) for Core ICU generation. -- Mono on Linux for some runtime/test workflows. -- GitVersion.MsBuild for versioning (requires git metadata). - -## Root files list -- .editorconfig -- .gitattributes -- .gitignore -- build.cmd -- build.sh -- CHANGELOG.md -- Directory.Build.props -- Directory.Build.targets -- Directory.Solution.props -- Directory.Solution.targets -- environ -- GitVersion.yml -- global.json -- LCM.sln -- LCM.sln.DotSettings -- LICENSE -- README.md - -## Repo top-level directories -- .github/ (GitHub Actions workflow) -- .vscode/ (VS settings) -- artifacts/ (build outputs) -- build/ (LCM.proj) -- src/ (production code) -- tests/ (unit tests) - -## README highlights (summary) -- Describes liblcm as FieldWorks model library for linguistic analyses. -- Build: use build.cmd (Windows) or build.sh (Linux). Default Debug, optional Release. -- Debugging: use LOCAL_NUGET_REPO to publish local packages; see NuGet local feeds. -- Tests: Windows via ReSharper or NUnit console; Linux via mono + NUnit console (requires environ). - -## Trust these instructions + +liblcm is the core FieldWorks Language & Culture Model library. It provides the object-oriented data model, serialization, persistence, and domain services for linguistic, anthropological, and text corpus data used by [FieldWorks](https://github.com/sillsdev/FieldWorks). + +The codebase is heavily code-generated from `MasterLCModel.xml`. Understanding the generation pipeline and the rules below is essential before making changes. + +## Critical Rules + +**Violating any of these will break the build or corrupt data.** + +1. **NEVER edit `Generated*.cs` files.** These 9 files are produced by the code generation pipeline from `MasterLCModel.xml` via NVelocity templates. Edit the XML model or `.vm.cs` templates instead. The generated files are: + - `GeneratedConstants.cs`, `GeneratedInterfaces.cs`, `GeneratedFactoryInterfaces.cs`, `GeneratedRepositoryInterfaces.cs` + - `DomainImpl/GeneratedClasses.cs`, `DomainImpl/GeneratedFactoryImplementations.cs` + - `Infrastructure/Impl/GeneratedRepositoryImplementations.cs`, `Infrastructure/Impl/GeneratedBackendProvider.cs` + - `IOC/GeneratedServiceLocatorBootstrapper.cs` + +2. **Model changes require a version bump and migration.** Almost every change to `MasterLCModel.xml` requires incrementing the `version` attribute and writing a data migration class. The ONLY exceptions are: editing ``/`` elements, editing XML comments, or adding attributes that only affect the code generator. Read the warnings at the top of `MasterLCModel.xml` carefully. + +3. **All data changes must occur within a UnitOfWork.** Use `UndoableUnitOfWorkHelper` for user actions or `NonUndoableUnitOfWorkHelper` for system operations. Changes outside a UOW will throw or silently fail. + +4. **No references to `System.Windows.Forms`.** The build enforces this via the `CheckWinForms` target. + +5. **Model version bumps require a matching migration registration.** New migrations must be registered in `LcmDataMigrationManager`'s constructor dictionary. Even no-op version bumps need a `DoNothingDataMigration` entry. + +## Build and Validation + +### Prerequisites +- .NET SDK 8.x +- Windows: .NET Framework 4.6.1 targeting pack +- Linux: mono-devel, icu-fw packages +- Full git history (GitVersion.MsBuild requires `git fetch --unshallow` or `fetch-depth: 0`) + +### CI Commands (GitHub Actions, `.github/workflows/ci-cd.yml`) +``` +dotnet build --configuration Release +dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release +dotnet pack --include-symbols --no-restore --no-build -p:SymbolPackageFormat=snupkg --configuration Release +``` +On Linux, prefix test/build with `. environ &&`. + +### Local Build +From repo root: `dotnet build [--configuration Debug|Release]`. To run tests: `dotnet test --no-restore --no-build -p:ParallelizeAssembly=false [--configuration Release]`. On Linux, prefix with `. environ &&` if using environ for mono/ICU. + +### Known Issues +- GitVersion.MsBuild requires full git metadata. Shallow clones will fail. +- `NU1701` warnings are expected; treat as warnings unless the build breaks. + +## Architecture Overview + +### Code Generation + +`MasterLCModel.xml` is the single source of truth. The `GenerateModel` MSBuild target runs `LcmGenerate` from `SIL.LCModel.Build.Tasks`, which parses the XML and uses NVelocity templates in `LcmGenerate/*.vm.cs` to produce the 9 generated C# files. Edit the XML or templates only — never the generated files. + +### MasterLCModel.xml Schema + +The model is organized into `CellarModule` elements containing `class` elements. Each class has: +- `id`: Class name (e.g., `LexEntry`) +- `num`: Class number within its module (combined with module number to form the class ID) +- `base`: Parent class (inheritance). All classes inherit from `CmObject` +- `abstract`: Whether the class can be instantiated +- `owner`: `required` (default), `optional`, or `none` +- `singleton`: Whether only one instance exists (e.g., `LangProject`) + +Properties come in three types: +- ``: Value types. `sig` is the type: `Integer`, `Boolean`, `String`, `Unicode`, `MultiString`, `MultiUnicode`, `Time`, `GenDate`, `Binary`, `Guid`, `TextPropBinary` +- ``: Ownership references. `card` is `atomic`, `seq`, or `col`. `sig` is the target class +- ``: Non-owning references. Same attributes as `` + +Field IDs (flids) are formed as: module-number + class-number + field-number (e.g., `5016005` = Ling module `5` + LexSense class `016` + Definition field `005`). + +Key string type distinction: `Unicode`/`MultiUnicode` are plain character sequences with no formatting. `String`/`MultiString` support embedded runs with writing systems, styles, and other attributes. + +### Partial Class Pattern + +Generated classes are `partial`. Hand-written code extends them in `DomainImpl/Overrides*.cs` files: +- `OverridesLing_Lex.cs` -- Lexical domain (LexDb, LexEntry, LexSense, etc.) +- `OverridesCellar.cs` -- Core classes (CmObject, CmPossibility, StText, etc.) +- `OverridesLing_Wfi.cs` -- Wordform analysis +- `OverridesLing_MoClasses.cs` -- Morphological classes +- `OverridesLangProj.cs` -- Language project +- `OverridesLing_Disc.cs` -- Discourse charting +- `OverridesNotebk.cs` -- Notebook + +These files add virtual properties (`[VirtualProperty]` attribute), convenience methods, business logic, and side-effect handlers. Virtual properties are discovered automatically via reflection -- no XML or registration needed. + +### Persistence and Infrastructure + +**LcmCache** (`LcmCache.cs`) is the entry point for all data access. Despite its name, it is a service locator facade, not a cache. Key accessors: `ServiceLocator`, `LanguageProject`, `DomainDataByFlid`, `ActionHandlerAccessor`. + +**Backend Providers** (all in `Infrastructure/Impl/`): +- `XMLBackendProvider` -- File-based XML storage (the `.fwdata` format) +- `MemoryOnlyBackendProvider` -- In-memory only, used in tests +- `SharedXMLBackendProvider` -- Multi-process shared access via memory-mapped files + +**Surrogate/IdentityMap Pattern**: Objects are loaded lazily. The backend reads XML into `CmObjectSurrogate` placeholders. On first access to `.Object`, the surrogate parses XML and creates the real `CmObject`. The `IdentityMap` ensures one instance per Guid/Hvo. Bulk loading by domain (Lexicon, Scripture, Text, WFI) is available via `BackendProvider.LoadDomain()`. + +**IOC**: StructureMap via `LcmServiceLocatorFactory`. Factories, repositories, and infrastructure services are registered as singletons. Generated code handles factory/repository registration in `GeneratedServiceLocatorBootstrapper.cs`. + +### Data Migration + +Model version bumps require a migration class in `DomainServices/DataMigration/` and registration in `LcmDataMigrationManager`. See the `writing-a-data-migration` skill for structure, repository behavior, and current-version details. + +### Key Domain Classes + +Ownership hierarchy (simplified): +``` +LangProject (singleton, owner=none) + +-- LexDb (atomic) + | +-- [Entries accessed via virtual property, LexEntry has owner=none] + | +-- LexSense (seq) + | | +-- LexExampleSentence (seq) + | +-- MoForm / MoStemAllomorph / MoAffixAllomorph (atomic: LexemeForm, seq: AlternateForms) + | +-- MoMorphSynAnalysis (col: MorphoSyntaxAnalyses) + +-- PartsOfSpeech (CmPossibilityList, atomic) + +-- SemanticDomainList (CmPossibilityList, atomic) + +-- ResearchNotebook (RnResearchNbk, atomic) + +-- TranslatedScripture (Scripture, atomic) + +-- Styles (StStyle, col) +``` + +`CmPossibility` / `CmPossibilityList` are the list/list-item pattern used extensively for categories, types, domains, and other enumerated values. + +Writing systems: Projects have vernacular (the language being studied) and analysis (languages used for descriptions, typically English/French/Spanish) writing systems. `MultiUnicode` and `MultiString` properties store alternatives keyed by writing system. + +## Project Layout + +``` +src/ + SIL.LCModel/ Main library (net462; netstandard2.0) + MasterLCModel.xml Model source of truth + MasterLCModel.xsd XML schema for the model + LcmGenerate/ NVelocity templates + HandGenerated.xml + DomainImpl/ Generated + hand-written class implementations + DomainServices/ Business logic and domain services + DataMigration/ Migration classes (DataMigration7000001..7000072) + Infrastructure/Impl/ Backend providers, UnitOfWork, IdentityMap + IOC/ StructureMap DI setup + SIL.LCModel.Core/ Core utilities, Cellar types, ICU, writing systems (netstandard2.0; net462; net8.0) + SIL.LCModel.Utils/ Shared utilities (net462; netstandard2.0) + SIL.LCModel.Build.Tasks/ MSBuild tasks for code generation + SIL.LCModel.FixData/ Data-fix utilities + CSTools/ Auxiliary tools (pg/lg) +tests/ + SIL.LCModel.Tests/ Main library tests + SIL.LCModel.Core.Tests/ Core tests + SIL.LCModel.Utils.Tests/ Utility tests + SIL.LCModel.FixData.Tests/ FixData tests + TestHelper/ Test support project +``` + +## Common Tasks + +The following tasks have detailed Agent Skills (in `.github/skills/`) that are auto-discovered by compatible agents (Copilot, Cursor, Claude, Codex). If your agent supports skills, it will load the relevant guide automatically. Otherwise, read the SKILL.md in the corresponding directory. + +- **Adding a property to an existing class** -- skill: `adding-a-property` +- **Writing a data migration** -- skill: `writing-a-data-migration` +- **Adding a new class to the model** -- skill: `adding-a-new-class` +- **Adding a virtual property** (computed, no model change) -- skill: `adding-a-virtual-property` +- **Writing tests** -- skill: `writing-tests` + +## Trust These Instructions + Follow this file first. Only search the repo if these instructions are incomplete or prove incorrect for your task. diff --git a/README.md b/README.md index 6419b977..33f040fa 100644 --- a/README.md +++ b/README.md @@ -23,22 +23,9 @@ with language and culture data, including anthropological, text corpus, and ling 3. Build liblcm - cd into the directory of the cloned liblcm repository. + - Run `dotnet build` to build the liblcm library (or open `LCM.sln` in Visual Studio / Rider and build there). - On Windows: - - - Run the appropriate `vsvars*.bat`. Alternatively, `LCM.sln` can be built from within Visual Studio. - - Run `build.cmd` to build the liblcm library. - - On Linux: - - - Run `build.sh` to build the liblcm library. - -By default, this will build liblcm in the Debug configuration. -To build with a different configuration, use: - -```bash -build.(cmd|sh) (Debug|Release) -``` + By default this uses the Debug configuration. For Release: `dotnet build --configuration Release`. ## Debugging @@ -51,7 +38,7 @@ To publish and consume LCModel through local sources: local network) to publish locally-built packages - See [these instructions](https://docs.microsoft.com/en-us/nuget/hosting-packages/local-feeds) to enable local package sources -- `build /t:pack` will pack nuget packages and publish them to `LOCAL_NUGET_REPO` +- `dotnet pack` will produce NuGet packages; configure your local feed so they are published to `LOCAL_NUGET_REPO` (e.g. `dotnet nuget push artifacts/*.nupkg -s %LOCAL_NUGET_REPO%`) ## Tests diff --git a/build.cmd b/build.cmd deleted file mode 100644 index 73e1c825..00000000 --- a/build.cmd +++ /dev/null @@ -1,20 +0,0 @@ - @ECHO OFF - -if "%1"=="" ( - SET CONFIG=Debug -) else ( - SET CONFIG=%1 -) - -if "%2"=="" ( - SET TARGET=Build -) else ( - SET TARGET=%2 -) - -if not "%3"=="" ( - echo Usage: "build [(Debug|Release) []]" - exit /b 1 -) - -msbuild /t:%TARGET% /p:Configuration=%CONFIG% LCM.sln \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100755 index 5a71c493..00000000 --- a/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -cd "$(dirname "$0")" -if [ -z "$1" ] ; then - CONFIG=Debug -else - CONFIG=$1 -fi - -if [ -z "$2" ] ; then - TARGET=Build -else - TARGET=$2 -fi - -if [ -n "$3" ] ; then - echo Usage: "build [(Debug|Release) []]" - exit 1 -fi - -. environ -msbuild /t:$TARGET /p:Configuration=$CONFIG LCM.sln \ No newline at end of file diff --git a/build/LCM.proj b/build/LCM.proj deleted file mode 100644 index 40a27688..00000000 --- a/build/LCM.proj +++ /dev/null @@ -1,109 +0,0 @@ - - - $(MSBuildProjectDirectory)/.. - $(teamcity_build_checkoutDir) - LCM.sln - $(RootDir)/$(Solution) - Release - KnownMonoIssue, - SkipOnTeamCity,$(ExtraExcludeCategories) - true - false - Any CPU - true - false - true - false - true - false - $(RootDir)/output/$(Configuration)/TestResults.xml - $(ContinuousIntegrationBuild) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/agents/adding-a-new-class.md b/docs/agents/adding-a-new-class.md new file mode 100644 index 00000000..1e9c32ce --- /dev/null +++ b/docs/agents/adding-a-new-class.md @@ -0,0 +1,159 @@ +# Task: Adding a New Class to the Model + +This guide covers adding an entirely new class to `MasterLCModel.xml`. This is less common than adding properties and more involved -- it touches the model, requires a migration, may need an owner property on an existing class, and may need hand-written partial class logic. + +## Prerequisites + +- Read the Critical Rules section in AGENTS.md +- Know the parent (base) class (typically `CmObject` for simple classes) +- Know whether the class requires an owner and which class will own it +- Know the properties the new class needs + +## Steps + +### 1. Determine Class Placement + +Classes live inside `` elements in `MasterLCModel.xml`. The main modules are: + +| Module | id | num | Contains | +|--------|----|-----|----------| +| Cellar | CellarModule | 0 | Core classes (CmObject, CmPossibility, StText, etc.) | +| Scripture | Scripture | 3 | Scripture classes | +| LangProj | LangProj | 6 | LangProject and related | +| Ling | Ling | 5 | Linguistic classes (LexEntry, LexSense, Morph*, Wfi*, etc.) | +| Notebook | Notebook | 24 | Notebook classes | + +Choose the module that best fits your class. Most new classes go in `Ling` (module 5). + +### 2. Determine the Class Number + +Within the module, find the highest existing `num` attribute on `` elements and use the next integer. + +The class ID (used in code as `kClassId`) is formed by combining the module number and the class number. For example, in module `5` (Ling), class number `134` would have class ID `5134`. + +### 3. Add the Class to MasterLCModel.xml + +File: `src/SIL.LCModel/MasterLCModel.xml` + +```xml + + + Description of the new class. No newlines inside para elements. + + + + + + + +``` + +Key attributes: +- `abstract`: Set to `true` if only subclasses should be instantiated +- `base`: Parent class. Use `CmObject` unless inheriting from something more specific +- `depth`: Depth in the inheritance tree from `CmObject` (0 = direct child of CmObject) +- `abbr`: Short abbreviation for the class +- `owner`: `required` (default), `optional`, or `none`. Use `none` for unowned classes like `LexEntry` + +### 4. Add an Owning Property to the Owner Class + +Unless `owner="none"`, you need a property on the owning class that references the new class. Find the owner class in the XML and add an owning property: + +```xml + + + Owns instances of NewClassName. + + +``` + +### 5. Increment the Model Version + +Update the `version` attribute on `` and add a change history entry. + +### 6. Write the Data Migration + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +For a new class that doesn't exist in any data yet, a minimal migration suffices: + +```csharp +using System.Xml.Linq; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + // New class added to model. No existing data to migrate. + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +If the new class needs default instances created (e.g., a new possibility list), create them in the migration using `DataMigrationServices.CreatePossibilityList()` or raw XML construction. See `DataMigration7000069.cs` for examples of creating new lists and objects. + +### 7. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +### 8. Rebuild + +``` +dotnet build --configuration Release +``` + +The code generator will produce: +- A `NewClassNameTags` constants class (class ID, field IDs) +- An `INewClassName` interface +- An `INewClassNameFactory` factory interface and implementation +- An `INewClassNameRepository` repository interface and implementation +- A concrete `NewClassName` class in `DomainImpl/GeneratedClasses.cs` +- StructureMap registrations in `GeneratedServiceLocatorBootstrapper.cs` + +### 9. Add Hand-Written Extensions (if needed) + +If the class needs business logic, create a partial class in `src/SIL.LCModel/DomainImpl/`: + +```csharp +namespace SIL.LCModel.DomainImpl +{ + internal partial class NewClassName + { + // Virtual properties, convenience methods, overrides, etc. + } +} +``` + +Place it in the appropriate `Overrides*.cs` file or create a new one if it doesn't fit existing files. + +### 10. Update BootstrapNewLanguageProject (if needed) + +If the new class needs default instances in every new project, update `src/SIL.LCModel/DomainServices/BootstrapNewLanguageProject.cs` to create them. + +### 11. Write Tests + +Create migration tests (see [writing-a-data-migration.md](writing-a-data-migration.md)) and API tests using `MemoryOnlyBackendProviderTestBase` (see [writing-tests.md](writing-tests.md)). + +## Checklist + +- [ ] Class added to correct `` in `MasterLCModel.xml` +- [ ] Unique `num` within the module +- [ ] `base` class set correctly +- [ ] `owner` attribute set (or left as default `required`) +- [ ] Owning property added to the owner class (unless `owner="none"`) +- [ ] `version` attribute incremented on `` +- [ ] Change history entry added +- [ ] Migration class created and registered in `LcmDataMigrationManager` +- [ ] Build succeeds and code regenerates correctly +- [ ] Hand-written partial class added if business logic needed +- [ ] `BootstrapNewLanguageProject` updated if default instances needed +- [ ] Tests written +- [ ] Generated files NOT manually edited diff --git a/docs/agents/adding-a-property.md b/docs/agents/adding-a-property.md new file mode 100644 index 00000000..f2580eab --- /dev/null +++ b/docs/agents/adding-a-property.md @@ -0,0 +1,189 @@ +# Task: Adding a Property to an Existing Class + +This guide covers adding a new persisted property to an existing class in the LCM model. This is one of the most common and most dangerous changes -- it touches the XML model, requires a data migration, and triggers code regeneration. + +If you need a computed/derived property that is NOT persisted, see [adding-a-virtual-property.md](adding-a-virtual-property.md) instead. + +## Prerequisites + +- Read the Critical Rules section in AGENTS.md +- Know the target class name (e.g., `LexSense`) +- Know the property type (`basic`, `owning`, or `rel`) and signature + +## Steps + +### 1. Edit MasterLCModel.xml + +File: `src/SIL.LCModel/MasterLCModel.xml` + +Find the target class and add the property inside its `` element. Choose the next available `num` for that class (check existing properties). + +**Basic property example** (adding a `MultiString` field): +```xml + + + Description of the field. No newlines inside para elements. + + +``` + +**Owning property example** (adding an owning sequence): +```xml + + + Description of owned objects. + + +``` + +**Reference property example** (adding a reference collection): +```xml + + + Description of referenced objects. + + +``` + +Property type signatures for ``: +- `Integer`, `Boolean`, `String`, `Unicode`, `MultiString`, `MultiUnicode` +- `Time`, `GenDate`, `Binary`, `Guid`, `TextPropBinary` + +Cardinality values for `` and ``: +- `atomic` -- zero or one target +- `seq` -- ordered list +- `col` -- unordered collection + +### 2. Increment the Model Version + +In the same file (`MasterLCModel.xml`), update the `version` attribute on the root `` element: + +```xml + +``` + +Add a change history entry just below the existing ones: + +```xml + DD Month YYYY (7000073): Added NewFieldName to ClassName. Brief description. +``` + +### 3. Write the Data Migration + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +**For new optional properties with safe defaults (most common case)**, existing data doesn't need modification. But you still need the migration class: + +```csharp +// Copyright (c) YYYY SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + // New optional property with safe default; no data changes needed. + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +**IMPORTANT**: If you are adding a C# value type property (int, bool, GenDate, DateTime), you MUST add an explicit XML element with the default value to every existing instance. See the WARNING at the top of `MasterLCModel.xml`. Example migration that adds a default value: + +```csharp +public void PerformMigration(IDomainObjectDTORepository repoDto) +{ + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + + foreach (var dto in repoDto.AllInstancesWithSubclasses("TargetClass")) + { + var element = XElement.Parse(dto.Xml); + if (element.Element("NewBoolField") == null) + { + element.Add(new XElement("NewBoolField", new XAttribute("val", "False"))); + DataMigrationServices.UpdateDTO(repoDto, dto, element.ToString()); + } + } + + DataMigrationServices.IncrementVersionNumber(repoDto); +} +``` + +### 4. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +Add a line in the constructor, after the last existing entry: + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +If no data changes are needed, you can use `m_bumpNumberOnlyMigration` instead: +```csharp +m_individualMigrations.Add(7000073, m_bumpNumberOnlyMigration); +``` +In this case you do NOT need to create a `DataMigration7000073.cs` file. + +### 5. Rebuild to Regenerate Code + +``` +dotnet build --configuration Release +``` + +This triggers `GenerateModel` which regenerates all 9 `Generated*.cs` files from the updated `MasterLCModel.xml`. The new property will appear in the generated constants, interfaces, class implementations, etc. + +### 6. Add Hand-Written Logic (if needed) + +If the property needs custom logic beyond what the generator provides (computed side effects, validation, etc.), add it to the appropriate `Overrides*.cs` partial class in `src/SIL.LCModel/DomainImpl/`. + +### 7. Write Tests + +**Migration test**: Create `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigration7000073Tests.cs` + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + // Parse test data XML (create a matching .xml file in the test data directory) + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + // Assert the migration results + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + // Add specific assertions for your migration... + } + } +} +``` + +**API test**: For testing property access via the LCM API, inherit from `MemoryOnlyBackendProviderTestBase`. + +### Checklist + +- [ ] Property added to `MasterLCModel.xml` with correct `num`, `id`, `sig`, and (if relational) `card` +- [ ] `version` attribute incremented on `` +- [ ] Change history comment added +- [ ] Migration class created OR `m_bumpNumberOnlyMigration` used +- [ ] Migration registered in `LcmDataMigrationManager` constructor +- [ ] If adding a C# value type: migration writes explicit defaults to all existing instances +- [ ] Build succeeds (`dotnet build --configuration Release`) +- [ ] Migration test written +- [ ] Generated files NOT manually edited diff --git a/docs/agents/adding-a-virtual-property.md b/docs/agents/adding-a-virtual-property.md new file mode 100644 index 00000000..ccf74392 --- /dev/null +++ b/docs/agents/adding-a-virtual-property.md @@ -0,0 +1,154 @@ +# Task: Adding a Virtual Property + +Virtual properties are computed/derived properties that are NOT persisted in the data store. They are discovered automatically via reflection from the `[VirtualProperty]` attribute. No model version change, no migration, and no XML editing is required. + +Use this when you need a property that: +- Computes a value from other persisted data +- Provides a back-reference (e.g., "all senses that reference this semantic domain") +- Exposes a convenience accessor + +If the property needs to be persisted, see [adding-a-property.md](adding-a-property.md) instead. + +## Steps + +### 1. Choose the Target File + +Virtual properties are added to partial class definitions in `src/SIL.LCModel/DomainImpl/`. Find the appropriate `Overrides*.cs` file: + +| File | Classes | +|------|---------| +| `OverridesLing_Lex.cs` | LexDb, LexEntry, LexSense, LexEntryRef, LexExampleSentence, etc. | +| `OverridesCellar.cs` | CmObject, CmPossibility, CmSemanticDomain, StText, StPara, etc. | +| `OverridesLing_Wfi.cs` | WfiWordform, WfiAnalysis, WfiGloss, WfiMorphBundle | +| `OverridesLing_MoClasses.cs` | MoForm, MoStemAllomorph, MoAffixAllomorph, MoMorphSynAnalysis, etc. | +| `OverridesLangProj.cs` | LangProject | +| `OverridesLing_Disc.cs` | DsConstChart, ConstChartRow, etc. | +| `OverridesNotebk.cs` | RnGenericRec | + +If the class doesn't have a partial class in any of these files yet, add a new `partial class` block to the appropriate file. + +### 2. Add the Property + +Add a public property with the `[VirtualProperty]` attribute inside the partial class. + +**Required imports:** +```csharp +using SIL.LCModel.Core.Cellar; // CellarPropertyType +using SIL.LCModel.Infrastructure; // VirtualPropertyAttribute +``` + +### 3. Choose the Right Pattern + +**Simple value type** (Integer, Boolean): +```csharp +[VirtualProperty(CellarPropertyType.Boolean)] +public bool IsSpecialCase +{ + get { return /* computed boolean expression */; } +} +``` + +**Reference collection** (back-references or computed lists): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceCollection, "LexSense")] +public IEnumerable RelatedSenses +{ + get + { + // Compute and return the collection + return Services.GetInstance() + .AllInstances() + .Where(s => /* filter condition */); + } +} +``` + +The second parameter to `VirtualProperty` is the **signature** -- the unqualified class name of the target type. Required for all object-type properties (Reference*, Owning*). + +**Reference sequence** (ordered list): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceSequence, "LexEntry")] +public IEnumerable OrderedEntries +{ + get { return /* computed ordered sequence */; } +} +``` + +**MultiUnicode** (computed multi-writing-system string): +```csharp +[VirtualProperty(CellarPropertyType.MultiUnicode)] +public IMultiAccessorBase ComputedTitle +{ + get + { + if (m_titleFlid == 0) + m_titleFlid = Cache.MetaDataCache.GetFieldId("ClassName", "ComputedTitle", false); + return new VirtualStringAccessor(this, m_titleFlid, ComputedTitleForWs); + } +} +private int m_titleFlid; + +private ITsString ComputedTitleForWs(int ws) +{ + // Return a TsString for the given writing system + return TsStringUtils.MakeString("computed value", ws); +} +``` + +**Reference atomic** (single computed reference): +```csharp +[VirtualProperty(CellarPropertyType.ReferenceAtomic, "CmPossibility")] +public ICmPossibility ComputedCategory +{ + get { return /* single object or null */; } +} +``` + +### 4. Property Registration + +No registration is needed. The `LcmMetaDataCache` automatically discovers properties with `[VirtualProperty]` via reflection during initialization. FLIDs are auto-assigned starting at 20,000,000. + +### 5. Accessing Virtual Properties + +**From C# code** -- use the property directly: +```csharp +var senses = semanticDomain.ReferringSenses; +``` + +**From the SilDataAccess layer** (for views/UI integration): +```csharp +int flid = cache.MetaDataCache.GetFieldId("ClassName", "PropertyName", false); +var value = cache.DomainDataByFlid.get_Prop(obj.Hvo, flid); +``` + +### 6. Optional: Expose on the Interface + +If the virtual property should be accessible via the public interface (e.g., `ILexEntry`), add it to the hand-written partial interface. The generated interfaces are partial, so you can extend them: + +```csharp +// In a file like src/SIL.LCModel/ILexEntryExtensions.cs or similar +namespace SIL.LCModel +{ + public partial interface ILexEntry + { + IEnumerable ComputedProperty { get; } + } +} +``` + +Check existing patterns -- many interfaces already have partial extensions. + +### 7. Write Tests + +Test virtual properties using `MemoryOnlyBackendProviderTestBase`. See [writing-tests.md](writing-tests.md). + +## Checklist + +- [ ] Property added to the correct partial class in `DomainImpl/Overrides*.cs` +- [ ] `[VirtualProperty]` attribute applied with correct `CellarPropertyType` +- [ ] Signature parameter provided for object-type properties +- [ ] Property is public and read-only (getter only) +- [ ] Interface extended if the property needs to be part of the public API +- [ ] No changes to `MasterLCModel.xml` (virtual properties are not persisted) +- [ ] No data migration needed +- [ ] Tests written diff --git a/docs/agents/writing-a-data-migration.md b/docs/agents/writing-a-data-migration.md new file mode 100644 index 00000000..a2f3ff86 --- /dev/null +++ b/docs/agents/writing-a-data-migration.md @@ -0,0 +1,195 @@ +# Task: Writing a Data Migration + +Data migrations transform existing persisted data when the model version changes. They operate on raw XML via `DomainObjectDTO` objects -- live `CmObject` instances are NOT available during migration. + +## When a Migration is Needed + +- Adding a C# value-type property (int, bool, GenDate, DateTime) that needs explicit defaults +- Removing a property or class (must clean up existing XML) +- Renaming a property or class +- Changing a property's type or cardinality +- Restructuring ownership or references +- Any change that requires existing persisted data to be transformed + +If the model change is purely additive (new optional reference/string property with no data to transform), you can use `m_bumpNumberOnlyMigration` in the manager instead. See step 4 in [adding-a-property.md](adding-a-property.md). + +## Steps + +### 1. Determine the Next Version Number + +Check `src/SIL.LCModel/MasterLCModel.xml` for the current version in ``. The current version is **7000072**. Your migration file number is the next integer: **7000073**. + +### 2. Create the Migration Class + +Create: `src/SIL.LCModel/DomainServices/DataMigration/DataMigration7000073.cs` + +```csharp +// Copyright (c) YYYY 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.Xml.Linq; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + internal class DataMigration7000073 : IDataMigration + { + /// + /// Brief description of what this migration does. + /// + public void PerformMigration(IDomainObjectDTORepository repoDto) + { + DataMigrationServices.CheckVersionNumber(repoDto, 7000072); + + // --- Your migration logic here --- + + DataMigrationServices.IncrementVersionNumber(repoDto); + } + } +} +``` + +**Mandatory structure:** +1. First line: `DataMigrationServices.CheckVersionNumber(repoDto, previousVersion)` +2. Middle: your data transformation +3. Last line: `DataMigrationServices.IncrementVersionNumber(repoDto)` + +### 3. Common Migration Operations + +**Finding objects:** +```csharp +// All instances of a class (including subclasses) +var entries = repoDto.AllInstancesWithSubclasses("LexEntry"); + +// All instances of exact class (no subclasses) +var entries = repoDto.AllInstancesSansSubclasses("LexEntry"); + +// Single object by GUID +var dto = repoDto.GetDTO("guid-string-here"); + +// Owner of an object +var ownerDto = repoDto.GetOwningDTO(dto); + +// Directly owned children +var children = repoDto.GetDirectlyOwnedDTOs(dto.Guid); +``` + +**Modifying XML:** +```csharp +var element = XElement.Parse(dto.Xml); + +// Add an element +element.Add(new XElement("NewProperty", new XAttribute("val", "False"))); + +// Remove an element +element.Element("OldProperty")?.Remove(); + +// Add an objsur (owning reference) +var container = new XElement("OwnedThings"); +container.Add(new XElement("objsur", + new XAttribute("guid", targetGuid), + new XAttribute("t", "o"))); // "o" for owning, "r" for reference +element.Add(container); + +// Save changes +DataMigrationServices.UpdateDTO(repoDto, dto, element.ToString()); +``` + +**Creating new objects:** +```csharp +var newGuid = Guid.NewGuid().ToString().ToLowerInvariant(); +var sb = new StringBuilder(); +sb.AppendFormat("", newGuid, ownerGuid); +sb.Append(""); +sb.Append("value"); +sb.Append(""); +sb.Append(""); +repoDto.Add(new DomainObjectDTO(newGuid, "ClassName", sb.ToString())); +``` + +**Removing objects:** +```csharp +// Remove object, its owned children, and clean up owner's objsur +DataMigrationServices.RemoveIncludingOwnedObjects(repoDto, dto, removeFromOwner: true); +``` + +**Creating possibility lists** (use the helper): +```csharp +DataMigrationServices.CreatePossibilityList(repoDto, listGuid, ownerGuid, + new[] { Tuple.Create("en", "Abbr", "List Name") }, + DateTime.Now, WritingSystemServices.kwsAnals); +``` + +### 4. Register the Migration + +File: `src/SIL.LCModel/DomainServices/DataMigration/LcmDataMigrationManager.cs` + +Add at the end of the constructor's registration block: + +```csharp +m_individualMigrations.Add(7000073, new DataMigration7000073()); +``` + +### 5. Update MasterLCModel.xml Version + +File: `src/SIL.LCModel/MasterLCModel.xml` + +Update the version attribute and add a change history entry: +```xml + +``` + +### 6. Write Tests + +Create: `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigration7000073Tests.cs` + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + // Add assertions verifying data was transformed correctly + } + } +} +``` + +**Test data file**: Create a minimal `.xml` file with sample `` elements in the test data directory used by `DataMigrationTestServices.ParseProjectFile()`. Look at existing test data files like `DataMigration7000072.xml` for the expected format. + +## Key Rules + +- Migrations operate on raw XML strings via `DomainObjectDTO`. You cannot use `ICmObject` or any live LCM API. +- Use `XElement.Parse()` / `.ToString()` for XML manipulation. Do not use string replacement on XML. +- Always call `CheckVersionNumber` first and `IncrementVersionNumber` last. +- Handle null checks: optional elements may not exist in all objects. +- The repository tracks changes automatically. `UpdateDTO` marks as modified. `Add` marks as new. `Remove` marks as deleted. +- Use `AllInstancesWithSubclasses` when the property could be on subclasses too. +- GUIDs in the data are lowercase. Use `.ToLowerInvariant()` when comparing. +- For large migrations, organize logic into private helper methods (see `DataMigration7000072.cs` for this pattern). + +## Checklist + +- [ ] Migration class created with correct version number +- [ ] `CheckVersionNumber` called with previous version (N-1) +- [ ] `IncrementVersionNumber` called at the end +- [ ] Migration registered in `LcmDataMigrationManager` constructor +- [ ] `MasterLCModel.xml` version attribute updated +- [ ] Change history entry added to `MasterLCModel.xml` +- [ ] Test class created extending `DataMigrationTestsBase` +- [ ] Test data XML file created +- [ ] Build succeeds diff --git a/docs/agents/writing-tests.md b/docs/agents/writing-tests.md new file mode 100644 index 00000000..3a32428d --- /dev/null +++ b/docs/agents/writing-tests.md @@ -0,0 +1,185 @@ +# Task: Writing Tests + +Tests in liblcm use NUnit. The test infrastructure provides base classes that set up an `LcmCache` with the appropriate backend provider. + +## Test Project Structure + +Tests live in `tests/`: +- `SIL.LCModel.Tests/` -- Main library tests (model, domain services, infrastructure) +- `SIL.LCModel.Core.Tests/` -- Core utility tests +- `SIL.LCModel.Utils.Tests/` -- Utility tests +- `SIL.LCModel.FixData.Tests/` -- FixData tests + +## Base Classes + +### MemoryOnlyBackendProviderTestBase + +**Use for**: Testing the LCM public API (properties, factories, repositories, domain services). + +Located in `tests/SIL.LCModel.Tests/LcmTestBase.cs`. + +This creates a fresh in-memory `LcmCache` with a blank language project per test fixture. No file I/O. This is the most common base class. + +```csharp +using NUnit.Framework; +using SIL.LCModel.Infrastructure; + +namespace SIL.LCModel.SomeArea +{ + [TestFixture] + public class MyFeatureTests : MemoryOnlyBackendProviderTestBase + { + [Test] + public void MyTest() + { + // Cache is available via the Cache property + var lp = Cache.LanguageProject; + + // All data changes must be in a UnitOfWork + UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, () => + { + // Create objects via factories + var entry = Cache.ServiceLocator.GetInstance().Create(); + + // Set properties + var ws = Cache.DefaultVernWs; + entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("test", ws); + + // Assert + Assert.IsNotNull(entry); + }); + } + } +} +``` + +Key points: +- `Cache` property gives you the `LcmCache` +- `m_actionHandler` is the `IActionHandler` for UnitOfWork operations +- Default writing systems: `Cache.DefaultAnalWs` (English), `Cache.DefaultVernWs` (French) +- Use `UndoableUnitOfWorkHelper.Do()` or `NonUndoableUnitOfWorkHelper.Do()` for data changes + +### MemoryOnlyBackendProviderRestoredForEachTestTestBase + +**Use for**: Tests that need a clean state for each test method (not just each fixture). + +Same as above but disposes and recreates the cache before each `[Test]`. + +### DataMigrationTestsBase + +**Use for**: Testing data migrations. + +Located in `tests/SIL.LCModel.Tests/DomainServices/DataMigration/DataMigrationTests.cs`. + +Provides `m_dataMigrationManager` (an `IDataMigrationManager` instance). + +```csharp +using System.Xml.Linq; +using NUnit.Framework; + +namespace SIL.LCModel.DomainServices.DataMigration +{ + [TestFixture] + public class DataMigration7000073Tests : DataMigrationTestsBase + { + [Test] + public void DataMigration7000073Test() + { + // 1. Parse test data + var dtos = DataMigrationTestServices.ParseProjectFile("DataMigration7000073.xml"); + + // 2. Create repository at the PREVIOUS version + var mockMdc = new MockMDCForDataMigration(); + IDomainObjectDTORepository dtoRepos = new DomainObjectDtoRepository( + 7000072, dtos, mockMdc, null, TestDirectoryFinder.LcmDirectories); + + // 3. Run the migration + m_dataMigrationManager.PerformMigration(dtoRepos, 7000073, new DummyProgressDlg()); + + // 4. Verify version + Assert.AreEqual(7000073, dtoRepos.CurrentModelVersion); + + // 5. Verify data transformations + var dto = dtoRepos.GetDTO("some-guid-from-test-data"); + var element = XElement.Parse(dto.Xml); + Assert.IsNotNull(element.Element("ExpectedNewElement")); + } + } +} +``` + +**Test data files**: Migration tests use XML files containing sample `` elements. These live in the test data directory and are parsed by `DataMigrationTestServices.ParseProjectFile()`. Look at existing files like `DataMigration7000072.xml` for the format. The file should contain a minimal set of `` elements that exercise the migration logic. + +## Common Test Patterns + +### Creating Test Objects + +```csharp +UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, () => +{ + // Factories are accessed via ServiceLocator + var entryFactory = Cache.ServiceLocator.GetInstance(); + var senseFactory = Cache.ServiceLocator.GetInstance(); + + var entry = entryFactory.Create(); + var sense = senseFactory.Create(); + entry.SensesOS.Add(sense); +}); +``` + +### Setting String Properties + +```csharp +int vernWs = Cache.DefaultVernWs; +int analWs = Cache.DefaultAnalWs; + +// MultiUnicode +entry.CitationForm.set_String(vernWs, TsStringUtils.MakeString("word", vernWs)); + +// MultiString +sense.Definition.set_String(analWs, TsStringUtils.MakeString("a definition", analWs)); +``` + +### Accessing Repositories + +```csharp +var entryRepo = Cache.ServiceLocator.GetInstance(); +var allEntries = entryRepo.AllInstances(); +var count = entryRepo.Count; +``` + +### Testing with Custom Fields + +```csharp +using (var customField = new CustomFieldForTest( + Cache, "My Field", "MyField", + LexEntryTags.kClassId, + CellarPropertyType.MultiUnicode, + Guid.Empty)) +{ + // customField.Flid gives you the field ID + // Test using the custom field... +} +// Custom field is automatically removed on Dispose +``` + +## Running Tests + +``` +dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release +``` + +Or run individual test classes/methods from your IDE. + +Tests must NOT run in parallel (`ParallelizeAssembly=false`) due to shared state in the ICU and writing system subsystems. + +## Checklist + +- [ ] Test class inherits from the appropriate base class +- [ ] `[TestFixture]` attribute on the class +- [ ] `[Test]` attribute on test methods +- [ ] All data changes wrapped in `UndoableUnitOfWorkHelper.Do()` or `NonUndoableUnitOfWorkHelper.Do()` +- [ ] Test assertions verify the expected behavior +- [ ] For migration tests: test data XML file created, repository initialized at previous version +- [ ] Tests pass: `dotnet test --configuration Release` diff --git a/tests/SIL.LCModel.Core.Tests/Attributes/InitializeIcuAttribute.cs b/tests/SIL.LCModel.Core.Tests/Attributes/InitializeIcuAttribute.cs index 615c51a9..04962ae1 100644 --- a/tests/SIL.LCModel.Core.Tests/Attributes/InitializeIcuAttribute.cs +++ b/tests/SIL.LCModel.Core.Tests/Attributes/InitializeIcuAttribute.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using System.IO; using System.Reflection; using Icu; @@ -32,32 +33,75 @@ public override void BeforeTest(ITest testDetails) PreTestPathEnvironment = Environment.GetEnvironmentVariable("PATH"); + // Set ICU_DATA and PATH from build output (test assembly dir) before any Wrapper use, so tests pass without manual env. + SetIcuEnvironmentFromBuildOutputIfPresent(); + Wrapper.Verbose = true; - if (IcuVersion > 0) - Wrapper.ConfineIcuVersions(IcuVersion); + EnsureIcuDataEnvironmentVariableIsSet(); try { - Wrapper.Init(); + Text.CustomIcu.InitIcuDataDir(); } catch (Exception e) { Console.WriteLine($"InitializeIcuAttribute: ERROR: failed when calling Wrapper.Init() with {e.GetType()}: {e.Message}"); } - EnsureIcuDataEnvironmentVariableIsSet(); + if (IcuVersion > 0) + Wrapper.ConfineIcuVersions(IcuVersion); + } + /// + /// If the test assembly's directory has the build output layout (IcuData/icudt70l, lib/win-*), set ICU_DATA and prepend those lib paths to PATH + /// so the first ICU load finds our DLLs. Allows "dotnet test" to pass without manual environment setup. + /// + private static void SetIcuEnvironmentFromBuildOutputIfPresent() + { try { - Text.CustomIcu.InitIcuDataDir(); + var assembly = Assembly.GetExecutingAssembly(); + var location = assembly.Location; + if (string.IsNullOrEmpty(location)) + return; + var assemblyDir = Path.GetDirectoryName(location); + if (string.IsNullOrEmpty(assemblyDir)) + return; + + var icuDataVersionDir = Path.Combine(assemblyDir, "IcuData", $"icudt{CustomIcuVersion}l"); + var nfcFw = Path.Combine(icuDataVersionDir, "nfc_fw.nrm"); + var nfkcFw = Path.Combine(icuDataVersionDir, "nfkc_fw.nrm"); + if (!File.Exists(nfcFw) || !File.Exists(nfkcFw)) + return; + + Environment.SetEnvironmentVariable("ICU_DATA", icuDataVersionDir, EnvironmentVariableTarget.Process); + + var pathPrefixes = new List(); + AddPathIfExists(pathPrefixes, assemblyDir, "lib", "win-x86"); + AddPathIfExists(pathPrefixes, assemblyDir, "lib", "x86"); + AddPathIfExists(pathPrefixes, assemblyDir, "lib", "win-x64"); + AddPathIfExists(pathPrefixes, assemblyDir, "lib", "x64"); + if (pathPrefixes.Count > 0) + { + var existingPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + var newPath = string.Join(Path.PathSeparator.ToString(), pathPrefixes) + Path.PathSeparator + existingPath; + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } } catch (Exception e) { - Console.WriteLine($"InitializeIcuAttribute: ERROR: failed with {e.GetType()}: {e.Message}"); + Console.WriteLine($"InitializeIcuAttribute: ERROR: failed when setting ICU env from build output: {e.GetType()}: {e.Message}"); } } + private static void AddPathIfExists(List list, string baseDir, params string[] parts) + { + var path = Path.Combine(baseDir, Path.Combine(parts)); + if (Directory.Exists(path)) + list.Add(path); + } + private void EnsureIcuDataEnvironmentVariableIsSet() { string dir = null;