@datagrok/sequence-translator 1.10.13 → 1.10.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/detectors.js +30 -2
  3. package/dist/455.js +1 -1
  4. package/dist/455.js.map +1 -1
  5. package/dist/package-test.js +1 -1
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -1
  8. package/dist/package.js.map +1 -1
  9. package/files/samples/sirna-demo.csv +38 -0
  10. package/files/tests/chem_enum_cores.csv +5 -0
  11. package/files/tests/chem_enum_rgroups.csv +5 -0
  12. package/package.json +2 -2
  13. package/src/apps/structure/view/ui.ts +1 -1
  14. package/src/apps/translator/view/ui.ts +1 -1
  15. package/src/oligo-renderer/canvas-renderer.ts +500 -0
  16. package/src/oligo-renderer/cell-renderer.ts +105 -0
  17. package/src/oligo-renderer/converters.ts +77 -0
  18. package/src/oligo-renderer/helm-parser.ts +154 -0
  19. package/src/oligo-renderer/legend-panel.ts +154 -0
  20. package/src/oligo-renderer/structures-panel.ts +96 -0
  21. package/src/oligo-renderer/tooltip.ts +223 -0
  22. package/src/oligo-renderer/types.ts +221 -0
  23. package/src/package-api.ts +43 -1
  24. package/src/package-test.ts +2 -0
  25. package/src/package.g.ts +56 -3
  26. package/src/package.ts +92 -5
  27. package/src/polytool/const.ts +1 -1
  28. package/src/polytool/pt-chem-enum-dialog.ts +940 -0
  29. package/src/polytool/pt-chem-enum.ts +553 -0
  30. package/src/polytool/pt-dialog.ts +4 -125
  31. package/src/polytool/pt-enumerate-seq-dialog.ts +3 -3
  32. package/src/tests/oligo-renderer-tests.ts +299 -0
  33. package/src/tests/polytool-enumerate-chem-tests.ts +408 -0
  34. package/test-console-output-1.log +303 -97
  35. package/test-record-1.mp4 +0 -0
  36. package/src/polytool/pt-enumeration-chem.ts +0 -100
@@ -19,12 +19,10 @@ import {doPolyToolConvert} from './conversion/pt-conversion';
19
19
  import {getOverriddenLibrary} from './conversion/pt-synthetic';
20
20
  import {defaultErrorHandler} from '../utils/err-info';
21
21
  import {getLibrariesList} from './utils';
22
- import {getEnumerationChem, PT_CHEM_EXAMPLE} from './pt-enumeration-chem';
23
-
24
22
  import {
25
- PT_ERROR_DATAFRAME, PT_UI_ADD_HELM, PT_UI_DIALOG_CONVERSION, PT_UI_DIALOG_ENUMERATION,
23
+ PT_ERROR_DATAFRAME, PT_UI_ADD_HELM, PT_UI_DIALOG_CONVERSION,
26
24
  PT_UI_GET_HELM, PT_UI_LINEARIZE, PT_UI_LINEARIZE_TT,
27
- PT_UI_HIGHLIGHT_MONOMERS, PT_UI_RULES_USED, PT_UI_USE_CHIRALITY
25
+ PT_UI_HIGHLIGHT_MONOMERS, PT_UI_RULES_USED, PT_UI_USE_CHIRALITY,
28
26
  } from './const';
29
27
 
30
28
  import {_package} from '../package';
@@ -50,21 +48,6 @@ type PolyToolConvertSerialized = {
50
48
  rules: string[];
51
49
  };
52
50
 
53
- type PolyToolEnumerateChemSerialized = {
54
- mol: string;
55
- screenLibrary: string | null;
56
- }
57
-
58
- export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
59
- await _package.initPromise;
60
- try {
61
- const dialog = await getPolyToolEnumerationChemDialog(cell);
62
- dialog.show({resizable: true});
63
- } catch (_err: any) {
64
- grok.shell.warning('To run PolyTool Enumeration, sketch the molecule and specify the R group to vary');
65
- }
66
- }
67
-
68
51
  export async function polyToolConvertUI(): Promise<void> {
69
52
  await _package.initPromise;
70
53
  let dialog: DG.Dialog | null = null;
@@ -178,111 +161,6 @@ export async function getPolyToolConvertDialog(srcCol?: DG.Column): Promise<DG.D
178
161
  }
