viberadar 0.3.4 → 0.3.6

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.
@@ -46,6 +46,43 @@
46
46
  .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
47
  .header-time { font-size: 12px; color: var(--dim); }
48
48
 
49
+ /* ── Coverage button ─────────────────────────────────────────────────────── */
50
+ #covBtn {
51
+ padding: 5px 12px;
52
+ background: var(--bg);
53
+ border: 1px solid var(--border);
54
+ border-radius: 6px;
55
+ color: var(--muted);
56
+ font-size: 12px;
57
+ cursor: pointer;
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 5px;
61
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
62
+ white-space: nowrap;
63
+ }
64
+ #covBtn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
65
+ #covBtn:disabled { cursor: not-allowed; opacity: 0.7; }
66
+ #covBtn.cov-running { color: var(--yellow); border-color: var(--yellow); }
67
+ #covBtn.cov-error { color: var(--red); border-color: var(--red); }
68
+ #covBtn.cov-done { color: var(--green); border-color: var(--green); }
69
+ #termBtn {
70
+ padding: 5px 12px;
71
+ background: var(--bg);
72
+ border: 1px solid var(--border);
73
+ border-radius: 6px;
74
+ color: var(--muted);
75
+ font-size: 12px;
76
+ cursor: pointer;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 5px;
80
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
81
+ white-space: nowrap;
82
+ }
83
+ #termBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
84
+ #termBtn.term-active { color: var(--accent); border-color: var(--accent); }
85
+
49
86
  /* ── Stats bar ───────────────────────────────────────────────────────────── */
50
87
  .stats-bar {
51
88
  display: flex;
@@ -379,6 +416,45 @@
379
416
  letter-spacing: 0.5px; color: var(--muted);
380
417
  margin: 14px 0 6px;
381
418
  }
419
+
420
+ /* ── Test-type cards inside feature detail ───────────────────────────────── */
421
+ .test-type-grid {
422
+ display: grid;
423
+ grid-template-columns: repeat(4, 1fr);
424
+ gap: 10px;
425
+ margin-bottom: 20px;
426
+ }
427
+ .test-type-card {
428
+ background: var(--bg-card);
429
+ border: 1px solid var(--border);
430
+ border-radius: 8px;
431
+ padding: 14px 16px;
432
+ cursor: pointer;
433
+ transition: background 0.15s, border-color 0.15s;
434
+ position: relative;
435
+ overflow: hidden;
436
+ }
437
+ .test-type-card:hover { background: var(--bg-hover); border-color: var(--dim); }
438
+ .test-type-card .tt-accent {
439
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
440
+ }
441
+ .test-type-card .tt-label {
442
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
443
+ color: var(--muted); margin-bottom: 6px; margin-top: 2px;
444
+ }
445
+ .test-type-card .tt-count {
446
+ font-size: 26px; font-weight: 700; line-height: 1;
447
+ margin-bottom: 4px;
448
+ }
449
+ .test-type-card .tt-sub {
450
+ font-size: 11px; color: var(--dim);
451
+ }
452
+ .test-type-card.tt-empty .tt-count { color: var(--dim); }
453
+ .test-type-card.tt-empty { opacity: 0.7; }
454
+ .test-type-card.tt-active {
455
+ background: var(--bg-hover);
456
+ border-width: 2px;
457
+ }
382
458
  .file-rows { display: flex; flex-direction: column; gap: 2px; }
