Skip to content
Open
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
153 changes: 153 additions & 0 deletions cypress/e2e/journeys/flows/node-color-assignment-uploaded.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/// <reference types="cypress" />

import { getProfile } from '../datasets/profile';
import {
launchProfileToTwoD,
openGlobalStylingTab,
saveSessionFromFileMenu,
} from '../../../support/journey-helpers';

type WinWithMT = Window & {
commonService?: any;
cytoscapeInstance?: any;
};

const fixturePath = (name: string): string => `${Cypress.config('fixturesFolder')}/${name}`;
const normalizeColor = (value: string): string => String(value || '').replace(/\s+/g, '').toLowerCase();

const selectNodeColorField = (label: string): void => {
cy.get('#node-color-variable').click({ force: true });
cy.contains('li[role="option"]', label, { timeout: 15000 }).click({ force: true });
};

const uploadColorAssignments = (fixture: string): void => {
cy.get('[data-testid="node-color-assignment-file"]')
.selectFile(fixturePath(fixture), { force: true });
};

const assertRenderedNodeColor = (field: string, value: string, expectedColor: string): void => {
cy.window().should((rawWindow: unknown) => {
const win = rawWindow as WinWithMT;
const nodes = win.cytoscapeInstance
?.nodes(':visible')
.filter((node: any) => node.children().length === 0 && node.data(field) === value);

expect(nodes?.length, `${field}=${value} nodes`).to.be.greaterThan(0);
nodes.forEach((node: any) => {
expect(normalizeColor(node.style('background-color'))).to.equal(normalizeColor(expectedColor));
});
});
};

describe('Journey Flow - Node color assignment file import', () => {
const profile = getProfile('color-by-uploaded-categorical');

afterEach(() => {
cy.get('body').then(($body) => {
if ($body.find('.p-dialog:visible .p-dialog-title:contains("Global Settings")').length) {
cy.closeGlobalSettings();
}
});
});

it('applies partial iTOL assignments, refreshes colors, and saves unused mappings', () => {
const sessionFileBase = `color_assignments_${Date.now()}`;
const sessionFilePath = `${Cypress.config('downloadsFolder')}/${sessionFileBase}.microbetrace`;

launchProfileToTwoD(profile);
openGlobalStylingTab();
cy.get('[data-testid="node-color-assignment-file"]').should('not.exist');
selectNodeColorField('Lineage');
cy.get('[data-testid="node-color-assignment-file"]').should('exist');
cy.get('#node-color-assignment-row')
.should('contain.text', 'Apply Color Assignment File')
.and('contain.text', 'Choose File');
uploadColorAssignments('Cypress_Color_Assignments_iTOL.txt');

cy.get('[data-testid="node-color-assignment-status"]', { timeout: 15000 })
.should('contain.text', 'Applied 3 color assignments')
.and('contain.text', '1 retained for future data');

cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?.Lineage;
expect(assignments?.['B.1.617.2']).to.equal('#123456');
expect(assignments?.['B.1.617.1']).to.equal('#abcdef');
expect(assignments?.['FUTURE.LINEAGE']).to.equal('#fedcba');
});
cy.get('#key-tables-node-table td[data-value="B.1.617.2"]')
.closest('tr')
.find('input[type="color"]')
.should('have.value', '#123456');
assertRenderedNodeColor('Lineage', 'B.1.617.2', 'rgb(18,52,86)');

selectNodeColorField('Profession');
selectNodeColorField('Lineage');
assertRenderedNodeColor('Lineage', 'B.1.617.2', 'rgb(18,52,86)');

cy.closeGlobalSettings();
saveSessionFromFileMenu(sessionFileBase);
cy.readFile(sessionFilePath, 'utf8', { timeout: 30000 }).then((contents) => {
const saved = JSON.parse(contents);
expect(saved.session.style.nodeColorAssignments.Lineage['B.1.617.2']).to.equal('#123456');
expect(saved.session.style.nodeColorAssignments.Lineage['FUTURE.LINEAGE']).to.equal('#fedcba');
});
});

it('applies an iTOL label mismatch without confirmation and accepts a selected-field table', () => {
launchProfileToTwoD(profile);
openGlobalStylingTab();
selectNodeColorField('Lineage');

uploadColorAssignments('Cypress_Color_Assignments_Mismatch.txt');
cy.get('.p-confirmdialog').should('not.exist');
cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?.Lineage;
expect(assignments?.['B.1.617.2']).to.equal('#cc9999');
});

uploadColorAssignments('Cypress_Color_Assignments_Table.csv');
cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?.Lineage;
expect(assignments?.['B.1.617.2']).to.equal('#654321');
});
assertRenderedNodeColor('Lineage', 'B.1.617.2', 'rgb(101,67,33)');
});

