diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 9da7ca6..2a2aa22 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => { expect(CATALOG).toHaveLength(CATALOG_SIZE) expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 }) // Every row has a non-empty canonical genotype display string and unique name. + // GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens. expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length) - expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true) + expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true) }) it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { @@ -523,3 +524,99 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell') }) }) + +describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => { + // ── Display symbols ──────────────────────────────────────────────────── + it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => { + // Fuchsschimmel: E=[ef,ef] hom + expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe( + 'AA CC DD e[f]e[f] GG PP spsp rere', + ) + // C-locus het: cchm + ch + expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe( + 'aa c[chm]c[h] DD EE GG PP spsp rere', + ) + // C-locus hom cchm + expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe( + 'aa c[chm]c[chm] DD EE GG PP spsp rere', + ) + }) + + // ── E-locus display order: E > e > e[f] ───────────────────────────── + it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => { + // Stored canonical: [ef, e] (ef dominant over e in storage). + // Display must swap to [e, ef] per breeder convention. + const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere') + expect(g.E).toEqual(['ef', 'e']) // storage order unchanged + expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere') + }) + + it('E+e stays Ee (E dominant over e, no swap needed)', () => { + expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe( + 'aa CC DD Ee GG PP spsp rere', + ) + }) + + it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => { + expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe( + 'aa CC DD Ee[f] GG PP spsp rere', + ) + }) + + // ── Julian oracle fixtures (HUMANQUESTION D3/D4) ───────────────────── + it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => { + const display = 'aa C- D- ee[f] Gg Pp spsp rere' + const g = fromDisplayString(display) + expect(g.E).toEqual(['ef', 'e']) + expect(g.C).toEqual(['C', '?']) + expect(toDisplayString(g)).toBe(display) + // Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is. + }) + + it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => { + const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere' + const g = fromDisplayString(display) + expect(g.C).toEqual(['cchm', 'ch']) + expect(g.E).toEqual(['E', 'e']) + expect(toDisplayString(g)).toBe(display) + }) + + it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => { + const display = 'aa Cc[h] dd EE Gg P- Spsp rere' + const g = fromDisplayString(display) + expect(g.C).toEqual(['C', 'ch']) + expect(g.D).toEqual(['d', 'd']) + expect(toDisplayString(g)).toBe(display) + }) + + // ── Parser accepts both forms ───────────────────────────────────────── + it('bracket input round-trips identically to internal-symbol input', () => { + expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe( + toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')), + ) + expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe( + toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')), + ) + expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe( + toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')), + ) + }) + + it('e[-] standalone: parses as [e,?], displays e-', () => { + const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere') + expect(g.E).toEqual(['e', '?']) + expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere') + }) + + it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => { + // Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele. + // The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken. + const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp' + const g = fromDisplayString(input) + expect(g.E).toEqual(['e', '?']) + expect(g.C).toEqual(['cchm', 'cchm']) + expect(g.D).toEqual(['D', 'd']) + expect(g.Sp).toEqual(['Sp', 'sp']) + expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere') + }) +}) diff --git a/gerbil-manager-web/src/genetics/colorVarietySeed.generated.json b/gerbil-manager-web/src/genetics/colorVarietySeed.generated.json index c696cbc..c6e634f 100644 --- a/gerbil-manager-web/src/genetics/colorVarietySeed.generated.json +++ b/gerbil-manager-web/src/genetics/colorVarietySeed.generated.json @@ -2,35 +2,35 @@ { "name": "Pink Eyed White (PEW)", "english": "Pink Eyed White", - "canonicalGenotype": "AA chch DD EE GG pp spsp rere", + "canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere", "sortOrder": 0, "image": "rotaugen-weiss-pew-d-sep-e-sep.jpg" }, { "name": "Hermelin", "english": "Dark Tailed White", - "canonicalGenotype": "aa chch DD EE GG PP spsp rere", + "canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere", "sortOrder": 1, "image": "hermelin.jpeg" }, { "name": "Himalaya", "english": "Himalayan", - "canonicalGenotype": "AA chch DD EE GG PP spsp rere", + "canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere", "sortOrder": 2, "image": "himalaya.jpg" }, { "name": "Zobel", "english": "Sable", - "canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere", + "canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere", "sortOrder": 3, "image": "zobel.jpeg" }, { "name": "Rotaugenschimmel", "english": "Red-Eyed Roan", - "canonicalGenotype": "AA CC DD efef GG pp spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere", "sortOrder": 4, "image": "rotaugen-schimmel.jpg" }, @@ -168,7 +168,7 @@ }, { "name": "Orangeschimmel", - "canonicalGenotype": "AA CC DD efef GG PP spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere", "sortOrder": 25, "image": "schimmel-orangeschimmel.jpg" }, @@ -210,31 +210,31 @@ }, { "name": "Silberschimmel", - "canonicalGenotype": "AA CC DD efef gg PP spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere", "sortOrder": 32, "image": "silberschimmel.jpg" }, { "name": "Polarfuchsschimmel", - "canonicalGenotype": "AA CC DD efef gg PP spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere", "sortOrder": 33, "image": "polarfuchsschimmel.jpg" }, { "name": "Algierfuchsschimmel", - "canonicalGenotype": "AA CC DD efef GG PP spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere", "sortOrder": 34, "image": "algierfuchsschimmel.jpg" }, { "name": "Kohlfuchsschimmel", - "canonicalGenotype": "aa CC DD efef GG PP spsp rere", + "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere", "sortOrder": 35, "image": "kohlfuchsschimmel.jpg" }, { "name": "Blaufuchsschimmel", - "canonicalGenotype": "aa CC DD efef gg PP spsp rere", + "canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere", "sortOrder": 36, "image": "blaufuchsschimmel.jpg" }, @@ -252,7 +252,7 @@ }, { "name": "Goldfuchsschimmel", - "canonicalGenotype": "AA CC DD efef GG pp spsp rere", + "canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere", "sortOrder": 39, "image": "goldfuchsschimmel.jpg" }, @@ -270,7 +270,7 @@ }, { "name": "Rotfuchsschimmel", - "canonicalGenotype": "aa CC DD efef GG pp spsp rere", + "canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere", "sortOrder": 42, "image": "rotfuchsschimmel.jpg" }, @@ -282,7 +282,7 @@ }, { "name": "Kohlfuchsschimmel, hell", - "canonicalGenotype": "aa CC DD efef GG PP spsp rere", + "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere", "sortOrder": 44, "image": "kohlfuchsschimmel-hell.jpg" }, @@ -318,89 +318,89 @@ }, { "name": "Marder", - "canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere", + "canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere", "sortOrder": 50, "image": "marder.JPG" }, { "name": "Siam", - "canonicalGenotype": "aa cchmch DD EE GG PP spsp rere", + "canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere", "sortOrder": 51, "image": "siam-marder-hell.JPG" }, { "name": "Zobel-Hell", - "canonicalGenotype": "aa cchmch DD EE gg PP spsp rere", + "canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere", "sortOrder": 52, "image": "zobel-hell.jpg" }, { "name": "CP-Agouti", - "canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere", "sortOrder": 53, "image": "agouti-cp.jpg" }, { "name": "CP-Agouti-Hell", - "canonicalGenotype": "AA cchmch DD EE GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere", "sortOrder": 54 }, { "name": "CP-Silberagouti", - "canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere", "sortOrder": 55, "image": "silberagouti-cp.JPG" }, { "name": "CP-Silberagouti-Hell", - "canonicalGenotype": "AA cchmch DD EE gg PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere", "sortOrder": 56 }, { "name": "CP-Algierfuchs", - "canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere", "sortOrder": 57, "image": "algierfuchs-cp.jpg" }, { "name": "CP-Algierfuchs-Hell", - "canonicalGenotype": "AA cchmch DD ee GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere", "sortOrder": 58 }, { "name": "CP-Polarfuchs", - "canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere", "sortOrder": 59, "image": "polarfuchs-cp.jpg" }, { "name": "CP-Polarfuchs-Hell", - "canonicalGenotype": "AA cchmch DD ee gg PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere", "sortOrder": 60 }, { "name": "CP-Fuchs", - "canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere", "sortOrder": 61 }, { "name": "CP-Fuchs-Hell", - "canonicalGenotype": "AA cchmch dd ee GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere", "sortOrder": 62 }, { "name": "CP-Blaufuchs", - "canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere", "sortOrder": 63 }, { "name": "CP-Orangeschimmel", - "canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere", "sortOrder": 64 }, { "name": "CP-Orangeschimmel-Hell", - "canonicalGenotype": "AA cchmch DD efef GG PP spsp rere", + "canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere", "sortOrder": 65 } ] diff --git a/gerbil-manager-web/src/genetics/genotype.ts b/gerbil-manager-web/src/genetics/genotype.ts index 6441c93..5a54606 100644 --- a/gerbil-manager-web/src/genetics/genotype.ts +++ b/gerbil-manager-web/src/genetics/genotype.ts @@ -74,6 +74,32 @@ export function wildType(): Genotype { return out } +/** + * GEN-3h: breeder bracket-notation display symbols. + * STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes. + */ +const DISPLAY_SYMBOL: Readonly>> = { + ef: 'e[f]', + cchm: 'c[chm]', + ch: 'c[h]', +} +function displaySymbol(allele: string): string { + return DISPLAY_SYMBOL[allele] ?? allele +} + +/** + * GEN-3h: E-locus display order — breeder convention is E > e > e[f]. + * Storage/dominance order is E > ef > e; display swaps ef and e so that + * a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e". + */ +const E_DISPLAY_RANK: Readonly> = { E: 0, e: 1, ef: 2 } + +function displayPair(locus: LocusKey, pair: AllelePair): AllelePair { + if (locus !== 'E') return pair + const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER + return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]] +} + /** * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". * The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and @@ -81,12 +107,18 @@ export function wildType(): Genotype { * (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl. * GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder * convention) — e.g. ['C','?'] renders "C-". + * GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]); + * E-locus display order is E > e > e[f] (e before e[f] in het pairs). */ export function toDisplayString(g: Genotype): string { return LOCUS_ORDER.filter( (locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'), ) - .map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-')) + .map((locus) => { + const [a, b] = displayPair(locus, g[locus]) + return displaySymbol(a) + displaySymbol(b) + }) + .map((s) => s.replace(/\?/g, '-')) .join(' ') } @@ -146,8 +178,19 @@ function normalizeToken(tok: string): string | null { if (t === 'WP') t = 'Slsl' t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl') t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g') - // GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?' - // (the frozen storage contract keeps '?'; only DISPLAY renders '-'). + // GEN-3h: accept bracket display notation → canonical internal symbols. + t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal + t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal + t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal + // CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain). + // When e[-] is PRECEDED by a letter it is the second unknown allele in a + // 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only + // the e[-] part; the leading allele stays. Standalone e[-] falls through to + // the generic [-]→? rule below (which makes the bracket-dash a wildcard, + // leaving the leading allele intact for splitToken). + t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?') + t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard + // GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'. t = t.replace(/-/g, '?') return t }