fix(genetics): Genotyp-/Farbschlag-Engine-Bugs aus Ticket-Triage
- uw/Uwuw[d] (dichtes Underwhite) wird vor dem uw→G-Locus-Alias korrekt geparst (Vance: nicht mehr „Unbekannter Farbschlag", zeigt G-Locus statt international uw). [#34,#32] - ee[-] = sichtbarer Fuchs (rezessiv homozygot) → korrekt als Fuchs; bare „e-" (rezessiv + unbekannt) ist genetisch unmöglich → genotypeInvalid statt still falsch. [#42] - aa-Colourpoint-Zweig berücksichtigt jetzt D (Dilute) und E (Fuchs), statt fix Zobel/Marder/Siam zu erzwingen. [#3] - Wurf-Farbprognose: unbekanntes Allel (?) wird nicht mehr über die volle Dominanz expandiert → keine unmöglichen Dilute-/Schimmel-/„Unbekannt"-Nachkommen. [#37,#39,#40,#41,#38] Tests: test_genotype.py erweitert; genetics.test.ts +5 Blöcke (135 vitest grün, tsc/eslint sauber). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -291,6 +291,62 @@ describe('GEN-3a: Uw=G alias', () => {
|
|||||||
)
|
)
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('#32/#34: dense-underwhite [d] annotation is stripped (Uwuw[d] → Gg, never uw)', () => {
|
||||||
|
// Vance's real genotype: "aa Cc[chm] D- ee Uwuw[d] PP spsp" → Kohlfuchs, not Unbekannt.
|
||||||
|
expect(fromDisplayString('AA CC DD EE Uwuw[d] PP spsp rere').G).toEqual(['G', 'g'])
|
||||||
|
expect(fromDisplayString('AA CC DD EE uw[d]uw[d] PP spsp rere').G).toEqual(['g', 'g'])
|
||||||
|
const vance = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||||||
|
expect(vance.G).toEqual(['G', 'g'])
|
||||||
|
const display = toDisplayString(vance)
|
||||||
|
expect(display).toContain('Gg')
|
||||||
|
expect(display.toLowerCase()).not.toContain('uw')
|
||||||
|
expect(genotypeToFarbschlag(vance)).toBe('Kohlfuchs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#42: E-locus Algierfuchs (ee[-] visible Fuchs)', () => {
|
||||||
|
it('Aa CC D- ee[-] Gg Pp spsp → Algierfuchs', () => {
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('Aa CC D- ee[-] Gg Pp spsp'))).toBe('Algierfuchs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#3: aa colourpoint respects D (dilute) and E (Fuchs)', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
it('does NOT collapse aa colourpoint with dd/Fuchs to Zobel', () => {
|
||||||
|
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel')
|
||||||
|
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel Schecke')
|
||||||
|
})
|
||||||
|
it('aa cchm with full D + full extension still resolves to Marder/Zobel (regression)', () => {
|
||||||
|
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||||
|
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||||
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#37/#39/#40/#41: wildcard expansion is dominance/visibility constrained', () => {
|
||||||
|
it('an unknown E partner of a visible E never introduces ef (no phantom Schimmel)', () => {
|
||||||
|
// Mamta Mini (Ee[-] = [E,?]) × Gold (Ee): no Schimmel/efef/Unbekannt offspring.
|
||||||
|
const r = breed(
|
||||||
|
fromDisplayString('AA CC D- Ee[-] Gg PP spsp'),
|
||||||
|
fromDisplayString('Aa CC D- Ee Gg pp spsp'),
|
||||||
|
)
|
||||||
|
const names = r.byFarbschlag.map((f) => f.farbschlag)
|
||||||
|
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||||
|
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
|
||||||
|
expect(r.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a fox parent (ee) × Ee never yields impossible non-fox-only morphs from a wildcard', () => {
|
||||||
|
// Geely (ee gg, with A?/C?/D? wildcards) × Gaida: no Unbekannt, no Schimmel.
|
||||||
|
const r = breed(
|
||||||
|
fromDisplayString('A- C- D- ee[-] gg Pp spsp'),
|
||||||
|
fromDisplayString('A- CC D- Ee GG Pp spsp'),
|
||||||
|
)
|
||||||
|
const names = r.byFarbschlag.map((f) => f.farbschlag)
|
||||||
|
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||||
|
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||||
@@ -670,22 +726,23 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
it('#42: e[-]/e- standalone is INVALID (lone recessive fox + unknown) → throws', () => {
|
||||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
// Fox (e) is recessive; a single e with an unknown partner is genetically
|
||||||
expect(g.E).toEqual(['e', '?'])
|
// impossible. The breeder's rule: only "E-" and "ee[-]" exist, never "e-".
|
||||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
|
expect(() => fromDisplayString('aa CC DD e[-] GG PP spsp rere')).toThrow()
|
||||||
|
expect(() => fromDisplayString('aa CC DD e- GG PP spsp rere')).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
it('#42: ee[-] resolves to ee (Fuchs) — recessive phenotype implies homozygosity', () => {
|
||||||
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
|
// Real herdbook notation: ee[-] = visible fox. Since fox is recessive a
|
||||||
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
|
// visible fox MUST be ee, so the unknown second allele resolves to e.
|
||||||
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||||||
const g = fromDisplayString(input)
|
const g = fromDisplayString(input)
|
||||||
expect(g.E).toEqual(['e', '?'])
|
expect(g.E).toEqual(['e', 'e'])
|
||||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||||
expect(g.D).toEqual(['D', 'd'])
|
expect(g.D).toEqual(['D', 'd'])
|
||||||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
|
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd ee Gg Pp Spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
|
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
|
||||||
|
|||||||
@@ -243,10 +243,29 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||||
const agouti = resolvedPair(g, 'A').includes('A')
|
const agouti = resolvedPair(g, 'A').includes('A')
|
||||||
if (!agouti) {
|
if (!agouti) {
|
||||||
const [g1, g2] = resolvedPair(g, 'G')
|
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
|
||||||
const grey = g1 === 'g' && g2 === 'g'
|
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
|
||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
|
||||||
|
// dd dilute or ee Fuchs) is named from the resolved base colour, so
|
||||||
|
// 'aa cchm dd ee gg' no longer collapses to Zobel.
|
||||||
|
const [d1, d2] = resolvedPair(g, 'D')
|
||||||
|
const wildD = d1 === 'D' && d2 === 'D'
|
||||||
|
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
|
||||||
|
if (wildD && fullExtension) {
|
||||||
|
const [g1, g2] = resolvedPair(g, 'G')
|
||||||
|
const grey = g1 === 'g' && g2 === 'g'
|
||||||
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
|
}
|
||||||
|
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
|
||||||
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
|
if (!base) return null
|
||||||
|
const DILUTE = 'Dilute '
|
||||||
|
if (base.startsWith(DILUTE)) {
|
||||||
|
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||||
|
}
|
||||||
|
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||||
}
|
}
|
||||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
|
|||||||
@@ -180,17 +180,27 @@ function normalizeToken(tok: string): string | null {
|
|||||||
let t = tok
|
let t = tok
|
||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||||
|
// GEN-3b (#32/#34): Underwhite == G locus. Strip the breeder's "[d]" (dense
|
||||||
|
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
|
||||||
|
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
|
||||||
|
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
|
||||||
|
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
|
||||||
|
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
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\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||||
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
// #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
|
||||||
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
|
||||||
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
|
||||||
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
|
||||||
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
// fox allele with an unknown partner) is genetically impossible and is left to
|
||||||
// leaving the leading allele intact for splitToken).
|
// be rejected by splitToken (invalid → genotypeInvalid path).
|
||||||
|
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
||||||
|
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
|
||||||
|
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
|
||||||
|
// strips only the bracket part; the leading allele stays.
|
||||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
||||||
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
||||||
@@ -256,6 +266,13 @@ export function fromDisplayString(input: string): Genotype {
|
|||||||
const locus = ALLELE_TO_LOCUS[refAllele]
|
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||||
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
||||||
|
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
|
||||||
|
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
|
||||||
|
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
|
||||||
|
// the genotypeInvalid message instead of silently mis-computing the colour.
|
||||||
|
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
|
||||||
|
throw new Error(`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`)
|
||||||
|
}
|
||||||
acc[locus] = canonicalPair(locus, a, b)
|
acc[locus] = canonicalPair(locus, a, b)
|
||||||
}
|
}
|
||||||
const base = wildType()
|
const base = wildType()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* before combining, so a parent known only by phenotype can still be paired.
|
* before combining, so a parent known only by phenotype can still be paired.
|
||||||
*/
|
*/
|
||||||
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
||||||
import { LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
||||||
import {
|
import {
|
||||||
canonicalPair,
|
canonicalPair,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
@@ -20,6 +20,34 @@ import {
|
|||||||
type Genotype,
|
type Genotype,
|
||||||
} from './genotype'
|
} from './genotype'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #37/#39/#40/#41: which concrete alleles an UNKNOWN partner allele may actually be,
|
||||||
|
* given the KNOWN allele it is paired with at this locus.
|
||||||
|
*
|
||||||
|
* A hidden allele is constrained by the recorded (visible) one:
|
||||||
|
* 1. It can NEVER be more dominant than the known allele — otherwise the animal's
|
||||||
|
* phenotype would be different from what the breeder recorded. So the unknown
|
||||||
|
* only ranges over alleles with dominance rank >= rank(known) (equal or more
|
||||||
|
* recessive). This kills impossible more-dominant offspring morphs.
|
||||||
|
* 2. It can never be an allele that is VISIBLE in the heterozygote, unless the
|
||||||
|
* animal already expresses it. At the E locus 'ef' (Schimmel/roan) shows even
|
||||||
|
* heterozygously, so a non-Schimmel animal (known E or e) cannot secretly carry
|
||||||
|
* 'ef'. Excluding it removes the phantom Schimmel/efef predictions (#41).
|
||||||
|
*
|
||||||
|
* When BOTH alleles are unknown the locus is genuinely unconstrained → full set.
|
||||||
|
*/
|
||||||
|
function unknownPartnerOptions(locus: LocusKey, known: string): readonly string[] {
|
||||||
|
const alleles = LOCI[locus].alleles
|
||||||
|
if (known === WILDCARD) return alleles // fully unknown locus: any allele
|
||||||
|
const knownRank = dominanceRank(locus, known)
|
||||||
|
return alleles.filter((a) => {
|
||||||
|
if (dominanceRank(locus, a) < knownRank) return false // can't outrank the visible allele
|
||||||
|
// E-locus 'ef' is visible in het: only possible if the animal is itself Schimmel.
|
||||||
|
if (locus === 'E' && a === 'ef' && known !== 'ef') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** A probability distribution over outcomes of type T (keyed by a string). */
|
/** A probability distribution over outcomes of type T (keyed by a string). */
|
||||||
export interface DistEntry<T> {
|
export interface DistEntry<T> {
|
||||||
readonly value: T
|
readonly value: T
|
||||||
@@ -32,12 +60,18 @@ function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fra
|
|||||||
const addWeight = (allele: string, w: Fraction) => {
|
const addWeight = (allele: string, w: Fraction) => {
|
||||||
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
||||||
}
|
}
|
||||||
const alleles = LOCI[locus].alleles
|
// The "other" allele of the pair tells us what an unknown is allowed to be:
|
||||||
for (const a of pair) {
|
// an unknown partner is constrained by the known visible allele (see
|
||||||
|
// unknownPartnerOptions), not blown up uniformly over every allele.
|
||||||
|
const [a0, a1] = pair
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const a = pair[i]
|
||||||
if (a === WILDCARD) {
|
if (a === WILDCARD) {
|
||||||
// Unknown allele: uniform over the locus set, each contributing 1/2 of the gamete.
|
const known = i === 0 ? a1 : a0
|
||||||
const share = frac(1, 2 * alleles.length)
|
const options = unknownPartnerOptions(locus, known)
|
||||||
for (const concrete of alleles) addWeight(concrete, share)
|
// Unknown allele contributes 1/2 of the gamete, split over its possible values.
|
||||||
|
const share = frac(1, 2 * options.length)
|
||||||
|
for (const concrete of options) addWeight(concrete, share)
|
||||||
} else {
|
} else {
|
||||||
addWeight(a, frac(1, 2))
|
addWeight(a, frac(1, 2))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,15 @@ def parse(raw):
|
|||||||
for locus in LOCI:
|
for locus in LOCI:
|
||||||
pat = _LOCUS_TOKEN.get(locus)
|
pat = _LOCUS_TOKEN.get(locus)
|
||||||
if pat and pat.match(t):
|
if pat and pat.match(t):
|
||||||
mapped.setdefault(locus, _alleles_for(locus, t)) # first occurrence wins
|
alleles = _alleles_for(locus, t)
|
||||||
|
# #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
|
||||||
|
# homozygous "ee" (written "ee[-]", which already parses to [e, e]).
|
||||||
|
# A lone recessive "e" with an unknown partner ("e-"/"e[-]" -> [e, ?])
|
||||||
|
# is genetically impossible; mirror the frontend by REJECTING it
|
||||||
|
# (leave it unmapped/invalid) instead of mapping a wrong colour.
|
||||||
|
if locus == "E" and alleles in (["e", "?"], ["?", "e"]):
|
||||||
|
break # invalid -> falls through to unmappedTokens below
|
||||||
|
mapped.setdefault(locus, alleles) # first occurrence wins
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if matched:
|
if matched:
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ check("DP -> tag", g.parse("[DP]")["tags"] == ["DP"])
|
|||||||
check("tag not in genotype loci", g.parse("WFNZ")["mapped8locus"] == {})
|
check("tag not in genotype loci", g.parse("WFNZ")["mapped8locus"] == {})
|
||||||
check("tag not in unmapped", g.parse("aa WFNZ")["unmappedTokens"] == [])
|
check("tag not in unmapped", g.parse("aa WFNZ")["unmappedTokens"] == [])
|
||||||
|
|
||||||
|
# --- #32/#34: dense-underwhite [d] annotation stripped, maps to G locus (never uw) ---
|
||||||
|
r = g.parse("Uwuw[d]")
|
||||||
|
check("Uwuw[d] -> Gg (dense underwhite, mixed case)", r["mapped8locus"].get("G") == ["G", "g"])
|
||||||
|
check("Uwuw[d] leaves nothing unmapped (no stray uw token)", r["unmappedTokens"] == [])
|
||||||
|
r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp")
|
||||||
|
check("Vance genotype: G locus = Gg (display via G, not uw)", r["mapped8locus"].get("G") == ["G", "g"])
|
||||||
|
check("Vance genotype: E locus = ee (Fuchs)", r["mapped8locus"].get("E") == ["e", "e"])
|
||||||
|
check("Vance genotype: no unmapped tokens", r["unmappedTokens"] == [])
|
||||||
|
|
||||||
|
# --- #42: E-locus e-dash semantics ---
|
||||||
|
# ee[-] = visible Fuchs -> ee (recessive phenotype implies homozygosity)
|
||||||
|
check("ee[-] -> ee (visible Fuchs)", g.parse("ee[-]")["mapped8locus"].get("E") == ["e", "e"])
|
||||||
|
check("E- stays [E, ?] (valid unknown partner)", g.parse("E-")["mapped8locus"].get("E") == ["E", "?"])
|
||||||
|
# bare e- / e[-] is genetically impossible -> invalid (not mapped, surfaced as unmapped)
|
||||||
|
check("e- invalid -> not mapped at E", g.parse("e-")["mapped8locus"].get("E") is None)
|
||||||
|
check("e- invalid -> in unmappedTokens", "e-" in g.parse("e-")["unmappedTokens"])
|
||||||
|
check("e[-] invalid -> not mapped at E", g.parse("e[-]")["mapped8locus"].get("E") is None)
|
||||||
|
r = g.parse("Aa CC D- ee[-] Gg Pp spsp")
|
||||||
|
check("Algierfuchs genotype: E = ee (Fuchs)", r["mapped8locus"].get("E") == ["e", "e"])
|
||||||
|
check("Algierfuchs genotype: no unmapped tokens", r["unmappedTokens"] == [])
|
||||||
|
|
||||||
# --- looks_like_genotype recognizes Uw-bearing cells ---
|
# --- looks_like_genotype recognizes Uw-bearing cells ---
|
||||||
check("looks_like_genotype sees Uw as G",
|
check("looks_like_genotype sees Uw as G",
|
||||||
g.looks_like_genotype("aa Cc Uwuw") is True)
|
g.looks_like_genotype("aa Cc Uwuw") is True)
|
||||||
|
|||||||
Reference in New Issue
Block a user