it('matches the fixed first iTOL column to the selected node ID field', () => {
launchProfileToTwoD(profile);
openGlobalStylingTab();
selectNodeColorField('Id');

uploadColorAssignments('Cypress_Color_Assignments_ID_iTOL.txt');
cy.get('[data-testid="node-color-assignment-status"]')
.should('contain.text', '1 matched current value')
.and('contain.text', '0 retained for future data');
cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?._id;
expect(assignments?.['375596']).to.equal('#00daff');
expect(assignments?.['Example isolate']).to.not.exist;
});
assertRenderedNodeColor('_id', '375596', 'rgb(0,218,255)');
});

it('rejects conflicting rows without changing existing assignments', () => {
launchProfileToTwoD(profile);
openGlobalStylingTab();
selectNodeColorField('Lineage');
uploadColorAssignments('Cypress_Color_Assignments_Table.csv');
cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?.Lineage;
expect(assignments?.['B.1.617.2']).to.equal('#654321');
});

uploadColorAssignments('Cypress_Color_Assignments_Invalid.csv');
cy.get('[data-testid="node-color-assignment-status"]')
.should('have.attr', 'role', 'alert')
.and('contain.text', 'assigned both');
cy.window().should((rawWindow: unknown) => {
const assignments = (rawWindow as WinWithMT).commonService?.session?.style?.nodeColorAssignments?.Lineage;
expect(assignments?.['B.1.617.2']).to.equal('#654321');
});
assertRenderedNodeColor('Lineage', 'B.1.617.2', 'rgb(101,67,33)');
});
});
5 changes: 5 additions & 0 deletions cypress/fixtures/Cypress_Color_Assignments_ID_iTOL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DATASET_COLORSTRIP
SEPARATOR TAB
DATASET_LABEL isolate
DATA
375596 #00daff Example isolate
3 changes: 3 additions & 0 deletions cypress/fixtures/Cypress_Color_Assignments_Invalid.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
value,color
B.1.617.2,#ffffff
B.1.617.2,#000000
6 changes: 6 additions & 0 deletions cypress/fixtures/Cypress_Color_Assignments_Mismatch.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
DATASET_COLORSTRIP
SEPARATOR COMMA
DATASET_LABEL,MLST
COLOR,#00ff00
DATA
SYNTH-1,#cc9999,B.1.617.2
3 changes: 3 additions & 0 deletions cypress/fixtures/Cypress_Color_Assignments_Table.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Lineage,color,id
B.1.617.2,#654321,SYNTH-1
B.1.1.7,#112233,SYNTH-2
9 changes: 9 additions & 0 deletions cypress/fixtures/Cypress_Color_Assignments_iTOL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
DATASET_COLORSTRIP
SEPARATOR SPACE
DATASET_LABEL Lineage
# Values not present in the current dataset should remain available for future data.
COLOR #00ff00
DATA
SYNTH-1 #123456 B.1.617.2
SYNTH-2 #abcdef B.1.617.1
SYNTH-3 #fedcba FUTURE.LINEAGE
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ This folder contains project documentation, test planning notes, QA trackers, pr
- `manuals/`: user-facing manual PDFs and Word documents.
- `onboarding/`: onboarding artifacts and static reference pages.
- `contributor/`: contributor and agent-facing project guidance.
- `examples/`: user-facing example input files, including node color assignments.

## Entry Points

- `../testing-plan.md`: active testing strategy and Cypress coverage priorities.
- `testing/README.md`: testing documentation map.
- `performance/README.md`: performance documentation map.
- `examples/color-assignments/README.md`: supported node color-assignment formats and ready-to-import examples.
130 changes: 130 additions & 0 deletions src/app/contactTraceCommonServices/color-assignment.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
ColorAssignmentService,
NodeColorAssignmentParseError
} from './color-assignment.service';