179
162
  }
180
163
 
181
- async function getPolyToolEnumerationChemDialog(cell?: DG.Cell): Promise<DG.Dialog> {
182
- const subs: Unsubscribable[] = [];
183
- const destroy = () => {
184
- for (const sub of subs) sub.unsubscribe();
185
- };
186
- try {
187
- const [libList, helmHelper] = await Promise.all([
188
- getLibrariesList(), getHelmHelper()]);
189
-
190
- const molStr = (cell && cell.rowIndex >= 0) ? cell.value : PT_CHEM_EXAMPLE;//cell ? cell.value : PT_CHEM_EXAMPLE;
191
- let molfileValue: string = await (async (): Promise<string> => {
192
- if (DG.chem.isMolBlock(molStr)) return molStr;
193
- return (await grok.functions.call('Chem:convertMolNotation', {
194
- molecule: molStr,
195
- sourceNotation: cell?.column.getTag(DG.TAGS.UNITS) ?? DG.chem.Notation.Unknown,
196
- targetNotation: DG.chem.Notation.MolBlock,
197
- }));
198
- })();
199
-
200
- const molInput = new DG.chem.Sketcher(DG.chem.SKETCHER_MODE.EXTERNAL);
201
- molInput.syncCurrentObject = false;
202
- // sketcher.setMolFile(col.tags[ALIGN_BY_SCAFFOLD_TAG]);
203
- molInput.onChanged.subscribe((_: any) => {
204
- molfileValue = molInput.getMolFile();
205
- });
206
- molInput.root.classList.add('ui-input-editor');
207
- molInput.root.style.marginTop = '3px';
208
- molInput.setMolFile(molfileValue);
209
-
210
- //const helmInput = helmHelper.createHelmInput('Macromolecule', {value: helmValue});
211
- const screenLibraryInput = ui.input.choice('Library to use', {
212
- value: libList.length ? libList[0] : null,
213
- items: libList,
214
- nullable: false,
215
- onValueChanged: () => {
216
- dialog.getButton('OK').disabled = screenLibraryInput.value === null;
217
- }
218
- });
219
-
220
- molInput.root.setAttribute('style', `min-width:250px!important;`);
221
- molInput.root.setAttribute('style', `max-width:250px!important;`);
222
- screenLibraryInput.input.setAttribute('style', `min-width:250px!important;`);
223
-
224
- const div = ui.div([
225
- molInput.root,
226
- screenLibraryInput.root
227
- ]);
228
-
229
- subs.push(grok.events.onCurrentCellChanged.subscribe(() => {
230
- const cell = grok.shell.tv.dataFrame.currentCell;
231
-
232
- if (cell.column.semType === DG.SEMTYPE.MOLECULE)
233
- molInput.setValue(cell.value);
234
- }));
235
-
236
- const exec = async (): Promise<void> => {
237
- try {
238
- const molString = molInput.getMolFile();
239
-
240
- if (molString === undefined || molString === '') {
241
- grok.shell.warning('PolyTool: no molecule was provided');
242
- } else if (!molString.includes('R#')) {
243
- grok.shell.warning('PolyTool: no R group was provided');
244
- } else {
245
- const molecules = await getEnumerationChem(molString, screenLibraryInput.value!);
246
- const molCol = DG.Column.fromStrings('Enumerated', molecules);
247
- const df = DG.DataFrame.fromColumns([molCol]);
248
- grok.shell.addTableView(df);
249
- }
250
- } catch (err: any) {
251
- defaultErrorHandler(err);
252
- }
253
- };
254
-
255
- // Displays the molecule from a current cell (monitors changes)
256
- const dialog = ui.dialog(PT_UI_DIALOG_ENUMERATION)
257
- .add(div)
258
- .onOK(() => {
259
- exec().finally(() => { destroy(); });
260
- })
261
- .onCancel(() => {
262
- destroy();
263
- });
264
- subs.push(dialog.onClose.subscribe(() => {
265
- destroy();
266
- }));
267
- dialog.history(
268
- /* getInput */ (): PolyToolEnumerateChemSerialized => {
269
- return {
270
- mol: molInput.getMolFile(),
271
- screenLibrary: screenLibraryInput.value,
272
- };
273
- },
274
- /* applyInput */ (x: PolyToolEnumerateChemSerialized): void => {
275
- molInput.setMolFile(x.mol);
276
- screenLibraryInput.value = x.screenLibrary;
277
- });
278
- dialog.getButton('OK').disabled = screenLibraryInput.value === null;
279
- return dialog;
280
- } catch (err: any) {
281
- destroy();
282
- throw err;
283
- }
284
- }
285
-
286
164
  /** Returns Helm and molfile columns. */
