@shardworks/spider-apparatus 0.1.235 → 0.1.237

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/spider-apparatus",
3
- "version": "0.1.235",
3
+ "version": "0.1.237",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,17 +22,17 @@
22
22
  "hono": "^4.7.11",
23
23
  "yaml": "^2.0.0",
24
24
  "zod": "4.3.6",
25
- "@shardworks/stacks-apparatus": "0.1.235",
26
- "@shardworks/clerk-apparatus": "0.1.235",
27
- "@shardworks/fabricator-apparatus": "0.1.235",
28
- "@shardworks/tools-apparatus": "0.1.235",
29
- "@shardworks/animator-apparatus": "0.1.235",
30
- "@shardworks/codexes-apparatus": "0.1.235",
31
- "@shardworks/loom-apparatus": "0.1.235"
25
+ "@shardworks/stacks-apparatus": "0.1.237",
26
+ "@shardworks/tools-apparatus": "0.1.237",
27
+ "@shardworks/fabricator-apparatus": "0.1.237",
28
+ "@shardworks/animator-apparatus": "0.1.237",
29
+ "@shardworks/codexes-apparatus": "0.1.237",
30
+ "@shardworks/clerk-apparatus": "0.1.237",
31
+ "@shardworks/loom-apparatus": "0.1.237"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "25.5.0",
35
- "@shardworks/nexus-core": "0.1.235"
35
+ "@shardworks/nexus-core": "0.1.237"
36
36
  },