describe('ColorAssignmentService', () => {
let service: ColorAssignmentService;

beforeEach(() => {
service = new ColorAssignmentService();
});

it('parses the synthetic iTOL color strip and collapses identical duplicates', () => {
const result = service.parse(`DATASET_COLORSTRIP
SEPARATOR SPACE
DATASET_LABEL MLST
#LEGEND_TITLE Example
# FIELD_COLORS #ffffff
COLOR #00ff00
# FIELD_SHAPES 2
BORDER_WIDTH 1
BORDER_COLOR #ffffff
STRIP_WIDTH 100
MARGIN 0
# SHOW_INTERNAL 0
# LABEL_ROTATION 0
# STRAIGHT_LABELS 0
# ALIGN_TO_TREE 0
SIZE_FACTOR 1.0
# SYMBOL_SPACING 10
DATA
GCWGS-1 #ffffff 967
GCWGS-2 #f8da6a 84
GCWGS-3 #CC9999 8
GCWGS-4 #bababa 902
GCWGS-5 #cbf7cb 189
GCWGS-6 #CC9999 8`, 'MLST');

expect(result.format).toBe('itol-colorstrip');
expect(result.datasetLabel).toBe('MLST');
expect(result.rowCount).toBe(6);
expect(result.duplicateCount).toBe(1);
expect(result.uniqueAssignmentCount).toBe(5);
expect(result.assignments['967']).toBe('#ffffff');
expect(result.assignments['84']).toBe('#f8da6a');
expect(result.assignments['8']).toBe('#cc9999');
expect(result.assignments['902']).toBe('#bababa');
expect(result.assignments['189']).toBe('#cbf7cb');
});

it('supports TAB and COMMA iTOL separators', () => {
const tabResult = service.parse(
'DATASET_COLORSTRIP\nSEPARATOR TAB\nDATASET_LABEL\tMLST\nDATA\nA\t#123\t1',
'MLST'
);
const commaResult = service.parse(
'DATASET_COLORSTRIP\nSEPARATOR COMMA\nDATASET_LABEL,MLST\nDATA\nA,#abcdef,1',
'MLST'
);

expect(tabResult.assignments['1']).toBe('#112233');
expect(commaResult.assignments['1']).toBe('#abcdef');
});

it('uses the fixed first iTOL column as the assignment key for node ID fields', () => {
const result = service.parse(
'DATASET_COLORSTRIP\nSEPARATOR TAB\nDATASET_LABEL\tisolate\nDATA\n46200\t#00daff\tNCTCtest\n46267\t#ffb700\t2023HQ-00151',
'_id',
[{ _id: '46200' }, { _id: '46267' }]
);

expect(result.assignments['46200']).toBe('#00daff');
expect(result.assignments['46267']).toBe('#ffb700');
expect(result.assignments['NCTCtest']).toBeUndefined();
expect(result.matchedSampleIdCount).toBe(2);
});

it('resolves matched iTOL sample IDs through the selected field and retains unmatched row values', () => {
const result = service.parse(
'DATASET_COLORSTRIP\nSEPARATOR SPACE\nDATASET_LABEL MLST\nDATA\nGCWGS-1 #ffffff 967\nGCWGS-2 #f8da6a 84',
'MLST',
[{ _id: 'GCWGS-1', MLST: '967' }]
);

expect(result.assignments['967']).toBe('#ffffff');
expect(result.assignments['84']).toBe('#f8da6a');
expect(result.matchedSampleIdCount).toBe(1);
});

it('uses the first table column as the matching value and the named color column as its color', () => {
const genericResult = service.parse('id,value,color\nA,8,#123\nB,84,#abcdef', 'MLST');
const fieldResult = service.parse('MLST\textra\tcolor\n8\tignored\t#CC9999', 'MLST');

expect(genericResult.format).toBe('delimited-table');
expect(genericResult.assignments['A']).toBe('#112233');
expect(genericResult.assignments['B']).toBe('#abcdef');
expect(genericResult.assignments['8']).toBeUndefined();
expect(fieldResult.assignments['8']).toBe('#cc9999');
});

it('parses the supplied ID-first table layout for the selected internal _id field', () => {
const result = service.parse(
'id\tMLST\tcolor\tnotes\n' +
'SAMPLE-001\t8\t#cc9999\toptional columns are ignored\n' +
'SAMPLE-002\t84\t#f8da6a\toptional columns are ignored\n' +
'SAMPLE-003\t967\t#09f\toptional columns are ignored',
'_id'
);

expect(result.assignments['SAMPLE-001']).toBe('#cc9999');
expect(result.assignments['SAMPLE-002']).toBe('#f8da6a');
expect(result.assignments['SAMPLE-003']).toBe('#0099ff');
});

it('rejects conflicting duplicate assignments atomically', () => {
expect(() => service.parse('value,color\n8,#ffffff\n8,#000000', 'MLST'))
.toThrowError(NodeColorAssignmentParseError, /assigned both/);
});

it('rejects malformed rows, invalid colors, and unsupported files', () => {
expect(() => service.parse('DATASET_COLORSTRIP\nSEPARATOR SPACE\nDATA\nA #ffffff', 'MLST'))
.toThrowError(NodeColorAssignmentParseError, /sample ID, color, and variable value/);
expect(() => service.parse('value,color\n8,red', 'MLST'))
.toThrowError(NodeColorAssignmentParseError, /valid #RGB or #RRGGBB/);
expect(() => service.parse('one column only\n8', 'MLST'))
.toThrowError(NodeColorAssignmentParseError, /comma- or tab-delimited/);
expect(() => service.parse('color,value\n#ffffff,8', 'MLST'))
.toThrowError(NodeColorAssignmentParseError, /first column must contain matching values/);
});
});
Loading
Loading