383
459
  .file-row {
384
460
  display: grid;
@@ -397,6 +473,90 @@
397
473
  .file-row-name { font-weight: 500; word-break: break-all; }
398
474
  .file-row-dir { font-size: 11px; color: var(--dim); text-align: right; word-break: break-word; }
399
475
 
476
+ /* ── Agent setup banner ──────────────────────────────────────────────────── */
477
+ .agent-setup-banner {
478
+ background: linear-gradient(135deg, #161b22 0%, #1c2230 100%);
479
+ border: 1px solid var(--blue);
480
+ border-radius: 10px;
481
+ padding: 24px;
482
+ margin-bottom: 20px;
483
+ text-align: center;
484
+ }
485
+ .agent-setup-banner h3 { font-size: 15px; margin-bottom: 6px; }
486
+ .agent-setup-banner p { font-size: 12px; color: var(--muted); margin-bottom: 16px; }
487
+ .agent-choices { display: flex; gap: 10px; justify-content: center; }
488
+ .agent-choice-btn {
489
+ padding: 10px 24px;
490
+ border-radius: 8px;
491
+ border: 1px solid var(--border);
492
+ background: var(--bg);
493
+ color: var(--text);
494
+ font-size: 13px;
495
+ font-weight: 600;
496
+ cursor: pointer;
497
+ transition: background 0.15s, border-color 0.15s, transform 0.1s;
498
+ }
499
+ .agent-choice-btn:hover { background: var(--bg-hover); border-color: var(--blue); transform: translateY(-1px); }
500
+
501
+ /* ── Agent card button ───────────────────────────────────────────────────── */
502
+ .agent-card-btn {
503
+ margin-top: 10px;
504
+ padding: 5px 10px;
505
+ background: var(--bg);
506
+ border: 1px solid var(--border);
507
+ border-radius: 5px;
508
+ color: var(--blue);
509
+ font-size: 11px;
510
+ font-weight: 600;
511
+ cursor: pointer;
512
+ transition: background 0.1s, border-color 0.1s;
513
+ width: 100%;
514
+ text-align: left;
515
+ }
516
+ .agent-card-btn:hover { background: var(--bg-hover); border-color: var(--blue); }
517
+ .agent-card-btn:disabled { opacity: 0.5; cursor: not-allowed; }
518
+
519
+ /* ── Agent terminal panel ────────────────────────────────────────────────── */
520
+ .agent-panel {
521
+ position: fixed;
522
+ bottom: 0; left: 0; right: 0;
523
+ height: 280px;
524
+ background: #090d13;
525
+ border-top: 1px solid var(--border);
526
+ transform: translateY(100%);
527
+ transition: transform 0.25s ease;
528
+ z-index: 200;
529
+ display: flex;
530
+ flex-direction: column;
531
+ }
532
+ .agent-panel.open { transform: translateY(0); }
533
+ .agent-panel-header {
534
+ display: flex;
535
+ align-items: center;
536
+ gap: 10px;
537
+ padding: 8px 16px;
538
+ background: var(--bg-card);
539
+ border-bottom: 1px solid var(--border);
540
+ flex-shrink: 0;
541
+ }
542
+ .agent-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
543
+ .agent-panel-status { font-size: 11px; color: var(--muted); }
544
+ .agent-panel-close {
545
+ background: none; border: none; color: var(--muted); cursor: pointer;
546
+ font-size: 14px; padding: 3px 6px; border-radius: 4px; line-height: 1;
547
+ }
548
+ .agent-panel-close:hover { background: var(--border); color: var(--text); }
549
+ .agent-terminal {
550
+ flex: 1;
551
+ overflow-y: auto;
552
+ padding: 10px 16px;
553
+ font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
554
+ font-size: 12px;
555
+ line-height: 1.5;
556
+ }
557
+ .agent-line { color: #c9d1d9; }
558
+ .agent-line.err { color: var(--red); }
559
+
400
560
  /* ── Misc ────────────────────────────────────────────────────────────────── */
401
561
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
402
562
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
@@ -409,6 +569,8 @@
409
569
  <h1>VibeRadar</h1>
410
570
  <span class="header-project" id="projectName">—</span>
411
571
  <span class="header-time" id="scannedAt"></span>
572
+ <button id="covBtn" onclick="runCoverage()" title="Запустить тесты с coverage">🧪 Coverage</button>
573
+ <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
412
574
  <span id="liveDot" title="Connecting…" style="
413
575
  width:8px; height:8px; border-radius:50%;
414
576
  background:var(--dim); display:inline-block;
@@ -439,6 +601,15 @@
439
601
  <div id="panelContent"></div>
440
602
  </div>
441
603
 
604
+ <div class="agent-panel" id="agentPanel">
605
+ <div class="agent-panel-header">
606
+ <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
607
+ <span class="agent-panel-status" id="agentPanelStatus">running…</span>
608
+ <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
609
+ </div>
610
+ <div class="agent-terminal" id="agentTerminal"></div>
611
+ </div>
612
+
442
613
  <script>
443
614
  // ─── State ────────────────────────────────────────────────────────────────────
444
615
  let D = null;
@@ -446,7 +617,79 @@ let view = 'features';
446
617
  let searchQuery = '';
447
618
  let activeTypes = new Set();
448
619
  let activePanelKey = null;
449
- let drillFeatureKey = null; // null = grid, string = inside a feature
620
+ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
621
+ let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
622
+ let coverageRunning = false;
623
+ let coverageHasError = false;
624
+
625
+ // ─── Coverage button ───────────────────────────────────────────────────────────
626
+ function updateCovBtn() {
627
+ const btn = document.getElementById('covBtn');
628
+ if (!btn) return;
629
+ btn.className = '';
630
+ btn.disabled = coverageRunning;
631
+ if (coverageRunning) {
632
+ btn.className = 'cov-running';
633
+ btn.textContent = '⏳ Running...';
634
+ } else if (coverageHasError) {
635
+ btn.className = 'cov-error';
636
+ btn.textContent = '❌ Coverage failed';
637
+ } else {
638
+ btn.textContent = '🧪 Coverage';
639
+ }
640
+ }
641
+
642
+ async function runCoverage() {
643
+ if (coverageRunning) return;
644
+ await fetch('/api/run-coverage', { method: 'POST' });
645
+ // SSE events will update state
646
+ }
647
+
648
+ // ─── Agent ────────────────────────────────────────────────────────────────────
649
+ let agentRunning = false;
650
+
651
+ async function setAgent(agent) {
652
+ await fetch('/api/set-agent', {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json' },
655
+ body: JSON.stringify({ agent }),
656
+ });
657
+ // scheduleRescan will fire → data-updated → D.agent updates
658
+ }
659
+
660
+ async function runAgentTask(task, featureKey) {
661
+ if (agentRunning) return;
662
+ document.getElementById('agentTerminal').innerHTML = '';
663
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
664
+ document.getElementById('agentPanel').classList.add('open');
665
+ document.getElementById('termBtn').classList.add('term-active');
666
+ await fetch('/api/run-agent', {
667
+ method: 'POST',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({ task, featureKey }),
670
+ });
671
+ }
672
+
673
+ function closeAgentPanel() {
674
+ document.getElementById('agentPanel').classList.remove('open');
675
+ document.getElementById('termBtn').classList.remove('term-active');
676
+ }
677
+
678
+ function toggleAgentPanel() {
679
+ const panel = document.getElementById('agentPanel');
680
+ const btn = document.getElementById('termBtn');
681
+ panel.classList.toggle('open');
682
+ btn.classList.toggle('term-active', panel.classList.contains('open'));
683
+ }
684
+
685
+ function appendTerminalLine(line, isError) {
686
+ const term = document.getElementById('agentTerminal');
687
+ const el = document.createElement('div');
688
+ el.className = 'agent-line' + (isError ? ' err' : '');
689
+ el.textContent = line;
690
+ term.appendChild(el);
691
+ term.scrollTop = term.scrollHeight;
692
+ }
450
693
 
451
694
  // ─── Color helpers ────────────────────────────────────────────────────────────
452
695
  const TYPE_COLORS = {
@@ -480,9 +723,19 @@ function pluralFiles(n) {
480
723
  // ─── Init ─────────────────────────────────────────────────────────────────────
481
724
  async function init() {
482
725
  try {
483
- const res = await fetch('/api/data');
726
+ const [res, statusRes] = await Promise.all([
727
+ fetch('/api/data'),
728
+ fetch('/api/status').catch(() => null),
729
+ ]);
484
730
  D = await res.json();
485
731
 
732
+ if (statusRes) {
733
+ const status = await statusRes.json().catch(() => ({}));
734
+ coverageRunning = status.coverageRunning ?? false;
735
+ coverageHasError = status.coverageError ?? false;
736
+ }
737
+ updateCovBtn();
738
+
486
739
  document.getElementById('projectName').textContent = D.projectName;
487
740
  document.getElementById('scannedAt').textContent =
488
741
  new Date(D.scannedAt).toLocaleTimeString();
@@ -511,7 +764,7 @@ function renderStats() {
511
764
 
512
765
  let items;
513
766
  if (D.hasConfig && D.features) {
514
- const unmapped = src.filter(m => !m.featureKeys || m.featureKeys.length === 0).length;
767
+ const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
515
768
  items = [
516
769
  { v: D.features.length, l: 'Features' },
517
770
  { v: src.length, l: 'Source Files' },
@@ -579,14 +832,24 @@ function renderSidebar() {
579
832
  function renderContent() {
580
833
  const c = document.getElementById('content');
581
834
  if (view === 'features') {
582
- drillFeatureKey ? renderFeatureDetail(c) : renderFeatureCards(c);
835
+ if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
836
+ else if (drillFeatureKey) renderFeatureDetail(c);
837
+ else renderFeatureCards(c);
583
838
  } else {
584
839
  renderModuleGrid(c);
585
840
  }
586
841
  }
587
842
 
843
+ function backToFeatureDetail() {
844
+ drillTestType = null;
845
+ activePanelKey = null;
846
+ document.getElementById('panel').classList.remove('open');
847
+ renderContent();
848
+ }
849
+
588
850
  function backToFeatures() {
589
851
  drillFeatureKey = null;
852
+ drillTestType = null;
590
853
  activePanelKey = null;
591
854
  document.getElementById('panel').classList.remove('open');
592
855
  renderContent();
@@ -612,12 +875,25 @@ function renderFeatureCards(c) {
612
875
 
613
876
  if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
614
877
 
615
- c.innerHTML = '<div class="features-grid" id="featGrid"></div>';
878
+ // Agent setup banner — shown when no agent configured
879
+ const setupBanner = !D.agent ? `
880
+ <div class="agent-setup-banner">
881
+ <h3>🤖 Выбери AI агента</h3>
882
+ <p>VibeRadar будет запускать его прямо из дашборда — писать тесты, разбирать unmapped и не только</p>
883
+ <div class="agent-choices">
884
+ <button class="agent-choice-btn" onclick="setAgent('claude')">⚡ Claude Code</button>
885
+ <button class="agent-choice-btn" onclick="setAgent('codex')">🟢 Codex (OpenAI)</button>
886
+ </div>
887
+ </div>` : '';
888
+
889
+ c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
616
890
  const grid = document.getElementById('featGrid');
617
891
 
618
892
  list.forEach(f => {
619
893
  const pct = f.fileCount > 0 ? Math.round(f.testedCount / f.fileCount * 100) : 0;
620
894
  const isActive = activePanelKey === f.key;
895
+ const hasCov = f.coveragePct != null;
896
+ const covPct = hasCov ? Math.round(f.coveragePct) : null;
621
897
 
622
898
  const card = document.createElement('div');
623
899
  card.className = 'feature-card' + (isActive ? ' active' : '');
@@ -629,21 +905,51 @@ function renderFeatureCards(c) {
629
905
  <span class="feature-file-count">${f.fileCount} ${pluralFiles(f.fileCount)}</span>
630
906
  </div>
631
907
  ${f.description ? `<div class="feature-desc">${f.description}</div>` : ''}
908
+ ${hasCov ? `
909
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
910
+ <span style="font-size:24px;font-weight:700;line-height:1;color:${covColor(covPct)}">${covPct}%</span>
911
+ <div style="flex:1">
912
+ <div style="font-size:10px;color:var(--muted);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.4px">Coverage</div>
913
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
914
+ <div style="width:${covPct}%;height:100%;background:${covColor(covPct)};border-radius:2px;transition:width 0.4s"></div>
915
+ </div>
916
+ </div>
917
+ </div>` : `
918
+ <div style="font-size:11px;color:var(--dim);margin-bottom:10px">
919
+ coverage: нет данных
920
+ </div>`}
632
921
  <div class="feature-progress-wrap">
633
922
  <div class="feature-progress-bar">
634
923
  <div class="feature-progress-fill" style="width:${pct}%;background:${f.color}"></div>
635
924
  </div>
636
- <span class="feature-progress-label" style="color:${covColor(pct)}">${f.testedCount}/${f.fileCount} ✓</span>
925
+ <span class="feature-progress-label" style="color:${covColor(pct)}">${f.testedCount}/${f.fileCount} с тестами</span>
637
926
  </div>
927
+ ${D.agent && f.fileCount > f.testedCount ? `
928
+ <button class="agent-card-btn" data-task="write-tests" data-key="${f.key}">
929
+ ▶ Написать тесты (${f.fileCount - f.testedCount} без тестов)
930
+ </button>` : ''}
638
931
  </div>`;
639
- card.onclick = () => { drillFeatureKey = f.key; activePanelKey = null; document.getElementById('panel').classList.remove('open'); renderContent(); };
932
+ card.onclick = (e) => {
933
+ if (e.target.closest('.agent-card-btn')) return; // don't drill on agent btn click
934
+ drillFeatureKey = f.key; activePanelKey = null;
935
+ document.getElementById('panel').classList.remove('open');
936
+ renderContent();
937
+ };
938
+ const agentBtn = card.querySelector('.agent-card-btn');
939
+ if (agentBtn) {
940
+ agentBtn.onclick = (e) => {
941
+ e.stopPropagation();
942
+ runAgentTask(agentBtn.dataset.task, agentBtn.dataset.key);
943
+ };
944
+ }
640
945
  grid.appendChild(card);
641
946
  });
642
947
 
643
948
  // ── Unmapped card ──────────────────────────────────────────────────────────
644
949
  if (!q) {
950
+ const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
645
951
  const unmappedSrc = D.modules.filter(m =>
646
- m.type !== 'test' && (!m.featureKeys || m.featureKeys.length === 0)
952
+ m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
647
953
  );
648
954
  if (unmappedSrc.length > 0) {
649
955
  const isActive = activePanelKey === '__unmapped__';
@@ -651,6 +957,9 @@ function renderFeatureCards(c) {
651
957
  card.className = 'feature-card' + (isActive ? ' active' : '');
652
958
  card.style.borderStyle = 'dashed';
653
959
  card.style.opacity = '0.75';
960
+ const infraNote = infraSrc.length > 0
961
+ ? `<br><span style="color:var(--dim);font-size:11px">+ ${infraSrc.length} infra/system скрыты</span>`
962
+ : '';
654
963
  card.innerHTML = `
655
964
  <div class="feature-accent" style="background:var(--yellow)"></div>
656
965
  <div class="feature-body">
@@ -658,7 +967,7 @@ function renderFeatureCards(c) {
658
967
  <span style="color:var(--yellow)">⚠ Unmapped</span>
659
968
  <span class="feature-file-count">${unmappedSrc.length} ${pluralFiles(unmappedSrc.length)}</span>
660
969
  </div>
661
- <div class="feature-desc">Файлы вне карты фич — не входят ни в одну фичу</div>
970
+ <div class="feature-desc">Файлы вне карты фич — не входят ни в одну фичу${infraNote}</div>
662
971
  <div class="feature-progress-wrap">
663
972
  <div class="feature-progress-bar">
664
973
  <div class="feature-progress-fill" style="width:100%;background:var(--border)"></div>
@@ -666,12 +975,30 @@ function renderFeatureCards(c) {
666
975
  <span class="feature-progress-label" style="color:var(--dim)">нет привязки</span>
667
976
  </div>
668
977
  </div>`;
669
- card.onclick = () => openUnmappedPanel(unmappedSrc);
978
+ card.onclick = () => {
979
+ drillFeatureKey = '__unmapped__';
980
+ activePanelKey = null;
981
+ document.getElementById('panel').classList.remove('open');
982
+ renderContent();
983
+ };
670
984
  grid.appendChild(card);
671
985
  }
672
986
  }
673
987
  }
674
988
 
989
+ function testTypeCard(type, label, icon, color, count, active) {
990
+ const empty = count === 0 && type !== 'source';
991
+ const subLabel = empty ? 'нет тестов' : (type === 'source' ? 'код приложения' : pluralFiles(count));
992
+ return `
993
+ <div class="test-type-card${empty ? ' tt-empty' : ''}${active ? ' tt-active' : ''}" data-testtype="${type}"
994
+ style="${active ? 'border-color:' + color : ''}">
995
+ <div class="tt-accent" style="background:${color}"></div>
996
+ <div class="tt-label">${icon} ${label}</div>
997
+ <div class="tt-count" style="color:${active || !empty ? color : 'var(--dim)'}">${count}</div>
998
+ <div class="tt-sub">${subLabel}</div>
999
+ </div>`;
1000
+ }
1001
+
675
1002
  function renderFeatureDetail(c) {
676
1003
  const feat = D.features.find(f => f.key === drillFeatureKey);
677
1004
  if (!feat) { backToFeatures(); return; }
@@ -682,13 +1009,24 @@ function renderFeatureDetail(c) {
682
1009
  const testedCount = src.filter(m => m.hasTests).length;
683
1010
  const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
684
1011
 
1012
+ const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
1013
+ const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
1014
+ const e2eCount = feat.e2eTestCount ?? tst.filter(m => m.testType === 'e2e').length;
1015
+
1016
+ // Determine what list to show based on active tab
1017
+ // null or 'source' → source files; test type → test files of that type
1018
+ const activeTab = drillTestType || 'source';
1019
+ const listFiles = activeTab === 'source' ? src : tst.filter(m => m.testType === activeTab);
1020
+ const isTestList = activeTab !== 'source';
1021
+ const meta = TEST_TYPE_META[activeTab];
1022
+ const listLabel = meta
1023
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
1024
+ : `📁 Файлы фичи (${listFiles.length})`;
1025
+
685
1026
  const q = searchQuery.toLowerCase();
686
- const filteredSrc = q ? src.filter(m =>
687
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
688
- ) : src;
689
- const filteredTst = q ? tst.filter(m =>
1027
+ const filtered = q ? listFiles.filter(m =>
690
1028
  m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
691
- ) : tst;
1029
+ ) : listFiles;
692
1030
 
693
1031
  c.innerHTML = `
694
1032
  <div class="drill-header">
@@ -700,21 +1038,176 @@ function renderFeatureDetail(c) {
700
1038
  <div class="drill-stats">
701
1039
  <span>${src.length} файлов</span>
702
1040
  <span style="color:${covColor(pct)}">${pct}% с тестами</span>
703
- <span>${tst.length} тест-файлов</span>
1041
+ ${feat.coveragePct != null
1042
+ ? `<span style="color:${covColor(Math.round(feat.coveragePct))};font-weight:700">${Math.round(feat.coveragePct)}% coverage</span>`
1043
+ : `<span style="color:var(--dim);font-size:11px" title="Нажми 🧪 Coverage в шапке">coverage: нет данных</span>`
1044
+ }
704
1045
  </div>
705
1046
  </div>
706
1047
  ${feat.description ? `<div class="drill-desc">${feat.description}</div>` : ''}
1048
+
1049
+ <div class="test-type-grid">
1050
+ ${testTypeCard('source', 'Файлы', '📁', feat.color, src.length, activeTab === 'source')}
1051
+ ${testTypeCard('unit', 'Unit', '🧪', '#e3b341', unitCount, activeTab === 'unit')}
1052
+ ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration')}
1053
+ ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e')}
1054
+ </div>
1055
+
1056
+ <div class="drill-section-label">${listLabel}</div>
1057
+ <div class="file-rows" id="fileRows">
1058
+ ${filtered.length === 0
1059
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
1060
+ ${isTestList ? 'Нет тестов этого типа для данной фичи' : 'Нет файлов — возможно паттерны в конфиге не совпадают'}
1061
+ </div>`
1062
+ : filtered.map(m => fileRow(m, isTestList)).join('')
1063
+ }
1064
+ </div>`;
1065
+
1066
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
1067
+ card.onclick = () => {
1068
+ const type = card.dataset.testtype;
1069
+ drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
1070
+ activePanelKey = null;
1071
+ document.getElementById('panel').classList.remove('open');
1072
+ renderContent();
1073
+ };
1074
+ });
1075
+
1076
+ c.querySelectorAll('.file-row[data-id]').forEach(row => {
1077
+ row.onclick = () => {
1078
+ const m = D.modules.find(m => m.id === row.dataset.id);
1079
+ if (m) openModulePanel(m);
1080
+ };
1081
+ });
1082
+ }
1083
+
1084
+ const TEST_TYPE_META = {
1085
+ unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
1086
+ integration: { label: 'Integration', icon: '🔗', color: '#58a6ff', desc: 'Тесты с реальной БД и зависимостями' },
1087
+ e2e: { label: 'E2E', icon: '🎭', color: '#d2a8ff', desc: 'Сквозные тесты через браузер (Playwright)' },
1088
+ };
1089
+
1090
+ function renderTestTypeDetail(c) {
1091
+ const feat = D.features.find(f => f.key === drillFeatureKey);
1092
+ if (!feat) { backToFeatures(); return; }
1093
+ const meta = TEST_TYPE_META[drillTestType] || { label: drillTestType, icon: '🧪', color: '#58a6ff', desc: '' };
1094
+
1095
+ const tests = D.modules.filter(m =>
1096
+ m.type === 'test' &&
1097
+ m.testType === drillTestType &&
1098
+ m.featureKeys && m.featureKeys.includes(drillFeatureKey)
1099
+ );
1100
+
1101
+ const q = searchQuery.toLowerCase();
1102
+ const filtered = q ? tests.filter(m =>
1103
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1104
+ ) : tests;
1105
+
1106
+ c.innerHTML = `
1107
+ <div class="drill-header">
1108
+ <button class="back-btn" onclick="backToFeatureDetail()">← ${feat.label}</button>
1109
+ <div class="drill-title">
1110
+ <span>${meta.icon}</span>
1111
+ <span>${meta.label} тесты</span>
1112
+ </div>
1113
+ <div class="drill-stats">
1114
+ <span style="color:${meta.color}">${tests.length} ${pluralFiles(tests.length)}</span>
1115
+ </div>
1116
+ </div>
1117
+ <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
1118
+
1119
+ <div class="file-rows" id="fileRows">
1120
+ ${filtered.length === 0
1121
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
1122
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
1123
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
1124
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
1125
+ </div>`
1126
+ : filtered.map(m => fileRow(m, true)).join('')
1127
+ }
1128
+ </div>`;
1129
+
1130
+ c.querySelectorAll('.file-row[data-id]').forEach(row => {
1131
+ row.onclick = () => {
1132
+ const m = D.modules.find(m => m.id === row.dataset.id);
1133
+ if (m) openModulePanel(m);
1134
+ };
1135
+ });
1136
+ }
1137
+
1138
+ function renderUnmappedDetail(c) {
1139
+ const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
1140
+ const unmappedSrc = D.modules.filter(m =>
1141
+ m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
1142
+ );
1143
+
1144
+ const q = searchQuery.toLowerCase();
1145
+ const filtered = q ? unmappedSrc.filter(m =>
1146
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1147
+ ) : unmappedSrc;
1148
+
1149
+ // Build prompt text
1150
+ const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
1151
+ const plainList = unmappedSrc.map(m => '- ' + m.relativePath.replace(/\\/g, '/')).join('\n');
1152
+ const promptText =
1153
+ `В проекте ${unmappedSrc.length} файлов без привязки к фичам (unmapped).\n` +
1154
+ `\nДля каждого файла из списка реши:\n` +
1155
+ `1. Если файл относится к конкретной фиче → добавь его путь в "include" этой фичи в viberadar.config.json\n` +
1156
+ `2. Если это инфраструктура (утилиты, конфиги, middleware, типы, бутстрап) → добавь glob в массив "ignore"\n` +
1157
+ `3. Если файл явно бизнес-логика новой фичи → создай новую фичу\n` +
1158
+ `4. Если непонятно — пропусти\n` +
1159
+ `\nСуществующие фичи:\n${featureList}\n` +
1160
+ `\nФайлы:\n${plainList}`;
1161
+
1162
+ const infraNote = infraSrc.length > 0
1163
+ ? `<span style="color:var(--dim);font-size:12px">+ ${infraSrc.length} infra/system скрыты (в ignore)</span>`
1164
+ : '';
1165
+
1166
+ c.innerHTML = `
1167
+ <div class="drill-header">
1168
+ <button class="back-btn" onclick="backToFeatures()">← Все фичи</button>
1169
+ <div class="drill-title">
1170
+ <span style="color:var(--yellow)">⚠</span>
1171
+ <span>Unmapped файлы</span>
1172
+ </div>
1173
+ <div class="drill-stats">
1174
+ <span style="color:var(--yellow)">${unmappedSrc.length} без привязки</span>
1175
+ ${infraNote}
1176
+ </div>
1177
+ </div>
1178
+ <div style="padding:0 0 12px;display:flex;gap:8px;flex-wrap:wrap">
1179
+ ${D.agent ? `<button id="runAgentUnmapped" style="
1180
+ padding:7px 14px; background:var(--blue); border:none;
1181
+ border-radius:6px; color:#000; font-size:12px; font-weight:700; cursor:pointer;
1182
+ ">▶ Разобрать через ${D.agent === 'claude' ? 'Claude Code' : 'Codex'}</button>` : ''}
1183
+ <button id="copyUnmappedDrill" style="
1184
+ padding:7px 14px; background:var(--bg-card); border:1px solid var(--border);
1185
+ border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
1186
+ ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
1187
+ </div>
707
1188
  <div class="file-rows" id="fileRows">
708
- ${filteredSrc.length === 0 && !q
709
- ? '<div style="font-size:13px;color:var(--dim)">Нет файлов — возможно паттерны в конфиге не совпадают</div>'
710
- : filteredSrc.map(m => fileRow(m)).join('')
1189
+ ${filtered.length === 0
1190
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
1191
+ : filtered.map(m => fileRow(m)).join('')
711
1192
  }
712
- ${filteredTst.length > 0 ? `
713
- <div class="drill-section-label">Тест-файлы (${filteredTst.length})</div>
714
- ${filteredTst.map(m => fileRow(m, true)).join('')}
715
- ` : ''}
716
1193
  </div>`;
717
1194
 
1195
+ const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
1196
+ if (runAgentUnmappedBtn) {
1197
+ runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
1198
+ }
1199
+
1200
+ document.getElementById('copyUnmappedDrill').onclick = function() {
1201
+ navigator.clipboard.writeText(promptText).then(() => {
1202
+ this.textContent = '✅ Скопировано!';
1203
+ this.style.color = 'var(--green)';
1204
+ setTimeout(() => {
1205
+ this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
1206
+ this.style.color = 'var(--blue)';
1207
+ }, 3000);
1208
+ });
1209
+ };
1210
+
718
1211
  c.querySelectorAll('.file-row[data-id]').forEach(row => {
719
1212
  row.onclick = () => {
720
1213
  const m = D.modules.find(m => m.id === row.dataset.id);
@@ -865,6 +1358,7 @@ function openModulePanel(m) {
865
1358
  <span>Тесты</span>
866
1359
  <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓ есть' : '✗ нет'}</span>
867
1360
  </div>
1361
+ ${m.testCount != null ? `<div class="detail-row"><span>Тест-кейсов</span><span style="color:var(--green);font-weight:600">${m.testCount}</span></div>` : ''}
868
1362
  ${m.testFile ? `<div class="detail-row"><span>Тест-файл</span><span class="detail-row-right">${m.testFile}</span></div>` : ''}
869
1363
  ${featureLabels ? `<div class="detail-row"><span>Фичи</span><span class="detail-row-right">${featureLabels}</span></div>` : ''}
870
1364
  </div>
@@ -892,7 +1386,8 @@ function openModulePanel(m) {
892
1386
  document.getElementById('panel').classList.add('open');
893
1387
  }
894
1388
 
895
- function openUnmappedPanel(files) {
1389
+ function openUnmappedPanel(files, infraFiles) {
1390
+ infraFiles = infraFiles || [];
896
1391
  activePanelKey = '__unmapped__';
897
1392
  renderContent();
898
1393
 
@@ -906,6 +1401,9 @@ function openUnmappedPanel(files) {
906
1401
  });
907
1402
  const dirs = Object.keys(byDir).sort();
908
1403
 
1404
+ // Build feature list for context
1405
+ const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
1406
+
909
1407
  // Build plain-text list for copying to AI agent
910
1408
  const plainList = files
911
1409
  .map(m => '- ' + m.relativePath.replace(/\\/g, '/'))
@@ -913,26 +1411,38 @@ function openUnmappedPanel(files) {
913
1411
 
914
1412
  const promptText =
915
1413
  `В проекте ${files.length} файлов без привязки к фичам (unmapped).\n` +
916
- `Изучи список ниже и обнови viberadar.config.json:\n` +
917
- `добавь эти файлы в существующие фичи или создай новые.\n\n` +
918
- plainList;
1414
+ `\nДля каждого файла из списка реши:\n` +
1415
+ `1. Если файл относится к конкретной фиче → добавь его путь в "include" этой фичи в viberadar.config.json\n` +
1416
+ `2. Если это инфраструктура (утилиты, конфиги, middleware, типы, бутстрап) → добавь glob в массив "ignore"\n` +
1417
+ `3. Если файл явно бизнес-логика новой фичи → создай новую фичу\n` +
1418
+ `4. Если непонятно — пропусти\n` +
1419
+ `\nСуществующие фичи:\n${featureList}\n` +
1420
+ `\nФайлы:\n${plainList}`;
1421
+
1422
+ const infraNote = infraFiles.length > 0
1423
+ ? `<div style="margin-bottom:12px;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;color:var(--dim)">
1424
+ 🔒 <b style="color:var(--text)">${infraFiles.length} infra/system файлов</b> скрыты (добавлены в <code>ignore</code>)<br>
1425
+ Они не считаются unmapped и не показываются в карте фич.
1426
+ </div>`
1427
+ : '';
919
1428
 
920
1429
  document.getElementById('panelContent').innerHTML = `
921
1430
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
922
1431
  <span style="font-size:16px">⚠</span>
923
1432
  <div class="panel-title" style="color:var(--yellow)">Unmapped файлы</div>
924
1433
  </div>
925
- <div class="panel-subtitle">
926
- Не входят ни в одну фичу.<br>
927
- Запусти <code style="color:var(--blue)">npx viberadar init</code> — агент предложит куда добавить.
1434
+ <div class="panel-subtitle" style="margin-bottom:12px">
1435
+ Не входят ни в одну фичу. Скопируй список и отправь AI-агенту.
928
1436
  </div>
929
1437
 
1438
+ ${infraNote}
1439
+
930
1440
  <button id="copyUnmapped" style="
931
1441
  width:100%; padding:8px 12px; margin-bottom:16px;
932
1442
  background:var(--bg); border:1px solid var(--border);
933
1443
  border-radius:6px; color:var(--blue); font-size:12px;
934
1444
  cursor:pointer; text-align:left;
935
- ">📋 Скопировать список для AI-агента</button>
1445
+ ">📋 Скопировать промпт для AI-агента (${files.length} файлов)</button>
936
1446
 
937
1447
  ${dirs.map(dir => `
938
1448
  <div class="panel-section">
@@ -969,6 +1479,7 @@ document.querySelectorAll('.view-tab').forEach(tab => {
969
1479
  if (tab.classList.contains('disabled')) return;
970
1480
  view = tab.dataset.view;
971
1481
  drillFeatureKey = null;
1482
+ drillTestType = null;
972
1483
  activePanelKey = null;
973
1484
  searchQuery = '';
974
1485
  activeTypes.clear();
@@ -1010,11 +1521,8 @@ async function refreshData() {
1010
1521
  // Re-render drill-down or re-open panel
1011
1522
  const panelOpen = document.getElementById('panel').classList.contains('open');
1012
1523
  if (panelOpen && activePanelKey) {
1013
- if (activePanelKey === '__unmapped__') {
1014
- const unmapped = D.modules.filter(m =>
1015
- m.type !== 'test' && (!m.featureKeys || m.featureKeys.length === 0)
1016
- );
1017
- openUnmappedPanel(unmapped);
1524
+ if (drillFeatureKey === '__unmapped__') {
1525
+ renderContent(); // already routes to renderUnmappedDetail
1018
1526
  } else if (view === 'features' && D.features) {
1019
1527
  openFeaturePanel(activePanelKey);
1020
1528
  } else {
@@ -1042,6 +1550,78 @@ function connectSSE() {
1042
1550
  refreshData();
1043
1551
  });
1044
1552
 
1553
+ es.addEventListener('coverage-started', () => {
1554
+ coverageRunning = true;
1555
+ coverageHasError = false;
1556
+ updateCovBtn();
1557
+ });
1558
+
1559
+ es.addEventListener('coverage-done', () => {
1560
+ coverageRunning = false;
1561
+ coverageHasError = false;
1562
+ updateCovBtn();
1563
+ // data-updated fires separately and triggers refreshData()
1564
+ });
1565
+
1566
+ es.addEventListener('coverage-error', () => {
1567
+ coverageRunning = false;
1568
+ coverageHasError = true;
1569
+ updateCovBtn();
1570
+ setTimeout(() => { coverageHasError = false; updateCovBtn(); }, 8000);
1571
+ });
1572
+
1573
+ es.addEventListener('agent-started', (e) => {
1574
+ agentRunning = true;
1575
+ const { title } = JSON.parse(e.data);
1576
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
1577
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1578
+ document.getElementById('agentPanel').classList.add('open');
1579
+ document.getElementById('termBtn').classList.add('term-active');
1580
+ document.getElementById('agentTerminal').innerHTML = '';
1581
+ });
1582
+
1583
+ es.addEventListener('agent-output', (e) => {
1584
+ const { line, isError } = JSON.parse(e.data);
1585
+ appendTerminalLine(line, !!isError);
1586
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
1587
+ });
1588
+
1589
+ es.addEventListener('agent-done', () => {
1590
+ agentRunning = false;
1591
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
1592
+ renderContent();
1593
+ });
1594
+
1595
+ es.addEventListener('agent-summary', (e) => {
1596
+ const { passed, failed, files } = JSON.parse(e.data);
1597
+ const term = document.getElementById('agentTerminal');
1598
+ const allOk = failed === 0;
1599
+ const box = document.createElement('div');
1600
+ box.style.cssText = `
1601
+ margin: 10px 0 4px;
1602
+ padding: 10px 14px;
1603
+ border-radius: 8px;
1604
+ border: 1px solid ${allOk ? 'var(--green)' : 'var(--red)'};
1605
+ background: ${allOk ? '#0d2a1a' : '#2a0d0d'};
1606
+ font-family: inherit;
1607
+ `;
1608
+ box.innerHTML = `
1609
+ <div style="font-size:13px;font-weight:700;color:${allOk ? 'var(--green)' : 'var(--red)'}">
1610
+ ${allOk ? '✅' : '⚠️'} Тесты: ${passed} passed${failed > 0 ? ', ' + failed + ' failed' : ''}
1611
+ </div>
1612
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
1613
+ `;
1614
+ term.appendChild(box);
1615
+ term.scrollTop = term.scrollHeight;
1616
+ });
1617
+
1618
+ es.addEventListener('agent-error', (e) => {
1619
+ agentRunning = false;
1620
+ const { message } = JSON.parse(e.data);
1621
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
1622
+ appendTerminalLine('❌ ' + (message || 'Ошибка агента'), true);
1623
+ });
1624
+
1045
1625
  es.onerror = () => {
1046
1626
  setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
1047
1627
  es.close();