@datagrok/sequence-translator 1.10.21 → 1.10.23

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.
@@ -211,10 +211,158 @@ category('OligoRenderer: layout', () => {
211
211
  const layout = computeLayout(500, 70, m);
212
212
  expect(layout.textOnlyFallback, false);
213
213
  expect(layout.chipW > 5, true, 'chip width should be larger than minimum');
214
- expect(layout.chipW <= 17, true, 'chip width should respect max (17)');
214
+ // chipW is bounded above by the cell's height budget. V_PAD=10 is the
215
+ // top/bottom breathing room; the rest is divided among 2 strands +
216
+ // 1 strand-gap + 2 apex zones with the fixed chip aspect ratio.
217
+ const heightBudget = (70 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
218
+ expect(layout.chipW <= heightBudget + 0.01, true,
219
+ `chipW should be bounded by height (got ${layout.chipW}, cap ${heightBudget})`);
215
220
  expect(layout.antiY > layout.senseY, true);
216
221
  expect(layout.senseChips.length > 0, true);
217
222
  expect(layout.antiChips.length > 0, true);
223
+ // Top apex must sit at least V_PAD=10 from the cell's top edge.
224
+ expect(layout.senseY - layout.apexH >= 10 - 0.01, true,
225
+ `top apex zone violates 10px top margin: senseY=${layout.senseY}, apexH=${layout.apexH}`);
226
+ });
227
+
228
+ test('chipW grows with cell height (no hard max cap)', async () => {
229
+ // Same duplex, taller cells. Without a hardcoded MAX_CHIP_W cap, chipW
230
+ // must grow proportionally with cellH (height budget governs the cap).
231
+ // Smallest cell here is 60px to stay above the V_PAD=10 floor where
232
+ // the height budget would otherwise dip under MIN_CHIP_W.
233
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
234
+ const layoutSmall = computeLayout(1000, 60, m);
235
+ const layoutMid = computeLayout(1000, 100, m);
236
+ const layoutBig = computeLayout(1000, 180, m);
237
+ expect(layoutMid.chipW > layoutSmall.chipW, true,
238
+ `expected chipW to grow with height: ${layoutSmall.chipW} → ${layoutMid.chipW}`);
239
+ expect(layoutBig.chipW > layoutMid.chipW, true,
240
+ `expected chipW to keep growing: ${layoutMid.chipW} → ${layoutBig.chipW}`);
241
+ // Previously this was capped at MAX_CHIP_W=17. With the cap removed and
242
+ // a 180px-tall cell, chipW comfortably exceeds 30 even after V_PAD=10
243
+ // takes 20px out of the vertical budget.
244
+ expect(layoutBig.chipW > 30, true,
245
+ `chipW should exceed previous 17px cap on tall cells: got ${layoutBig.chipW}`);
246
+ });
247
+
248
+ test('chipW grows with cell width when not height-limited', async () => {
249
+ // Wide+tall cell with a small duplex (4 chips per strand). Width budget
250
+ // is generous, so chipW should be governed by the height cap.
251
+ const helm = 'RNA1{m(A)p.m(C)p.m(G)p.m(U)}|RNA2{m(U)p.m(G)p.m(C)p.m(A)}$$$$';
252
+ const m = parseHelmDuplex(helm);
253
+ const tallWide = computeLayout(2000, 140, m);
254
+ // Should fit easily; chipW capped by height budget. (cellH - 2*V_PAD)
255
+ // / heightFactor / aspect.
256
+ const heightCap = (140 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
257
+ expect(Math.abs(tallWide.chipW - heightCap) < 0.5, true,
258
+ `tall+wide cell: chipW should ride the height cap (got ${tallWide.chipW}, expected ~${heightCap.toFixed(1)})`);
259
+ });
260
+
261
+ test('top apex and bottom apex sit at least 10px from cell edges', async () => {
262
+ // V_PAD=10 guarantees vertical breathing room so the apex tips never
263
+ // touch the cell border.
264
+ const m = parseHelmDuplex(SAMPLE_DUPLEX);
265
+ for (const [w, h] of [[600, 70], [1000, 120], [1500, 200]]) {
266
+ const layout = computeLayout(w, h, m);
267
+ if (layout.textOnlyFallback) continue;
268
+ const topApexEdge = layout.senseY - layout.apexH;
269
+ const bottomApexEdge = layout.antiY >= 0 ? layout.antiY + layout.chipH + layout.apexH : -1;
270
+ expect(topApexEdge >= 10 - 0.01, true,
271
+ `top apex too close to top at ${w}×${h}: ${topApexEdge}`);
272
+ if (bottomApexEdge > 0)
273
+ expect(h - bottomApexEdge >= 10 - 0.01, true,
274
+ `bottom apex too close to bottom at ${w}×${h}: cellH-edge=${h - bottomApexEdge}`);
275
+ }
276
+ });
277
+
278
+ test('multi-char base ellipsizes — chipW does NOT shrink for everyone else', async () => {
279
+ // Tight cell with one long custom base on sense. Show-everything priority
280
+ // means: keep chipW as big as uniform-fit allows; ellipsize the long
281
+ // multi-char chip rather than shrinking every chip on the row.
282
+ const canonical = parseHelmDuplex(
283
+ 'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
284
+ 'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
285
+ const multichar = parseHelmDuplex(
286
+ 'RNA1{m([cpm6A])p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
287
+ 'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
288
+ const cellW = 300; const cellH = 50;
289
+ const lCanon = computeLayout(cellW, cellH, canonical);
290
+ const lMulti = computeLayout(cellW, cellH, multichar);
291
+ // Sizing is governed by uniform-fit, which depends only on chip COUNT
292
+ // and conjugate widths — not on whether a base is single- or multi-char.
293
+ // So a multi-char base must not pull chipW down.
294
+ expect(Math.abs(lMulti.chipW - lCanon.chipW) < 0.5, true,
295
+ `multi-char chipW=${lMulti.chipW.toFixed(2)} drifted from canonical chipW=${lCanon.chipW.toFixed(2)}; ` +
296
+ `sizing should be based on uniform fit, not widened`);
297
+ // Every monomer still draws (just the multi-char one shows an ellipsized label).
298
+ expect(lMulti.senseChips.length, multichar.sense.monomers.length);
299
+ expect(lMulti.antiChips.length, multichar.antisense!.monomers.length);
300
+ });
301
+
302
+ test('leading conjugate does NOT cause chipW to stall on roomy cells', async () => {
303
+ // Regression: `fitsInBudget` used to double-count the leading conjugate
304
+ // width (added `leadW` to the running total AND `widths[0]` already
305
+ // contained the pill width), so the binary search bailed early on cells
306
+ // where the conjugate strand had enough room. Net effect was the layout
307
+ // chose a chipW much smaller than what the cell could actually hold.
308
+ // This is the exact HELM the user reported, on a wide+tall cell.
309
+ const helm =
310
+ 'RNA1{[Chol].m(G)[sp].[fl2r](A)[sp].m(C)p.[fl2r](U)p.m(G)p.[fl2r](A)p.m(A)p.[fl2r](U)p.m(A)p.' +
311
+ '[fl2r](U)p.m(A)p.[fl2r](A)p.m(A)p.[fl2r](C)p.m(U)p.[fl2r](U)p.m(G)[sp].[fl2r](U)[sp].m(G)[L3]}|' +
312
+ 'RNA2{m(C)[sp].[fl2r]([br8A])[sp].m(C)p.[fl2r](A)p.m(A)p.[fl2r](G)p.m(U)p.[fl2r](U)p.m(U)p.' +
313
+ '[fl2r](A)p.m(U)p.[fl2r](A)p.m(U)p.[fl2r]([cneT])p.m(C)p.[fl2r](A)p.m(G)[sp].' +
314
+ '[fl2r]([c7io7A])[sp].m(C)}$$$$';
315
+ const m = parseHelmDuplex(helm);
316
+ const layoutNoConj = computeLayout(1000, 120, parseHelmDuplex(
317
+ 'RNA1{m(G)p.m(A)p.m(C)p.m(U)p.m(G)p.m(A)p.m(A)p.m(U)p.m(A)p.m(U)p.m(A)p.m(A)p.m(A)p.m(C)p.m(U)p.m(U)p.m(G)p.m(U)p.m(G)}|' +
318
+ 'RNA2{m(C)p.m(A)p.m(C)p.m(A)p.m(A)p.m(G)p.m(U)p.m(U)p.m(U)p.m(A)p.m(U)p.m(A)p.m(U)p.m(U)p.m(C)p.m(A)p.m(G)p.m(U)p.m(C)}$$$$'));
319
+ const layoutWithConj = computeLayout(1000, 120, m);
320
+ // With a 1000×120 cell, the height cap allows chipW ~28. The version
321
+ // without conjugates rides that cap; the version with [Chol]/[L3] +
322
+ // multi-char bases should pick a chipW that's a meaningful fraction
323
+ // (≥ 60%) of that — not collapse back to single digits.
324
+ expect(layoutWithConj.chipW >= layoutNoConj.chipW * 0.6, true,
325
+ `with-conjugate chipW=${layoutWithConj.chipW.toFixed(2)} dropped too far ` +
326
+ `below no-conjugate chipW=${layoutNoConj.chipW.toFixed(2)} on the same roomy cell`);
327
+ // And every monomer must place.
328
+ expect(layoutWithConj.senseChips.length, m.sense.monomers.length);
329
+ expect(layoutWithConj.antiChips.length, m.antisense!.monomers.length);
330
+ });
331
+
332
+ test('leading conjugate reduces chipW but no monomer is dropped', async () => {
333
+ // With a Cholesterol conjugate at sense 5', the conjugate pill consumes
334
+ // a chunk of horizontal budget. chipW shrinks compared to the same data
335
+ // without the conjugate — but all chips still place.
336
+ const noConj = parseHelmDuplex(
337
+ 'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
338
+ 'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
339
+ const withConj = parseHelmDuplex(
340
+ 'RNA1{[Chol].m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
341
+ 'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
342
+ const cellW = 350; const cellH = 70;
343
+ const lNo = computeLayout(cellW, cellH, noConj);
344
+ const lW = computeLayout(cellW, cellH, withConj);
345
+ expect(lW.chipW <= lNo.chipW + 0.01, true,
346
+ `with-conjugate chipW=${lW.chipW} must be ≤ no-conjugate chipW=${lNo.chipW}`);
347
+ // Every monomer (incl. the conjugate pill) is placed.
348
+ expect(lW.senseChips.length, withConj.sense.monomers.length);
349
+ expect(lW.antiChips.length, withConj.antisense!.monomers.length);
350
+ });
351
+
352
+ test('tight cell falls back to uniform widths with ellipsis (no text-only)', async () => {
353
+ // A duplex with a long base name on a moderately tight cell: widened
354
+ // would overflow even at MIN_CHIP_W, but uniform widths (with the
355
+ // multi-char chip ellipsized) still fit.
356
+ const m = parseHelmDuplex(
357
+ 'RNA1{m([cpm6A])p.m([5Br-dC])p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)}|' +
358
+ 'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)}$$$$');
359
+ const layout = computeLayout(220, 50, m);
360
+ // Not text-only — everything still draws as chips, just with truncated labels.
361
+ expect(layout.textOnlyFallback, false,
362
+ 'tight-but-survivable cell should not collapse to text-only');
363
+ // All chips placed (no chip dropped).
364
+ expect(layout.senseChips.length, m.sense.monomers.length);
365
+ expect(layout.antiChips.length, m.antisense!.monomers.length);
218
366
  });
219
367
 
220
368
  test('compact cell — both strands still fit in 28px row', async () => {
@@ -275,6 +423,71 @@ category('OligoRenderer: layout', () => {
275
423
  expect(conj.monomer.kind, 'conjugate');
276
424
  expect(conj.w >= layout.chipW, true, 'conjugate must be at least chip-wide');
277
425
  });
426
+
427
+ test('reversed-antisense linkage owner is the lower-indexed pair member', async () => {
428
+ // The `phosphate` field on a nucleotide always means "linkage immediately
429
+ // AFTER this monomer in 5'→3' data order" — so it lives on the lower-indexed
430
+ // end of the bond. When antisense is displayed reversed, the gap to the
431
+ // right of display index i pairs data positions (N-1-i, N-2-i); the owner
432
+ // must be the lower one, i.e. `monomers[i+1]` in the reversed array, not `m`.
433
+ //
434
+ // Small fixture: antisense `r(A)[sp].r(C)p.r(G)` →
435
+ // data 0 → A, phos=sp (linkage 0↔1, sp)
436
+ // data 1 → C, phos=p (linkage 1↔2, p)
437
+ // data 2 → G, phos='' (terminal)
438
+ // Reversed display: G, C, A.
439
+ // antiLinks[0] = gap right of display 0 → between data 2 and 1 → p, owner 1
440
+ // antiLinks[1] = gap right of display 1 → between data 1 and 0 → sp, owner 0
441
+ const helm = 'RNA1{r(A)p.r(C)p.r(G)}|RNA2{r(A)[sp].r(C)p.r(G)}$$$$';
442
+ const m = parseHelmDuplex(helm);
443
+ const layout = computeLayout(600, 70, m);
444
+ expect(layout.antiReversed, true);
445
+ expect(layout.antiLinks.length, 2);
446
+ expect(layout.antiLinks[0].ownerOrigIdx, 1);
447
+ expect(layout.antiLinks[0].phosphateSymbol, 'p');
448
+ expect(layout.antiLinks[1].ownerOrigIdx, 0);
449
+ expect(layout.antiLinks[1].phosphateSymbol, 'sp');
450
+ });
451
+
452
+ test('reversed-antisense draws ALL linkages including the leftmost-data sp', async () => {
453
+ // Regression: pre-fix, reversed-strand placement read `m.phosphate` for
454
+ // the gap to the right of display i, but that field belongs to the bond
455
+ // on the OTHER side of `m`. The net effect was a one-index shift across
456
+ // the row plus a dropped link at the terminal display position — so the
457
+ // 4 sp linkages on this antisense ended up as 3, in the wrong gaps.
458
+ const helm =
459
+ 'RNA1{m(C)[sp].m(A)[sp].m(U)p.m(G)p.m(G)p.m(U)p.m(U)p.m(G)p.m(A)p.m(A)p.' +
460
+ 'm(C)p.m(A)p.m(U)p.m(G)p.m(A)p.m(G)p.m(C)[sp].m(A)[sp].m(A)[L3]}|' +
461
+ 'RNA2{m(U)[sp].m(U)[sp].m(G)p.m(C)p.m(U)p.m(C)p.m(A)p.m(U)p.m(G)p.m(U)p.' +
462
+ 'm(U)p.m(C)p.m(A)p.m(A)p.m(C)p.m(C)p.m(A)[sp].m(U)[sp].m(G)}$$$$';
463
+ const m = parseHelmDuplex(helm);
464
+ const layout = computeLayout(1200, 90, m);
465
+ expect(layout.antiReversed, true);
466
+ // Antisense has 19 nucleotides → 18 inter-nucleotide gaps, all drawn.
467
+ expect(layout.antiLinks.length, 18,
468
+ `expected 18 antisense linkages, got ${layout.antiLinks.length}`);
469
+ // 4 of them must be `sp`. With reversal, the sp at data 0↔1 maps to the
470
+ // RIGHTMOST display gap and the sp at data 17↔18 maps to the LEFTMOST —
471
+ // so sp owners, in display order, are 17, 16, 1, 0.
472
+ const spOwners = layout.antiLinks
473
+ .filter((l) => l.phosphateSymbol === 'sp')
474
+ .map((l) => l.ownerOrigIdx);
475
+ expect(spOwners.length, 4,
476
+ `expected 4 sp linkages on antisense, got ${spOwners.length}`);
477
+ expect(spOwners.join(','), '17,16,1,0',
478
+ `sp owners (display order) must be 17,16,1,0; got ${spOwners.join(',')}`);
479
+
480
+ // And sense — 19 nucleotides + L3 conjugate. 18 nucleotide-to-nucleotide
481
+ // gaps; the bond into the L3 conjugate is not a phosphate, so it isn't
482
+ // pushed as a link. 4 of those 18 are sp (data owners 0, 1, 16, 17).
483
+ expect(layout.senseLinks.length, 18,
484
+ `expected 18 sense linkages, got ${layout.senseLinks.length}`);
485
+ const senseSpOwners = layout.senseLinks
486
+ .filter((l) => l.phosphateSymbol === 'sp')
487
+ .map((l) => l.ownerOrigIdx)
488
+ .sort((a, b) => a - b);
489
+ expect(senseSpOwners.join(','), '0,1,16,17');
490
+ });
278
491
  });
279
492
 
280
493
  category('OligoRenderer: hit testing', () => {
@@ -288,18 +501,12 @@ category('OligoRenderer: hit testing', () => {
288
501
  expect(hit!.strand, 'sense');
289
502
  expect(hit!.position, 0);
290
503
 
291
- // Gap immediately after first chip should miss (unless it's a PS link)
292
- const prev = m.sense.monomers[0];
293
- const isPS = prev.kind === 'nucleotide' && (prev as any).phosphate === 'sp';
504
+ // Gap between chips at chip-row-Y should always miss now PS linkage
505
+ // markers live in the apex zone above (sense) / below (antisense) the
506
+ // chip row, not in the inter-chip gap at chip-row-Y.
294
507
  const gapX = first.x + first.w + layout.chipGap * 0.1;
295
508
  const miss = hitTest(gapX, layout.senseY + layout.chipH / 2, m, layout);
296
- if (isPS) {
297
- // Should hit the PS linkage marker
298
- expect(miss !== null, true);
299
- expect(miss!.linkage !== undefined, true);
300
- } else {
301
- expect(miss, null);
302
- }
509
+ expect(miss, null);
303
510
  });
304
511
 
305
512
  test('antisense hit returns original position despite reversed display', async () => {
@@ -314,15 +521,18 @@ category('OligoRenderer: hit testing', () => {
314
521
  'leftmost AS chip in pair-aligned display = last monomer in data');
315
522
  });
316
523
 
317
- test('hovering between two chips with PS linkage returns the linkage', async () => {
318
- // Force a PS linkage between position 0 and 1
524
+ test('hovering the apex above a PS linkage returns the linkage', async () => {
525
+ // PS linkage between sense pos 0 and 1. The apex sits above the sense
526
+ // chip row (since sense's decoration side is "top"), peak at senseY-apexH.
319
527
  const helm = 'RNA1{m(G)[sp].m(A)p.m(C)p}$$$$';
320
528
  const m = parseHelmDuplex(helm);
321
529
  const layout = computeLayout(600, 70, m);
322
530
  const c0 = layout.senseChips[0];
323
531
  const c1 = layout.senseChips[1];
324
532
  const midX = (c0.x + c0.w + c1.x) / 2;
325
- const hit = hitTest(midX, layout.senseY + layout.chipH / 2, m, layout);
533
+ // Apex zone is [senseY - apexH, senseY]; aim mid-zone.
534
+ const apexY = layout.senseY - layout.apexH / 2;
535
+ const hit = hitTest(midX, apexY, m, layout);
326
536
  expect(hit !== null, true);
327
537
  expect(hit!.linkage !== undefined, true);
328
538
  expect(hit!.linkage!.phosphateSymbol, 'sp');