Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/tw/singleCellCellType.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RawSingleCellCellTypeTerm, SingleCellCellTypeTerm, TermGroupSetting, TermValues } from '#types'
import { TermTypes } from '#shared/terms.js'

const termType = 'singleCellCellType'
const termType = TermTypes.SINGLECELL_CELL_TYPE
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TermTypes does not define SINGLECELL_CELL_TYPE (the existing constant is TermTypes.SINGLECELL_CELLTYPE: 'singleCellCellType'). As written, termType will be undefined, which breaks validation/type checks and can let invalid terms pass silently. Update this to use the correct TermTypes key so termType resolves to 'singleCellCellType'.

Suggested change
const termType = TermTypes.SINGLECELL_CELL_TYPE
const termType = TermTypes.SINGLECELL_CELLTYPE

Copilot uses AI. Check for mistakes.

export class SingleCellCellTypeBase {
type = termType
Expand Down
5 changes: 3 additions & 2 deletions client/tw/singleCellGeneExpression.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RawSingleCellGeneExpTerm, SingleCellGeneExpressionTerm } from '#types'
import type { TwOpts } from './TwBase.ts'
import { TermTypes } from '#shared/terms.js'

const termType = 'singleCellGeneExpression'
const termType = TermTypes.SINGLECELL_GENE_EXPRESSION