37
37
  "files": [
38
38
  "dist",
@@ -20,54 +20,82 @@ const indexHtml = readFileSync(resolve(__dirname, 'index.html'), 'utf-8');
20
20
  // ── Rig list row structure ──────────────────────────────────────────────
21
21
 
22
22
  describe('spider.js rig list row HTML', () => {
23
+ // After the keyed in-place refactor, rig rows are assembled by
24
+ // createRigRow via document.createElement rather than an HTML template.
25
+ // These assertions pin the same invariants against the new function
26
+ // body: both writ-title and rig-id cells are built as rig-link anchors,
27
+ // and the writ-title cell is never a plain <td> of text.
28
+ const createRigRowMatch = spiderJs.match(
29
+ /function createRigRow\(rig\)[\s\S]*?return tr;\s*\}/,
30
+ );
31
+ const createRigRowBody = createRigRowMatch ? createRigRowMatch[0] : '';
32
+
23
33
  it('renders the writ-title cell as a rig-link anchor', () => {
24
- // The row template should contain a <td> with a rig-link anchor for the
25
- // writ title (the second <td> in each row). We match the template
26
- // fragment that builds the writ-title cell.
27
- const writTitleCellPattern =
28
- /<td><a class="rig-link" href="#" data-rig-id="[^"]*"\s*>\s*'\s*\+\s*esc\(writTitle\)/;
34
+ // createRigRow builds a dedicated anchor for the writ title with
35
+ // class 'rig-link' and copies data-rig-id across to it.
36
+ assert.ok(createRigRowBody, 'should find createRigRow body');
29
37
  assert.match(
30
- spiderJs,
31
- writTitleCellPattern,
32
- 'writ-title cell should be a clickable rig-link anchor',
38
+ createRigRowBody,
39
+ /writTitleAnchor\s*=\s*document\.createElement\(['"]a['"]\)/,
40
+ 'writ-title cell should be built around a dedicated anchor element',
41
+ );
42
+ assert.match(
43
+ createRigRowBody,
44
+ /writTitleAnchor\.className\s*=\s*['"]rig-link['"]/,
45
+ 'writ-title anchor should carry the rig-link class',
46
+ );
47
+ assert.match(
48
+ createRigRowBody,
49
+ /writTitleAnchor\.setAttribute\(['"]data-rig-id['"]\s*,\s*rig\.id\)/,
50
+ 'writ-title anchor should carry data-rig-id for click routing',
33
51
  );
34
52
  });
35
53
 
36
54
  it('renders the rig-id cell as a rig-link anchor', () => {
37
- const rigIdCellPattern =
38
- /<td><a class="rig-link" href="#" data-rig-id="[^"]*"\s*>\s*'\s*\+\s*esc\(rig\.id\)/;
55
+ assert.ok(createRigRowBody, 'should find createRigRow body');
39
56
  assert.match(
40
- spiderJs,
41
- rigIdCellPattern,
42
- 'rig-id cell should be a clickable rig-link anchor',
57
+ createRigRowBody,
58
+ /rigIdAnchor\s*=\s*document\.createElement\(['"]a['"]\)/,
59
+ 'rig-id cell should be built around a dedicated anchor element',
60
+ );
61
+ assert.match(
62
+ createRigRowBody,
63
+ /rigIdAnchor\.className\s*=\s*['"]rig-link['"]/,
64
+ 'rig-id anchor should carry the rig-link class',
65
+ );
66
+ assert.match(
67
+ createRigRowBody,
68
+ /rigIdAnchor\.textContent\s*=\s*rig\.id/,
69
+ 'rig-id anchor should show rig.id as its visible text',
43
70
  );
44
71
  });
45
72
 
46
73
  it('writ-title and rig-id cells share the same rig-link class', () => {
47
- // Both cells must use the same class so the click handler wires them
48
- // identically. Count rig-link anchors in the row template.
49
- const rowTemplateMatch = spiderJs.match(
50
- /var rows = filtered\.map\(function \(rig\) \{[\s\S]*?\.join\(''\)/,
51
- );
52
- assert.ok(rowTemplateMatch, 'should find the row template block');
53
- const rowTemplate = rowTemplateMatch[0];
54
-
55
- const rigLinkCount = (rowTemplate.match(/class="rig-link"/g) || []).length;
74
+ // Both cells must use the same class so the click-wiring treats them
75
+ // identically. Count rig-link references inside createRigRow.
76
+ assert.ok(createRigRowBody, 'should find createRigRow body');
77
+ const rigLinkCount = (createRigRowBody.match(/['"]rig-link['"]/g) || []).length;
56
78
  assert.ok(
57
79
  rigLinkCount >= 2,
58
- `expected at least 2 rig-link anchors in the row template, found ${rigLinkCount}`,
80
+ `expected at least 2 rig-link anchors in createRigRow, found ${rigLinkCount}`,
59
81
  );
60
82
  });
61
83
 
62
84
  it('writ-title cell is NOT rendered as plain text', () => {
63
- // Regression guard: the old pattern was just '<td>' + esc(writTitle) + '</td>'
64
- // with no anchor. Ensure that pattern no longer exists.
85
+ // Regression guard: the writ-title cell must always be an anchor, not
86
+ // a plain text <td>. We forbid both the legacy string-template shape
87
+ // and any plain assignment of the writ title onto a <td>'s textContent.
65
88
  const plainTitlePattern = /'<td>'\s*\+\s*esc\(writTitle\)\s*\+\s*'<\/td>'/;
66
89
  assert.doesNotMatch(
67
90
  spiderJs,
68
91
  plainTitlePattern,
69
92
  'writ-title cell must not be rendered as plain (non-linked) text',
70
93
  );
94
+ assert.doesNotMatch(
95
+ spiderJs,
96
+ /writTitleTd\.textContent\s*=\s*writTitle/,
97
+ 'writ-title text must never be written directly onto the td',
98
+ );
71
99
  });
72
100
 
73
101
  it('writ deep-links target the canonical Clerk writs page path', () => {
@@ -900,6 +928,70 @@ describe('spider.js pipeline keyed update', () => {
900
928
  });
901
929
  });
902
930
 
931
+ // ── Rig-list renderer keyed in-place update ─────────────────────────────
932
+
933
+ describe('spider.js rig-list keyed update', () => {
934
+ // Mirrors the pipeline keyed-update block: a fast-path / slow-path
935
+ // update strategy for the rig-tbody, keyed by data-rig-id on each <tr>.
936
+ // The three mutating <td>s carry per-cell class hooks so updateRigRow
937
+ // can patch them without positional selectors.
938
+
939
+ it('createRigRow sets data-rig-id on the <tr>', () => {
940
+ const block = spiderJs.match(
941
+ /function createRigRow\(rig\)[\s\S]*?return tr;\s*\}/,
942
+ );
943
+ assert.ok(block, 'should find createRigRow');
944
+ assert.match(
945
+ block[0],
946
+ /tr\.setAttribute\(['"]data-rig-id['"]\s*,\s*rig\.id\)/,
947
+ 'createRigRow should index the row by data-rig-id on the <tr>',
948
+ );
949
+ });
950
+
951
+ it('renderRigList has a fast path that patches in place when order is unchanged', () => {
952
+ const block = spiderJs.match(
953
+ /function renderRigList\([\s\S]*?(?=\n \/\/|\n function )/,
954
+ );
955
+ assert.ok(block, 'should find renderRigList');
956
+ assert.match(
957
+ block[0],
958
+ /getAttribute\(['"]data-rig-id['"]\)/,
959
+ 'renderRigList should key existing rows by data-rig-id',
960
+ );
961
+ assert.match(
962
+ block[0],
963
+ /orderUnchanged/,
964
+ 'renderRigList should compute an orderUnchanged flag for the fast path',
965
+ );
966
+ assert.match(
967
+ block[0],
968
+ /updateRigRow\(/,
969
+ 'renderRigList should reach into updateRigRow for in-place patches',
970
+ );
971
+ });
972
+
973
+ it('exposes updateRigRow(row, rig) for in-place cell updates', () => {
974
+ assert.match(
975
+ spiderJs,
976
+ /function updateRigRow\(row, rig\)/,
977
+ 'should define updateRigRow(row, rig) helper for in-place row updates',
978
+ );
979
+ });
980
+
981
+ it('rig-row cells carry per-cell class hooks for status, cost, and engines', () => {
982
+ // Without class hooks, the keyed-update path would have to fall back
983
+ // to positional selectors inside the row — brittle and inconsistent
984
+ // with the pipeline pattern.
985
+ for (const cls of ['rig-row-status', 'rig-row-cost', 'rig-row-engines']) {
986
+ assert.match(
987
+ spiderJs,
988
+ new RegExp(`['"]${cls}['"]`),
989
+ `createRigRow/updateRigRow should reference the ${cls} class hook`,
990
+ );
991
+ }
992
+ });
993
+ });
994
+
903
995
  // ── Server-supplied cost (no per-engine fetch) ──────────────────────────
904
996
 
905
997
  describe('spider.js server-supplied engine cost', () => {
@@ -1015,7 +1107,9 @@ describe('spider.js rig-list Cost column', () => {
1015
1107
  // T5 / D7: the rig-list table has a dedicated Cost column, rendered
1016
1108
  // immediately to the left of Engines, sourced from rig.costSummary.
1017
1109
  // The Cost column always renders — showing $0.00 when no sessions have
1018
- // reported cost yet.
1110
+ // reported cost yet. After the keyed in-place refactor, the Cost cell
1111
+ // is structurally built in createRigRow and the $0.00 fallback lives
1112
+ // inside updateRigRow.
1019
1113
 
1020
1114
  it('index.html declares a Cost <th> between Writ Title and Engines', () => {
1021
1115
  assert.match(
@@ -1026,19 +1120,20 @@ describe('spider.js rig-list Cost column', () => {
1026
1120
  });
1027
1121
 
1028
1122
  it('row template emits a cost cell between writ-title and engines cells', () => {
1029
- const rowTemplateMatch = spiderJs.match(
1030
- /var rows = filtered\.map\(function \(rig\) \{[\s\S]*?\.join\(''\)/,
1031
- );
1032
- assert.ok(rowTemplateMatch, 'should find the row template block');
1033
- const rowTemplate = rowTemplateMatch[0];
1034
- // esc(writTitle) must come before esc(formatCostUsd(costUsd))
1035
- // which must come before esc(engineSummary(rig.engines))
1036
- const writIdx = rowTemplate.indexOf('esc(writTitle)');
1037
- const costIdx = rowTemplate.indexOf('formatCostUsd(costUsd)');
1038
- const enginesIdx = rowTemplate.indexOf('engineSummary(rig.engines)');
1039
- assert.ok(writIdx >= 0, 'row template should include writ title cell');
1040
- assert.ok(costIdx >= 0, 'row template should include cost cell');
1041
- assert.ok(enginesIdx >= 0, 'row template should include engines cell');
1123
+ // createRigRow appends cells to the <tr> in DOM order, so we pin the
1124
+ // ordering invariant by checking that the writ-title cell is appended
1125
+ // before the cost cell, which is appended before the engines cell.
1126
+ const createRigRowMatch = spiderJs.match(
1127
+ /function createRigRow\(rig\)[\s\S]*?return tr;\s*\}/,
1128
+ );
1129
+ assert.ok(createRigRowMatch, 'should find createRigRow body');
1130
+ const body = createRigRowMatch[0];
1131
+ const writIdx = body.indexOf('tr.appendChild(writTitleTd)');
1132
+ const costIdx = body.indexOf('tr.appendChild(costTd)');
1133
+ const enginesIdx = body.indexOf('tr.appendChild(enginesTd)');
1134
+ assert.ok(writIdx >= 0, 'createRigRow should append a writ-title cell');
1135
+ assert.ok(costIdx >= 0, 'createRigRow should append a cost cell');
1136
+ assert.ok(enginesIdx >= 0, 'createRigRow should append an engines cell');
1042
1137
  assert.ok(
1043
1138
  writIdx < costIdx && costIdx < enginesIdx,
1044
1139
  'cost cell must sit between writ-title and engines cells',
@@ -1046,15 +1141,23 @@ describe('spider.js rig-list Cost column', () => {
1046
1141
  });
1047
1142
 
1048
1143
  it('row template falls back to 0 when costSummary is absent (renders $0.00)', () => {
1049
- const rowTemplateMatch = spiderJs.match(
1050
- /var rows = filtered\.map\(function \(rig\) \{[\s\S]*?\.join\(''\)/,
1144
+ // The $0.00 fallback now lives in updateRigRow — the cost cell's
1145
+ // textContent is always derived via formatCostUsd, defaulting costUsd
1146
+ // to 0 when costSummary is missing or costUsd is not a number.
1147
+ const updateRigRowMatch = spiderJs.match(
1148
+ /function updateRigRow\(row, rig\)[\s\S]*?(?=\n \}\n\n \/\/|\n function )/,
1051
1149
  );
1052
- assert.ok(rowTemplateMatch, 'should find the row template block');
1053
- const rowTemplate = rowTemplateMatch[0];
1150
+ assert.ok(updateRigRowMatch, 'should find updateRigRow body');
1151
+ const body = updateRigRowMatch[0];
1054
1152
  assert.match(
1055
- rowTemplate,
1153
+ body,
1056
1154
  /rig\.costSummary[\s\S]*?costUsd[\s\S]*?:\s*0/,
1057
- 'row template should default costUsd to 0 when costSummary is missing',
1155
+ 'updateRigRow should default costUsd to 0 when costSummary is missing',
1156
+ );
1157
+ assert.match(
1158
+ body,
1159
+ /formatCostUsd\(costUsd\)/,
1160
+ 'updateRigRow should render the cell via formatCostUsd',
1058
1161
  );
1059
1162
  });
1060
1163
  });
@@ -577,35 +577,174 @@
577
577
  if (empty) empty.style.display = 'none';
578
578
  if (table) table.style.display = '';
579
579
 
580
- var rows = filtered.map(function (rig) {
581
- var writTitle = (writLookup[rig.writId] && writLookup[rig.writId].title) || '\u2014';
582
- // D7/D11: render $0.00 when no cost data exists. Server populates
583
- // costSummary from the animator sessions book for every rig.
584
- var costUsd = (rig.costSummary && typeof rig.costSummary.costUsd === 'number') ? rig.costSummary.costUsd : 0;
585
- return '<tr>' +
586
- '<td>' + badgeHtml(rig.status) + '</td>' +
587
- '<td><a class="rig-link" href="#" data-rig-id="' + esc(rig.id) + '">' + esc(writTitle) + '</a></td>' +
588
- '<td>' + esc(formatCostUsd(costUsd)) + '</td>' +
589
- '<td>' + esc(engineSummary(rig.engines)) + '</td>' +
590
- '<td><a class="rig-link" href="#" data-rig-id="' + esc(rig.id) + '">' + esc(rig.id) + '</a></td>' +
591
- '<td><a href="/pages/writs/?writ=' + esc(rig.writId) + '">' + esc(rig.writId) + '</a></td>' +
592
- '<td>' + esc(formatDate(rig.createdAt)) + '</td>' +
593
- '</tr>';
580
+ // Index existing row children by rig id (mirrors renderPipelineInto).
581
+ var existingRows = {};
582
+ var existingList = tbody.children;
583
+ for (var i = 0; i < existingList.length; i++) {
584
+ var existingId = existingList[i].getAttribute('data-rig-id');
585
+ if (existingId) existingRows[existingId] = existingList[i];
586
+ }
587
+
588
+ // Fast path: same rig set in the same order. Patch each row in place
589
+ // without touching the parent's child list — the common case during
590
+ // the 2 s poll when nothing has been added or removed.
591
+ var orderUnchanged =
592
+ existingList.length === filtered.length &&
593
+ filtered.every(function (rig, idx) {
594
+ return existingList[idx].getAttribute('data-rig-id') === rig.id;
595
+ });
596
+
597
+ if (orderUnchanged) {
598
+ for (var f = 0; f < filtered.length; f++) {
599
+ updateRigRow(existingList[f], filtered[f]);
600
+ }
601
+ return;
602
+ }
603
+
604
+ // Slow path: rig set or order changed. Detach every row (without
605
+ // `innerHTML = ''`, which would drop the nodes we want to reuse),
606
+ // then re-append matched rows via createRigRow / updateRigRow in the
607
+ // new filtered order. Rows for rig ids not in the incoming list are
608
+ // simply not re-attached and fall out of scope.
609
+ while (tbody.firstChild) {
610
+ tbody.removeChild(tbody.firstChild);
611
+ }
612
+
613
+ filtered.forEach(function (rig) {
614
+ var row = existingRows[rig.id];
615
+ if (!row) {
616
+ row = createRigRow(rig);
617
+ }
618
+ updateRigRow(row, rig);
619
+ tbody.appendChild(row);
594
620
  });
621
+ }
595
622
 
596
- tbody.innerHTML = rows.join('');
597
-
598
- // Wire rig-link clicks
599
- var links = tbody.querySelectorAll('.rig-link');
600
- for (var i = 0; i < links.length; i++) {
601
- (function (link) {
602
- link.addEventListener('click', function (e) {
603
- e.preventDefault();
604
- var rigId = link.getAttribute('data-rig-id');
605
- var rig = rigs.find(function (r) { return r.id === rigId; });
606
- if (rig) showRigDetail(rig);
607
- });
608
- })(links[i]);
623
+ // ── Rig-row construction + in-place update ─────────────────────────────
624
+
625
+ /**
626
+ * Build a new <tr> for the given rig. Modeled one-for-one on
627
+ * createPipelineNode: data-rig-id on the outer node (D3), per-cell class
628
+ * hooks on the mutating <td>s (D4), and rig-link click handlers wired
629
+ * once per anchor with a closure over rig.id (D5/D6). The live rig is
630
+ * resolved inside the handler via rigs.find so reused rows pick up the
631
+ * latest payload without needing their listeners re-attached.
632
+ */
633
+ function createRigRow(rig) {
634
+ var tr = document.createElement('tr');
635
+ tr.setAttribute('data-rig-id', rig.id);
636
+
637
+ // Cell 1: Status. A stable <span class="badge"> child is patched in
638
+ // place by updateRigRow — the className toggles the variant, the
639
+ // textContent is the status word.
640
+ var statusTd = document.createElement('td');
641
+ statusTd.className = 'rig-row-status';
642
+ var statusBadge = document.createElement('span');
643
+ statusBadge.className = 'badge';
644
+ statusTd.appendChild(statusBadge);
645
+ tr.appendChild(statusTd);
646
+
647
+ // Cell 2: Writ title (rig-link anchor). Writ title text is written by
648
+ // updateRigRow on every poll (D9) so writLookup refreshes are visible.
649
+ var writTitleTd = document.createElement('td');
650
+ var writTitleAnchor = document.createElement('a');
651
+ writTitleAnchor.className = 'rig-link';
652
+ writTitleAnchor.href = '#';
653
+ writTitleAnchor.setAttribute('data-rig-id', rig.id);
654
+ writTitleAnchor.addEventListener('click', function (e) {
655
+ e.preventDefault();
656
+ var live = rigs.find(function (r) { return r.id === rig.id; });
657
+ if (live) showRigDetail(live);
658
+ });
659
+ writTitleTd.appendChild(writTitleAnchor);
660
+ tr.appendChild(writTitleTd);
661
+
662
+ // Cell 3: Cost.
663
+ var costTd = document.createElement('td');
664
+ costTd.className = 'rig-row-cost';
665
+ tr.appendChild(costTd);
666
+
667
+ // Cell 4: Engines.
668
+ var enginesTd = document.createElement('td');
669
+ enginesTd.className = 'rig-row-engines';
670
+ tr.appendChild(enginesTd);
671
+
672
+ // Cell 5: Rig id (rig-link anchor). The id text never changes for a
673
+ // given row, so it's written once at create time.
674
+ var rigIdTd = document.createElement('td');
675
+ var rigIdAnchor = document.createElement('a');
676
+ rigIdAnchor.className = 'rig-link';
677
+ rigIdAnchor.href = '#';
678
+ rigIdAnchor.setAttribute('data-rig-id', rig.id);
679
+ rigIdAnchor.textContent = rig.id;
680
+ rigIdAnchor.addEventListener('click', function (e) {
681
+ e.preventDefault();
682
+ var live = rigs.find(function (r) { return r.id === rig.id; });
683
+ if (live) showRigDetail(live);
684
+ });
685
+ rigIdTd.appendChild(rigIdAnchor);
686
+ tr.appendChild(rigIdTd);
687
+
688
+ // Cell 6: Writ deep-link to the Clerk writs page. Stable per row.
689
+ var writIdTd = document.createElement('td');
690
+ var writIdAnchor = document.createElement('a');
691
+ writIdAnchor.href = '/pages/writs/?writ=' + encodeURIComponent(rig.writId || '');
692
+ writIdAnchor.textContent = rig.writId || '';
693
+ writIdTd.appendChild(writIdAnchor);
694
+ tr.appendChild(writIdTd);
695
+
696
+ // Cell 7: Created timestamp (stable per row).
697
+ var createdTd = document.createElement('td');
698
+ createdTd.textContent = formatDate(rig.createdAt);
699
+ tr.appendChild(createdTd);
700
+
701
+ return tr;
702
+ }
703
+
704
+ /**
705
+ * Patch the mutating cells of an existing rig row in place. Safe to
706
+ * call on every 2 s poll — writes only happen when values actually
707
+ * changed, mirroring the idempotent-write pattern used by setText /
708
+ * updatePipelineNode. Cells touched: status (badge class + text), writ
709
+ * title (anchor textContent — re-read from writLookup each call per
710
+ * D9), cost, engines.
711
+ */
712
+ function updateRigRow(row, rig) {
713
+ // Status badge — class + text, both idempotent.
714
+ var statusTd = row.querySelector('.rig-row-status');
715
+ if (statusTd) {
716
+ var badgeEl = statusTd.querySelector('.badge');
717
+ if (badgeEl) {
718
+ var bc = badgeClass(rig.status);
719
+ var nextClass = bc ? 'badge ' + bc : 'badge';
720
+ if (badgeEl.className !== nextClass) badgeEl.className = nextClass;
721
+ if (badgeEl.textContent !== rig.status) badgeEl.textContent = rig.status;
722
+ }
723
+ }
724
+
725
+ // Writ title — re-read from writLookup on every call (D9). The
726
+ // writ-title anchor is the first .rig-link in the row; the rig-id
727
+ // anchor appears later and is not touched here.
728
+ var writTitle = (writLookup[rig.writId] && writLookup[rig.writId].title) || '\u2014';
729
+ var writTitleAnchor = row.querySelector('.rig-link');
730
+ if (writTitleAnchor && writTitleAnchor.textContent !== writTitle) {
731
+ writTitleAnchor.textContent = writTitle;
732
+ }
733
+
734
+ // Cost — D7/D11 fallback to 0 when costSummary is absent so the cell
735
+ // always renders $0.00 rather than blank.
736
+ var costTd = row.querySelector('.rig-row-cost');
737
+ if (costTd) {
738
+ var costUsd = (rig.costSummary && typeof rig.costSummary.costUsd === 'number') ? rig.costSummary.costUsd : 0;
739
+ var costText = formatCostUsd(costUsd);
740
+ if (costTd.textContent !== costText) costTd.textContent = costText;
741
+ }
742
+
743
+ // Engine summary.
744
+ var enginesTd = row.querySelector('.rig-row-engines');
745
+ if (enginesTd) {
746
+ var enginesText = engineSummary(rig.engines);
747
+ if (enginesTd.textContent !== enginesText) enginesTd.textContent = enginesText;
609
748
  }
610
749
  }
611
750