From 58fddf6fbe7e9c470a11f1d0c2471392b7add3c8 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Sun, 20 Sep 2026 14:09:32 +0200 Subject: [PATCH] Add optional grammatical gender --- CHANGELOG.md | 4 + README.md | 8 +- .../LexiconKit/LexiconGrammaticalGender.swift | 11 +++ .../LexiconKit/LexiconKit.docc/LexiconKit.md | 3 +- Sources/LexiconKit/LexiconTerm.swift | 11 ++- Tests/LexiconKitTests/LexiconEntryTests.swift | 87 +++++++++++++++++++ 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 Sources/LexiconKit/LexiconGrammaticalGender.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index de0eaa1..1729d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ All notable changes to LexiconKit are documented here. ## Unreleased +### Added + +- Add optional, language-independent grammatical gender metadata to `LexiconTerm` with backward-compatible Codable behavior. + ## 0.1.1 — 2026-09-13 ### Changed diff --git a/README.md b/README.md index 4888e27..cdebf4b 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,15 @@ LexiconKit is a reusable, UI-agnostic domain package for personal vocabulary collections, definitions, provenance, lightweight metadata, and opaque references to host-owned media. -LexiconKit provides persistence-friendly values for vocabulary entries, terms, definitions, definition provenance, tags, and opaque host-owned media references. Dictionary lookup, translation, exercises, persistence frameworks, synchronization, and UI remain outside its boundary. +LexiconKit provides persistence-friendly values for vocabulary entries, terms, optional grammatical gender, definitions, definition provenance, tags, and opaque host-owned media references. Dictionary lookup, translation, language-specific display articles, exercises, persistence frameworks, synchronization, and UI remain outside its boundary. ```swift let entry = LexiconEntry( - term: LexiconTerm(text: "Haus", languageCode: "de"), + term: LexiconTerm( + text: "Haus", + languageCode: "de", + grammaticalGender: .neuter + ), definitions: [ LexiconDefinition( text: "house", diff --git a/Sources/LexiconKit/LexiconGrammaticalGender.swift b/Sources/LexiconKit/LexiconGrammaticalGender.swift new file mode 100644 index 0000000..125e9a1 --- /dev/null +++ b/Sources/LexiconKit/LexiconGrammaticalGender.swift @@ -0,0 +1,11 @@ +/// A grammatical gender category associated with a lexical term. +public enum LexiconGrammaticalGender: String, Codable, Hashable, Sendable { + /// The feminine grammatical gender. + case feminine + + /// The masculine grammatical gender. + case masculine + + /// The neuter grammatical gender. + case neuter +} diff --git a/Sources/LexiconKit/LexiconKit.docc/LexiconKit.md b/Sources/LexiconKit/LexiconKit.docc/LexiconKit.md index 054da38..6702345 100644 --- a/Sources/LexiconKit/LexiconKit.docc/LexiconKit.md +++ b/Sources/LexiconKit/LexiconKit.docc/LexiconKit.md @@ -6,7 +6,7 @@ Model persistence-friendly vocabulary values without coupling consumers to UI, l LexiconKit models vocabulary independently from persistence, synchronization, user interface, and learning progression. Hosts decide how entries are validated, deduplicated, stored, synchronized, and presented. -Create a ``LexiconEntry`` from a ``LexiconTerm`` and one or more ``LexiconDefinition`` values. Definitions retain their ``LexiconDefinitionSource`` so application-provided and user-provided content remain distinguishable after serialization. ``LexiconTag`` values and an optional ``LexiconAssetReference`` carry host-defined metadata without leaking application or media-framework types into the domain. +Create a ``LexiconEntry`` from a ``LexiconTerm`` and one or more ``LexiconDefinition`` values. A term can carry an optional ``LexiconGrammaticalGender`` category without encoding a language-specific display article. Definitions retain their ``LexiconDefinitionSource`` so application-provided and user-provided content remain distinguishable after serialization. ``LexiconTag`` values and an optional ``LexiconAssetReference`` carry host-defined metadata without leaking application or media-framework types into the domain. All public values are `Codable`, `Hashable`, and `Sendable`. Stable entry and definition identifiers plus explicit creation and modification timestamps let a host persist and merge values using its own policy. @@ -16,6 +16,7 @@ All public values are `Codable`, `Hashable`, and `Sendable`. Stable entry and de - ``LexiconEntry`` - ``LexiconTerm`` +- ``LexiconGrammaticalGender`` ### Definitions diff --git a/Sources/LexiconKit/LexiconTerm.swift b/Sources/LexiconKit/LexiconTerm.swift index b7e153f..30cc19c 100644 --- a/Sources/LexiconKit/LexiconTerm.swift +++ b/Sources/LexiconKit/LexiconTerm.swift @@ -2,6 +2,9 @@ import Foundation /// A collected lexical form or phrase and its language metadata. public struct LexiconTerm: Codable, Hashable, Sendable { + /// The term's optional grammatical gender. + public var grammaticalGender: LexiconGrammaticalGender? + public var languageCode: String public var text: String @@ -10,7 +13,13 @@ public struct LexiconTerm: Codable, Hashable, Sendable { /// - Parameters: /// - text: The collected lexical form or phrase. /// - languageCode: The BCP 47 language code for the text. - public init(text: String, languageCode: String) { + /// - grammaticalGender: An optional language-independent grammatical gender category. + public init( + text: String, + languageCode: String, + grammaticalGender: LexiconGrammaticalGender? = nil + ) { + self.grammaticalGender = grammaticalGender self.languageCode = languageCode self.text = text } diff --git a/Tests/LexiconKitTests/LexiconEntryTests.swift b/Tests/LexiconKitTests/LexiconEntryTests.swift index 7d3d94d..aadfc23 100644 --- a/Tests/LexiconKitTests/LexiconEntryTests.swift +++ b/Tests/LexiconKitTests/LexiconEntryTests.swift @@ -4,6 +4,78 @@ import Testing @testable import LexiconKit struct LexiconEntryTests { + @Test func legacyEntryWithoutGrammaticalGenderDecodesWithNil() throws { + let payload = Data( + #"{"createdAt":0,"definitions":[],"id":"D2E60778-E44B-48BE-B539-268D1F15229A","modifiedAt":0,"tags":[],"term":{"languageCode":"de","text":"Haus"}}"# + .utf8 + ) + + let entry = try JSONDecoder().decode(LexiconEntry.self, from: payload) + + #expect(entry.term.text == "Haus") + #expect(entry.term.grammaticalGender == nil) + } + + @Test( + "Every supported grammatical gender round-trips", + arguments: [ + LexiconGrammaticalGender.feminine, + .masculine, + .neuter, + ] + ) + func grammaticalGenderRoundTrips(_ gender: LexiconGrammaticalGender) throws { + let term = LexiconTerm( + text: "Haus", + languageCode: "de", + grammaticalGender: gender + ) + + let data = try JSONEncoder().encode(term) + let decoded = try JSONDecoder().decode(LexiconTerm.self, from: data) + + #expect(decoded == term) + #expect(decoded.grammaticalGender == gender) + } + + @Test func existingInitializerDefaultsGrammaticalGenderToNil() { + let term = LexiconTerm(text: "laufen", languageCode: "de") + + #expect(term.grammaticalGender == nil) + } + + @Test func equalityAndHashingIncludeGrammaticalGender() { + let masculine = LexiconTerm( + text: "See", + languageCode: "de", + grammaticalGender: .masculine + ) + let feminine = LexiconTerm( + text: "See", + languageCode: "de", + grammaticalGender: .feminine + ) + + #expect(masculine != feminine) + #expect(Set([masculine, feminine]).count == 2) + } + + @Test func legacyDecoderIgnoresNewGrammaticalGenderKey() throws { + let entry = LexiconEntry( + term: LexiconTerm( + text: "Haus", + languageCode: "de", + grammaticalGender: .neuter + ), + definitions: [] + ) + + let data = try JSONEncoder().encode(entry) + let legacyEntry = try JSONDecoder().decode(LegacyEntry.self, from: data) + + #expect(legacyEntry.term == LegacyTerm(languageCode: "de", text: "Haus")) + } + @Test func completeEntryRoundTripsThroughCodable() throws { // Given let entryID = UUID(uuidString: "D2E60778-E44B-48BE-B539-268D1F15229A")! @@ -88,3 +160,18 @@ struct LexiconEntryTests { #expect(entry.definitions.map(\.text) == ["book"]) } } + +private struct LegacyTerm: Codable, Equatable { + var languageCode: String + var text: String +} + +private struct LegacyEntry: Codable { + let id: UUID + var assetReference: LexiconAssetReference? + var createdAt: Date + var definitions: [LexiconDefinition] + var modifiedAt: Date + var tags: Set + var term: LegacyTerm +}