export class SingleCellGeneExpressionBase {
type = termType
Expand All @@ -19,7 +20,7 @@ export class SingleCellGeneExpressionBase {
}

static validate(term: RawSingleCellGeneExpTerm) {
if (typeof term !== 'object') throw new Error('term is not an object')
if (!term || typeof term !== 'object') throw new Error('term is not an object')
if (term.type != termType) throw new Error(`incorrect term.type='${term?.type}', expecting '${termType}'`)
if (!term.gene && !term.name) throw new Error('no gene or name present')
if (!term.gene || typeof term.gene != 'string') throw new Error(`${termType} term.gene must be non-empty string`)
Expand Down
102 changes: 102 additions & 0 deletions client/tw/test/singleCellCellType.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import tape from 'tape'
import { SingleCellCellTypeBase } from '../singleCellCellType.ts'
import { TermTypes } from '#shared/terms.js'

/*************************
reusable helper functions
**************************/

function getValidRawTerm(overrides: any = {}) {
return {
type: TermTypes.SINGLECELL_CELL_TYPE,
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are using TermTypes.SINGLECELL_CELL_TYPE, but TermTypes defines SINGLECELL_CELLTYPE (no _TYPE). Using the non-existent key makes term.type undefined, which can cause these tests to pass even while the implementation is broken. Replace all occurrences with TermTypes.SINGLECELL_CELLTYPE and consider asserting the expected type string in the thrown error message to prevent this class of regression.

Suggested change
type: TermTypes.SINGLECELL_CELL_TYPE,
type: TermTypes.SINGLECELL_CELLTYPE,

Copilot uses AI. Check for mistakes.
sample: { sID: 'S1', eID: 'E1' },
plot: 'My plot',
...overrides
}
}

/**************
test sections
***************/

tape('\n', function (test) {
test.comment('-***- tw/singleCellCellType -***-')
test.end()
})

tape('validate() should throw on invalid terms', test => {
test.throws(
() => SingleCellCellTypeBase.validate(null as any),
/term is not an object/,
'Should throw when term is not an object'
)

test.throws(
() => SingleCellCellTypeBase.validate({ type: 'categorical' } as any),
/incorrect term.type='categorical'/,
'Should throw when term.type is incorrect'
)

test.doesNotThrow(
() => SingleCellCellTypeBase.validate(getValidRawTerm() as any),
'Should accept valid singleCellCellType term'
)

test.end()
})

tape('fill() should add default groupsetting and values when missing', test => {
const term = getValidRawTerm({ groupsetting: undefined, values: undefined })
SingleCellCellTypeBase.fill(term as any)

test.deepEqual(term.groupsetting, { disabled: false }, 'Should set default groupsetting')
test.deepEqual(term.values, {}, 'Should set default values object')
test.end()
})

tape('fill() should preserve existing groupsetting and values', test => {
const term = getValidRawTerm({
groupsetting: { disabled: true, lst: [{ name: 'g1', groups: [] }] },
values: { A: { key: 'A', label: 'A' } }
})
SingleCellCellTypeBase.fill(term as any)

test.equal(term.groupsetting.disabled, true, 'Should preserve existing groupsetting.disabled')
test.deepEqual(term.values, { A: { key: 'A', label: 'A' } }, 'Should preserve existing values')
test.end()
})

tape('fill() should no-op for class instances', test => {
const instance = new SingleCellCellTypeBase(getValidRawTerm() as any)
test.doesNotThrow(
() => SingleCellCellTypeBase.fill(instance as any),
'Should not throw when fill is called on instance'
)
test.end()
})

tape('constructor should set explicit term fields', test => {
const term = getValidRawTerm({
groupsetting: { disabled: true },
values: { B: { key: 'B', label: 'B' } }
})
const x = new SingleCellCellTypeBase(term as any)

test.equal(x.type, TermTypes.SINGLECELL_CELL_TYPE, 'Should set type')
test.deepEqual(x.sample, { sID: 'S1', eID: 'E1' }, 'Should set sample')
test.equal(x.plot, 'My plot', 'Should set plot')
test.deepEqual(x.groupsetting, { disabled: true }, 'Should preserve provided groupsetting')
test.deepEqual(x.values, { B: { key: 'B', label: 'B' } }, 'Should preserve provided values')
test.end()
})

tape('constructor should fallback to default fields when optional fields missing', test => {
const term = getValidRawTerm({ sample: undefined, plot: undefined, groupsetting: undefined, values: undefined })
const x = new SingleCellCellTypeBase(term as any)

test.deepEqual(x.sample, {}, 'Should fallback sample to empty object')
test.equal(x.plot, '', 'Should fallback plot to empty string')
test.deepEqual(x.groupsetting, { disabled: false }, 'Should fallback groupsetting')
test.deepEqual(x.values, {}, 'Should fallback values')
test.end()
})
153 changes: 153 additions & 0 deletions client/tw/test/singleCellGeneExpression.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import tape from 'tape'
import { SingleCellGeneExpressionBase, getSCGEunit } from '../singleCellGeneExpression.ts'
import { TermTypes } from '#shared/terms.js'

/*************************
reusable helper functions
**************************/

const mockVocabApi = {
termdbConfig: {
queries: {
singleCell: {
geneExpression: { unit: 'log2 CPM' }
}
}
}
}

const mockVocabApiNoUnit = {
termdbConfig: {
queries: {
singleCell: {
geneExpression: {}
}
}
}
}

function getValidRawTerm(overrides: any = {}) {
return {
type: TermTypes.SINGLECELL_GENE_EXPRESSION,
gene: 'TP53',
sample: 'Tumor cells',
...overrides
}
}

/**************
test sections
***************/

tape('\n', function (test) {
test.comment('-***- tw/singleCellGeneExpression -***-')
test.end()
})

tape('getSCGEunit() should return configured unit and fallback default unit', test => {
test.equal(getSCGEunit(mockVocabApi as any), 'log2 CPM', 'Should return configured unit from termdbConfig')
test.equal(getSCGEunit(mockVocabApiNoUnit as any), 'Gene Expression', 'Should fallback to default unit')
test.end()
})

tape('validate() should throw on invalid terms', test => {
test.throws(
() => SingleCellGeneExpressionBase.validate(null as any),
/term is not an object/,
'Should throw when term is not an object'
)

test.throws(
() => SingleCellGeneExpressionBase.validate({ type: TermTypes.GENE_EXPRESSION } as any),
/incorrect term.type='geneExpression'/,
'Should throw when term.type is incorrect'
)

test.throws(
() =>
SingleCellGeneExpressionBase.validate({
type: TermTypes.SINGLECELL_GENE_EXPRESSION,
sample: 'Tumor cells'
} as any),
/no gene or name present/,
'Should throw when both gene and name are missing'
)

test.throws(
() =>
SingleCellGeneExpressionBase.validate({
type: TermTypes.SINGLECELL_GENE_EXPRESSION,
gene: 123,
name: 'Bad gene',
sample: 'Tumor cells'
} as any),
/singleCellGeneExpression term.gene must be non-empty string/,
'Should throw when gene is not a non-empty string'
)

test.throws(
() =>
SingleCellGeneExpressionBase.validate({
type: TermTypes.SINGLECELL_GENE_EXPRESSION,
gene: 'TP53'
} as any),
/missing sample name/,
'Should throw when sample is missing'
)

test.end()
})

tape('fill() should populate missing name and unit', test => {
const term = getValidRawTerm({ name: undefined, unit: undefined })
SingleCellGeneExpressionBase.fill(term as any, { vocabApi: mockVocabApi as any } as any)

test.equal(term.unit, 'log2 CPM', 'Should set unit from vocabApi')
test.equal(term.name, 'TP53 log2 CPM', 'Should set generated name from gene and unit')
test.end()
})

tape('fill() should not overwrite existing name', test => {
const term = getValidRawTerm({ name: 'Custom label', unit: undefined })
SingleCellGeneExpressionBase.fill(term as any, { vocabApi: mockVocabApi as any } as any)

test.equal(term.name, 'Custom label', 'Should preserve existing name')
test.equal(term.unit, undefined, 'Should not force unit when name already exists')
test.end()
})

tape('fill() should no-op for class instances', test => {
const instance = new SingleCellGeneExpressionBase(getValidRawTerm(), { vocabApi: mockVocabApi as any } as any)
test.doesNotThrow(
() => SingleCellGeneExpressionBase.fill(instance as any, { vocabApi: mockVocabApi as any } as any),
'Should not throw when fill is called on instance'
)
test.end()
})

tape('constructor should set fields and use configured unit', test => {
const term = getValidRawTerm({ unit: undefined })
const x = new SingleCellGeneExpressionBase(term as any, { vocabApi: mockVocabApi as any } as any)

test.equal(x.type, TermTypes.SINGLECELL_GENE_EXPRESSION, 'Should set type')
test.equal(x.gene, 'TP53', 'Should set gene')
test.equal(x.sample, 'Tumor cells', 'Should set sample')
test.equal(x.unit, 'log2 CPM', 'Should set configured unit when term.unit is missing')
test.end()
})

tape('constructor should use default unit when config unit is missing', test => {
const term = getValidRawTerm({ unit: undefined })
const x = new SingleCellGeneExpressionBase(term as any, { vocabApi: mockVocabApiNoUnit as any } as any)

test.equal(x.unit, 'Gene Expression', 'Should fallback to default unit')
test.end()
})

tape('constructor should preserve explicit term.unit', test => {
const term = getValidRawTerm({ unit: 'Custom Unit' })
const x = new SingleCellGeneExpressionBase(term as any, { vocabApi: mockVocabApi as any } as any)

test.equal(x.unit, 'Custom Unit', 'Should preserve explicit term.unit')
test.end()
})
6 changes: 1 addition & 5 deletions release.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,2 @@
Features
- Expanded integration tests for TermTypeSearch. New unit tests available for the recently added Single-Cell Cell Type and Single-Cell Gene Expression handlers as well as previously implemented Gene Expression, SNP, ssGSEA, and Term Collection handlers.

Fixes
- Simplified logic for custom termType2terms vocabulary. Resolved issue with elements from other handlers lingering on tab change and the pills for the singleCellCellType not appearing after clicking away from the Single-Cell Cell Type tab.

- New unit tests for recently created termwrappers, singleCellCellType and singleCellGeneExpression.
Loading