287
165
  export async function polyToolConvert(seqCol: DG.Column<string>,
288
166
  generateHelm: boolean, linearize: boolean, chiralityEngine: boolean, highlight: boolean, ruleFiles: string[]
@@ -352,7 +230,8 @@ function buildCyclizedMonomerHoverLink(
352
230
 
353
231
  const alphabet = seqSH.alphabet as ALPHABET;
354
232
  const polymerType = alphabet == ALPHABET.RNA || alphabet == ALPHABET.DNA ? PolymerTypes.RNA : PolymerTypes.PEPTIDE;
355
- const monomersDict = getMonomersDictFromLib([seqMList], polymerType, alphabet, monomerLib, rdKitModule);
233
+ const monomersDict = getMonomersDictFromLib([seqMList], [undefined], polymerType,
234
+ alphabet, monomerLib, rdKitModule);
356
235
  // Call seq-to-molfile worker core directly
357
236
  const molWM = monomerSeqToMolfile(seqMList, monomersDict, alphabet, polymerType);
358
237
  return molWM.monomers;
@@ -26,7 +26,7 @@ import {PolyToolPlaceholdersInput} from './pt-placeholders-input';
26
26
  import {showMonomerSelectionDialog} from '@datagrok-libraries/bio/src/utils/monomer-selection-dialog';
27
27
  import {defaultErrorHandler} from '../utils/err-info';
28
28
  import {PolyToolPlaceholdersBreadthInput} from './pt-placeholders-breadth-input';
29
- import {PT_ENUM_TYPE_TOOLTIPS, PT_UI_DIALOG_ENUMERATION, PT_UI_GET_HELM, PT_UI_HIGHLIGHT_MONOMERS, PT_UI_RULES_USED, PT_UI_USE_CHIRALITY} from './const';
29
+ import {PT_ENUM_TYPE_TOOLTIPS, PT_HELM_UI_DIALOG_ENUMERATION, PT_UI_GET_HELM, PT_UI_HIGHLIGHT_MONOMERS, PT_UI_RULES_USED, PT_UI_USE_CHIRALITY} from './const';
30
30
  import {PolyToolDataRole, PolyToolTags} from '../consts';
31
31
  import {RuleInputs, RULES_PATH, RULES_STORAGE_NAME} from './conversion/pt-rules';
32
32
  import {Chain} from './conversion/pt-chain';
@@ -668,7 +668,7 @@ async function getPolyToolEnumerateDialog(
668
668
  if (Object.keys(inputs.placeholders.placeholdersValue).length === 0 &&
669
669
  Object.keys(inputs.placeholdersBreadth.placeholdersBreadthValue).length === 0
670
670
  ) {
671
- grok.shell.warning(`${PT_UI_DIALOG_ENUMERATION}: placeholders are empty`);
671
+ grok.shell.warning(`${PT_HELM_UI_DIALOG_ENUMERATION}: placeholders are empty`);
672
672
  return;
673
673
  }
674
674
  await getHelmHelper(); // initializes JSDraw and org
@@ -727,7 +727,7 @@ async function getPolyToolEnumerateDialog(
727
727
 
728
728
  // === DIALOG CONSTRUCTION AND LAYOUT ===
729
729
  // Layout: macromolecule editor on top, two-column (placeholders | breadth), two-column (options | rules)
730
- const dialog = ui.dialog({title: PT_UI_DIALOG_ENUMERATION, showFooter: true})
730
+ const dialog = ui.dialog({title: PT_HELM_UI_DIALOG_ENUMERATION, showFooter: true})
731
731
  .add(inputs.macromolecule.root)
732
732
  .add(ui.divH([
733
733
  ui.divV([
@@ -0,0 +1,299 @@
1
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
2
+
3
+ import {parseHelmDuplex, looksLikeHelm} from '../oligo-renderer/helm-parser';
4
+ import {computeLayout, hitTest, drawDuplex} from '../oligo-renderer/canvas-renderer';
5
+ import {
6
+ resolveSugar, resolvePhosphate, resolveConjugate,
7
+ hashColor, SUGAR_MODS, PHOSPHATE_MODS, CONJUGATE_MODS,
8
+ } from '../oligo-renderer/types';
9
+
10
+ // HELMCore canonical: lowercase backbone (`r`, `m`, `d`, `lna`, `fl2r`, `p`, `sp`).
11
+ // Single-char sugars/phosphates are unbracketed; multi-char must be bracketed.
12
+ const SAMPLE_DUPLEX =
13
+ 'RNA1{m(G)[sp].m(A)[sp].[fl2r](C)p.[fl2r](U)p.r(G)p.r(A)p.r(A)p.r(U)p.r(A)p.r(U)p.r(A)p.r(A)p.r(A)p.' +
14
+ 'r(C)p.r(U)p.r(U)p.m(G)p.m(U)[sp].m(G)[sp].[L3]}|' +
15
+ 'RNA2{[fl2r](C)[sp].m(A)[sp].m(A)p.m(G)p.r(U)p.r(U)p.r(U)p.r(A)p.r(U)p.r(A)p.r(U)p.r(U)p.r(C)p.' +
16
+ 'r(A)p.r(U)p.m(C)p.m(A)[sp].m(G)[sp].r(U)}$$$$';
17
+
18
+ const SAMPLE_SS = 'RNA1{[lna](C)[sp].[lna](A)[sp].[lna](G)[sp].d(T)[sp].d(C)[sp].d(A)[sp]}$$$$';
19
+
20
+ // Legacy / Pistoia-style symbols that should still render via the alias map.
21
+ const LEGACY_DUPLEX =
22
+ 'RNA1{[mR](G)[sP].[mR](A)[sP].[fR](C)P.R(U)P}|RNA2{[mR](C)[sP].R(A)P.R(U)P.R(G)P}$$$$';
23
+
24
+ category('OligoRenderer: parser', () => {
25
+ test('looksLikeHelm — positive', async () => {
26
+ expect(looksLikeHelm(SAMPLE_DUPLEX), true);
27
+ expect(looksLikeHelm('RNA1{R(A)P}'), true);
28
+ expect(looksLikeHelm('PEPTIDE1{A.C.G}'), true);
29
+ });
30
+
31
+ test('looksLikeHelm — negative', async () => {
32
+ expect(looksLikeHelm(''), false);
33
+ expect(looksLikeHelm('AUCGUACGUAGCUAGCAU'), false);
34
+ expect(looksLikeHelm('Af Cf Gf Uf'), false);
35
+ });
36
+
37
+ test('parses two-strand duplex', async () => {
38
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
39
+ expect(m.sense.type, 'RNA');
40
+ expect(m.sense.monomers.length, 20); // 19 nucleotides + L3 conjugate
41
+ expect(m.antisense !== null, true);
42
+ expect(m.antisense!.monomers.length, 19);
43
+ });
44
+
45
+ test('parses single-strand', async () => {
46
+ const m = parseHelmDuplex(SAMPLE_SS);
47
+ expect(m.sense.monomers.length, 6);
48
+ expect(m.antisense, null);
49
+ // First nucleotide has LNA sugar, base C, PS phosphate
50
+ const first = m.sense.monomers[0];
51
+ expect(first.kind, 'nucleotide');
52
+ if (first.kind === 'nucleotide') {
53
+ expect(first.sugar, 'lna');
54
+ expect(first.base, 'C');
55
+ expect(first.phosphate, 'sp');
56
+ }
57
+ });
58
+
59
+ test('single-char unbracketed deoxyribose `d` parses correctly', async () => {
60
+ // Regression: previously `dR(T)` was emitted (invalid HELM); the parser
61
+ // would split `d` as the sugar and choke on the rest. With canonical `d(T)`,
62
+ // sugar=d, base=T, phosphate empty.
63
+ const m = parseHelmDuplex('RNA1{d(T)p.d(C)p.d(G)}$$$$');
64
+ expect(m.sense.monomers.length, 3);
65
+ const first = m.sense.monomers[0];
66
+ if (first.kind === 'nucleotide') {
67
+ expect(first.sugar, 'd');
68
+ expect(first.base, 'T');
69
+ expect(first.phosphate, 'p');
70
+ }
71
+ });
72
+
73
+ test('parses bracketed conjugate', async () => {
74
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
75
+ const last = m.sense.monomers[m.sense.monomers.length - 1];
76
+ expect(last.kind, 'conjugate');
77
+ if (last.kind === 'conjugate') expect(last.symbol, 'L3');
78
+ });
79
+
80
+ test('handles empty / malformed without throwing', async () => {
81
+ const m1 = parseHelmDuplex('');
82
+ expect(m1.sense.monomers.length, 0);
83
+ expect(m1.antisense, null);
84
+ const m2 = parseHelmDuplex('not a helm string');
85
+ expect(m2.sense.monomers.length, 0);
86
+ });
87
+
88
+ test('passes through unknown sugar/phosphate symbols', async () => {
89
+ const helm = 'RNA1{[xr](A)[zp].[yr](C)p}$$$$';
90
+ const m = parseHelmDuplex(helm);
91
+ const m0 = m.sense.monomers[0];
92
+ if (m0.kind === 'nucleotide') {
93
+ expect(m0.sugar, 'xr');
94
+ expect(m0.phosphate, 'zp');
95
+ }
96
+ });
97
+ });
98
+
99
+ category('OligoRenderer: modification dictionary', () => {
100
+ test('resolves HELMCore canonical sugars', async () => {
101
+ expect(resolveSugar('m', 'A').meta.short, '2\'-OMe');
102
+ expect(resolveSugar('fl2r', 'C').meta.short, '2\'-F');
103
+ expect(resolveSugar('lna', 'G').meta.short, 'LNA');
104
+ expect(resolveSugar('moe', 'A').meta.short, '2\'-MOE');
105
+ });
106
+
107
+ test('aliases legacy uppercase symbols to canonical', async () => {
108
+ // `mR` (legacy) and `m` (canonical) must resolve identically
109
+ expect(resolveSugar('mR', 'A').meta.name, resolveSugar('m', 'A').meta.name);
110
+ expect(resolveSugar('fR', 'C').meta.name, resolveSugar('fl2r', 'C').meta.name);
111
+ expect(resolveSugar('LR', 'G').meta.name, resolveSugar('lna', 'G').meta.name);
112
+ expect(resolveSugar('dR', 'T').meta.name, resolveSugar('d', 'T').meta.name);
113
+ });
114
+
115
+ test('uses base-canonical color for unmodified ribose', async () => {
116
+ const a = resolveSugar('r', 'A');
117
+ const c = resolveSugar('r', 'C');
118
+ expect(a.color !== c.color, true,
119
+ `Unmodified A and C must use distinct canonical colors, got ${a.color} for both`);
120
+ });
121
+
122
+ test('falls back to hash color for unknown sugar', async () => {
123
+ const x = resolveSugar('zzz', 'A');
124
+ expect(x.color.startsWith('hsl('), true);
125
+ expect(x.meta.name, 'zzz');
126
+ });
127
+
128
+ test('hashColor is deterministic', async () => {
129
+ expect(hashColor('foo'), hashColor('foo'));
130
+ expect(hashColor('foo') !== hashColor('bar'), true);
131
+ });
132
+
133
+ test('PS phosphate (canonical and legacy) resolve to same color', async () => {
134
+ expect(resolvePhosphate('sp').color, PHOSPHATE_MODS['sp'].color);
135
+ expect(resolvePhosphate('sP').color, PHOSPHATE_MODS['sp'].color);
136
+ });
137
+
138
+ test('GalNAc / L3 / Chol resolve as conjugates', async () => {
139
+ expect(resolveConjugate('GalNAc').meta.category, 'conjugate');
140
+ expect(resolveConjugate('L3').meta.category, 'conjugate');
141
+ expect(resolveConjugate('Chol').meta.color, CONJUGATE_MODS['Chol'].color);
142
+ });
143
+ });
144
+
145
+ category('OligoRenderer: layout', () => {
146
+ test('comfortable cell — chips fit width and height', async () => {
147
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
148
+ const layout = computeLayout(500, 70, m);
149
+ expect(layout.textOnlyFallback, false);
150
+ expect(layout.chipW > 5, true, 'chip width should be larger than minimum');
151
+ expect(layout.chipW <= 17, true, 'chip width should respect max (17)');
152
+ expect(layout.antiY > layout.senseY, true);
153
+ expect(layout.senseChips.length > 0, true);
154
+ expect(layout.antiChips.length > 0, true);
155
+ });
156
+
157
+ test('compact cell — both strands still fit in 28px row', async () => {
158
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
159
+ const layout = computeLayout(300, 28, m);
160
+ expect(layout.antiY > 0, true);
161
+ expect(layout.chipH * 2 + layout.strandGap <= 28, true,
162
+ 'two strands must fit within 28px row');
163
+ });
164
+
165
+ test('very narrow cell triggers text-only fallback', async () => {
166
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
167
+ const layout = computeLayout(60, 30, m);
168
+ expect(layout.textOnlyFallback, true);
169
+ expect(layout.senseChips.length, 0);
170
+ });
171
+
172
+ test('aspect ratio is preserved across sizes', async () => {
173
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
174
+ for (const [w, h] of [[300, 50], [500, 70], [800, 90]]) {
175
+ const layout = computeLayout(w, h, m);
176
+ const ratio = layout.chipH / layout.chipW;
177
+ // ASPECT_H_OVER_W = 1.25 in canvas-renderer.ts
178
+ expect(Math.abs(ratio - 1.25) < 0.01, true,
179
+ `aspect ratio drifted at ${w}x${h}: got ${ratio.toFixed(3)}`);
180
+ }
181
+ });
182
+
183
+ test('single-strand layout', async () => {
184
+ const m = parseHelmDuplex(SAMPLE_SS);
185
+ const layout = computeLayout(400, 50, m);
186
+ expect(layout.antiY, -1);
187
+ expect(layout.antiChips.length, 0);
188
+ });
189
+
190
+ test('antisense reversed by default for pair-alignment', async () => {
191
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
192
+ const layout = computeLayout(800, 70, m);
193
+ expect(layout.antiReversed, true);
194
+ // First antisense chip in display = last antisense monomer in data
195
+ const antiLast = m.antisense!.monomers[m.antisense!.monomers.length - 1];
196
+ expect(layout.antiChips[0].monomer === antiLast, true);
197
+ });
198
+
199
+ test('pairAlign: false leaves antisense in data order', async () => {
200
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
201
+ const layout = computeLayout(800, 70, m, {pairAlign: false});
202
+ expect(layout.antiReversed, false);
203
+ expect(layout.antiChips[0].monomer === m.antisense!.monomers[0], true);
204
+ });
205
+
206
+ test('conjugate occupies a wider position; following chip does not overlap', async () => {
207
+ // KRAS-like duplex with [L3] conjugate at sense 3'
208
+ const helm = 'RNA1{m(G)p.m(A)p.m(C)p.[L3]}|RNA2{m(C)p.m(A)p.m(A)p.r(G)}$$$$';
209
+ const m = parseHelmDuplex(helm);
210
+ const layout = computeLayout(600, 70, m);
211
+ const conj = layout.senseChips[3];
212
+ expect(conj.monomer.kind, 'conjugate');
213
+ expect(conj.w >= layout.chipW, true, 'conjugate must be at least chip-wide');
214
+ });
215
+ });
216
+
217
+ category('OligoRenderer: hit testing', () => {
218
+ test('hits each chip via stored positions', async () => {
219
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
220
+ const layout = computeLayout(800, 70, m);
221
+
222
+ const first = layout.senseChips[0];
223
+ const hit = hitTest(first.x + first.w / 2, layout.senseY + layout.chipH / 2, m, layout);
224
+ expect(hit !== null, true);
225
+ expect(hit!.strand, 'sense');
226
+ expect(hit!.position, 0);
227
+
228
+ // Gap immediately after first chip should miss (unless it's a PS link)
229
+ const prev = m.sense.monomers[0];
230
+ const isPS = prev.kind === 'nucleotide' && (prev as any).phosphate === 'sp';
231
+ const gapX = first.x + first.w + layout.chipGap * 0.1;
232
+ const miss = hitTest(gapX, layout.senseY + layout.chipH / 2, m, layout);
233
+ if (isPS) {
234
+ // Should hit the PS linkage marker
235
+ expect(miss !== null, true);
236
+ expect(miss!.linkage !== undefined, true);
237
+ } else {
238
+ expect(miss, null);
239
+ }
240
+ });
241
+
242
+ test('antisense hit returns original position despite reversed display', async () => {
243
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
244
+ const layout = computeLayout(800, 70, m);
245
+ // Click on the leftmost antisense chip in display — that's the LAST monomer in data
246
+ const leftmost = layout.antiChips[0];
247
+ const hit = hitTest(leftmost.x + leftmost.w / 2, layout.antiY + layout.chipH / 2, m, layout);
248
+ expect(hit !== null, true);
249
+ expect(hit!.strand, 'antisense');
250
+ expect(hit!.position, m.antisense!.monomers.length - 1,
251
+ 'leftmost AS chip in pair-aligned display = last monomer in data');
252
+ });
253
+
254
+ test('hovering between two chips with PS linkage returns the linkage', async () => {
255
+ // Force a PS linkage between position 0 and 1
256
+ const helm = 'RNA1{m(G)[sp].m(A)p.m(C)p}$$$$';
257
+ const m = parseHelmDuplex(helm);
258
+ const layout = computeLayout(600, 70, m);
259
+ const c0 = layout.senseChips[0];
260
+ const c1 = layout.senseChips[1];
261
+ const midX = (c0.x + c0.w + c1.x) / 2;
262
+ const hit = hitTest(midX, layout.senseY + layout.chipH / 2, m, layout);
263
+ expect(hit !== null, true);
264
+ expect(hit!.linkage !== undefined, true);
265
+ expect(hit!.linkage!.phosphateSymbol, 'sp');
266
+ });
267
+ });
268
+
269
+ category('OligoRenderer: drawing smoke', () => {
270
+ test('draws without error on offscreen canvas', async () => {
271
+ const canvas = document.createElement('canvas');
272
+ canvas.width = 500; canvas.height = 70;
273
+ const g = canvas.getContext('2d')!;
274
+ const model = parseHelmDuplex(SAMPLE_DUPLEX);
275
+ const layout = drawDuplex(g, 0, 0, 500, 70, model);
276
+ expect(layout.textOnlyFallback, false);
277
+ });
278
+
279
+ test('handles unknown modifications without throwing', async () => {
280
+ const helm = 'RNA1{[xr](A)[zp].[yr](C)p}$$$$';
281
+ const canvas = document.createElement('canvas');
282
+ canvas.width = 200; canvas.height = 50;
283
+ const g = canvas.getContext('2d')!;
284
+ const model = parseHelmDuplex(helm);
285
+ drawDuplex(g, 0, 0, 200, 50, model);
286
+ // Verifying no throw is the assertion.
287
+ expect(true, true);
288
+ });
289
+
290
+ test('renders legacy uppercase symbols via alias map', async () => {
291
+ const canvas = document.createElement('canvas');
292
+ canvas.width = 400; canvas.height = 50;
293
+ const g = canvas.getContext('2d')!;
294
+ const model = parseHelmDuplex(LEGACY_DUPLEX);
295
+ drawDuplex(g, 0, 0, 400, 50, model);
296
+ expect(model.sense.monomers.length, 4);
297
+ expect(model.antisense !== null, true);
298
+ });
299
+ });