diff --git a/jig-core/src/main/resources/templates/assets/package.js b/jig-core/src/main/resources/templates/assets/package.js index 85ec8cdf2..819b19993 100644 --- a/jig-core/src/main/resources/templates/assets/package.js +++ b/jig-core/src/main/resources/templates/assets/package.js @@ -1,112 +1,95 @@ -let packageSummaryCache = null; -let diagramNodeIdToFqn = new Map(); -let aggregationDepth = 0; -let diagramElement = null; -let pendingDiagramRender = null; -let lastDiagramSource = ''; -let lastDiagramEdgeCount = 0; -const DEFAULT_MAX_EDGES = 500; -let packageFilterFqn = null; -let relatedFilterMode = 'direct'; -let relatedFilterFqn = null; -let diagramDirection = 'TD'; -let transitiveReductionEnabled = true; - -function getOrCreateDiagramErrorBox(diagram) { - let errorBox = document.getElementById('package-diagram-error'); - if (errorBox) return errorBox; - errorBox = document.createElement('div'); - errorBox.id = 'package-diagram-error'; - errorBox.setAttribute('role', 'alert'); - errorBox.style.display = 'none'; - errorBox.style.whiteSpace = 'pre-wrap'; - errorBox.style.border = '1px solid #cc3333'; - errorBox.style.background = '#fff5f5'; - errorBox.style.color = '#222222'; - errorBox.style.padding = '8px 12px'; - errorBox.style.margin = '12px 0'; - - const message = document.createElement('pre'); - message.id = 'package-diagram-error-message'; - message.style.whiteSpace = 'pre-wrap'; - message.style.margin = '0 0 8px 0'; - - const action = document.createElement('button'); - action.id = 'package-diagram-error-action'; - action.type = 'button'; - action.style.display = 'none'; - action.textContent = '描画する'; - - errorBox.appendChild(message); - errorBox.appendChild(action); - diagram.parentNode.insertBefore(errorBox, diagram); - return errorBox; -} - -function showDiagramErrorMessage(message, withAction, err, hash) { - const diagram = diagramElement; - if (!diagram) return; - console.error(message); - if (err) { - console.error(err); - } - if (hash) { - console.error('Mermaid error location:', hash.line, hash.loc); - } - const errorBox = getOrCreateDiagramErrorBox(diagram); - const messageNode = document.getElementById('package-diagram-error-message'); - const actionNode = document.getElementById('package-diagram-error-action'); - if (messageNode) messageNode.textContent = message; - if (actionNode) { - actionNode.style.display = withAction ? '' : 'none'; - if (withAction) { - actionNode.onclick = function () { - if (!pendingDiagramRender) return; - renderDiagramSvg(pendingDiagramRender.text, pendingDiagramRender.maxEdges); - pendingDiagramRender = null; - }; - } else { - actionNode.onclick = null; - } - } - errorBox.style.display = ''; - diagram.style.display = 'none'; -} - -function hideDiagramErrorMessage(diagram) { - const errorBox = getOrCreateDiagramErrorBox(diagram); - const messageNode = document.getElementById('package-diagram-error-message'); - const actionNode = document.getElementById('package-diagram-error-action'); - if (messageNode) messageNode.textContent = ''; - if (actionNode) { - actionNode.style.display = 'none'; - actionNode.onclick = null; - } - errorBox.style.display = 'none'; - diagram.style.display = ''; -} - -function renderDiagramSvg(text, maxEdges) { - const diagram = diagramElement; - if (!diagram || !window.mermaid) return; - hideDiagramErrorMessage(diagram); - diagram.removeAttribute('data-processed'); - diagram.textContent = text; - mermaid.initialize({startOnLoad: false, securityLevel: 'loose', maxEdges: maxEdges}); - mermaid.run({nodes: [diagram]}); +const packageContext = { + packageSummaryCache: null, + diagramNodeIdToFqn: new Map(), + aggregationDepth: 0, + diagramElement: null, + pendingDiagramRender: null, + lastDiagramSource: '', + lastDiagramEdgeCount: 0, + DEFAULT_MAX_EDGES: 500, + packageFilterFqn: null, + relatedFilterMode: 'direct', + relatedFilterFqn: null, + diagramDirection: 'TD', + transitiveReductionEnabled: true, +}; + +const DIAGRAM_CLICK_HANDLER_NAME = 'filterPackageDiagram'; + +const dom = { + getRelatedFilterTarget: () => document.getElementById('related-filter-target'), + setRelatedFilterTargetText: (element, text) => { if (element) element.textContent = text; }, + + getPackageTableBody: () => document.querySelector('#package-table tbody'), + getPackageTableRows: () => document.querySelectorAll('#package-table tbody tr'), + getPackageFilterInput: () => document.getElementById('package-filter-input'), + getApplyPackageFilterButton: () => document.getElementById('apply-package-filter'), + getClearPackageFilterButton: () => document.getElementById('clear-package-filter'), + getDepthSelect: () => document.getElementById('package-depth-select'), + getRelatedModeSelect: () => document.getElementById('related-mode-select'), + getClearRelatedFilterButton: () => document.getElementById('clear-related-filter'), + getDiagramDirectionRadios: () => document.querySelectorAll('input[name="diagram-direction"]'), + getDiagramDirectionRadio: () => document.querySelector('input[name="diagram-direction"]'), + getMutualDependencyList: () => document.getElementById('mutual-dependency-list'), + getDiagram: () => document.getElementById('package-relation-diagram'), + getDocumentBody: () => document.body, + + getDiagramErrorBox: () => document.getElementById('package-diagram-error'), + createDiagramErrorBox: (diagram) => { + let errorBox = document.createElement('div'); + errorBox.id = 'package-diagram-error'; + errorBox.setAttribute('role', 'alert'); + errorBox.style.display = 'none'; // Initially hidden + errorBox.style.whiteSpace = 'pre-wrap'; + errorBox.style.border = '1px solid #cc3333'; + errorBox.style.background = '#fff5f5'; + errorBox.style.color = '#222222'; + errorBox.style.padding = '8px 12px'; + errorBox.style.margin = '12px 0'; + // Message and action nodes created here too and appended + const message = document.createElement('pre'); + message.id = 'package-diagram-error-message'; + message.style.whiteSpace = 'pre-wrap'; + message.style.margin = '0 0 8px 0'; + + const action = document.createElement('button'); + action.id = 'package-diagram-error-action'; + action.type = 'button'; + action.style.display = 'none'; + action.textContent = '描画する'; + + errorBox.appendChild(message); + errorBox.appendChild(action); + diagram.parentNode.insertBefore(errorBox, diagram); // Insert into DOM + return errorBox; + }, + getDiagramErrorMessageNode: () => document.getElementById('package-diagram-error-message'), + getDiagramErrorActionNode: () => document.getElementById('package-diagram-error-action'), + setNodeTextContent: (element, text) => { if (element) element.textContent = text; }, + setNodeDisplay: (element, display) => { if (element) element.style.display = display; }, + setNodeOnClick: (element, handler) => { if (element) element.onclick = handler; }, + setDiagramElementDisplay: (diagram, display) => { if (diagram) diagram.style.display = display; }, + setDiagramContent: (element, content) => { if (element) element.textContent = content; }, + removeDiagramAttribute: (element, attribute) => { if (element) element.removeAttribute(attribute); }, + getPackageDataScript: () => document.getElementById('package-data'), + getNodeTextContent: (element) => { return element ? element.textContent : ''; }, +}; + +function getPackageSummaryData(context) { + if (context.packageSummaryCache) return context.packageSummaryCache; + const jsonText = dom.getNodeTextContent(dom.getPackageDataScript()); + context.packageSummaryCache = parsePackageSummaryData(jsonText); + return context.packageSummaryCache; } -function getPackageSummaryData() { - if (packageSummaryCache) return packageSummaryCache; - const jsonText = document.getElementById('package-data').textContent; +function parsePackageSummaryData(jsonText) { /** @type {{packages?: Array<{fqn: string, name: string, classCount: number, description: string}>, relations?: Array<{from: string, to: string}>, causeRelationEvidence?: Array<{from: string, to: string}>} | Array<{fqn: string, name: string, classCount: number, description: string}>} */ const packageData = JSON.parse(jsonText); - packageSummaryCache = { + return { packages: Array.isArray(packageData) ? packageData : (packageData.packages ?? []), relations: Array.isArray(packageData) ? [] : (packageData.relations ?? []), causeRelationEvidence: Array.isArray(packageData) ? [] : (packageData.causeRelationEvidence ?? []), }; - return packageSummaryCache; } function getPackageDepth(fqn) { @@ -114,8 +97,8 @@ function getPackageDepth(fqn) { return fqn.split('.').length; } -function getMaxPackageDepth() { - const {packages} = getPackageSummaryData(); +function getMaxPackageDepth(context) { + const {packages} = getPackageSummaryData(context); return packages.reduce((max, item) => Math.max(max, getPackageDepth(item.fqn)), 0); } @@ -177,7 +160,22 @@ function buildAggregationStatsForPackageFilter(packages, relations, packageFilte return buildAggregationStats(filteredPackages, filteredRelations, maxDepth); } -function buildAggregationStatsForFilters(packages, relations, packageFilterFqn, relatedFilterFqn, maxDepth) { +function buildAggregationStatsForRelated(packages, relations, rootFqn, maxDepth, aggregationDepth, relatedFilterMode) { + if (!rootFqn) { + return buildAggregationStats(packages, relations, maxDepth); + } + const aggregatedRoot = getAggregatedFqn(rootFqn, aggregationDepth); + const relatedSet = collectRelatedSet(aggregatedRoot, relations, aggregationDepth, relatedFilterMode); + const relatedPackages = packages.filter(item => relatedSet.has(getAggregatedFqn(item.fqn, aggregationDepth))); + const relatedRelations = relations.filter(relation => { + const from = getAggregatedFqn(relation.from, aggregationDepth); + const to = getAggregatedFqn(relation.to, aggregationDepth); + return relatedSet.has(from) && relatedSet.has(to); + }); + return buildAggregationStats(relatedPackages, relatedRelations, maxDepth); +} + +function buildAggregationStatsForFilters(packages, relations, packageFilterFqn, relatedFilterFqn, maxDepth, aggregationDepth, relatedFilterMode) { const withinPackageFilter = fqn => { if (!packageFilterFqn) return true; const prefix = `${packageFilterFqn}.`; @@ -190,7 +188,7 @@ function buildAggregationStatsForFilters(packages, relations, packageFilterFqn, if (relatedFilterFqn) { const aggregatedRoot = getAggregatedFqn(relatedFilterFqn, aggregationDepth); - const relatedSet = collectRelatedSet(aggregatedRoot, filteredRelations); + const relatedSet = collectRelatedSet(aggregatedRoot, filteredRelations, aggregationDepth, relatedFilterMode); filteredPackages = filteredPackages.filter(item => relatedSet.has(getAggregatedFqn(item.fqn, aggregationDepth)) ); @@ -211,151 +209,64 @@ function buildAggregationStatsForFilters(packages, relations, packageFilterFqn, return buildAggregationStats(filteredPackages, filteredRelations, maxDepth); } -function buildAggregationStatsForRelated(packages, relations, rootFqn, maxDepth) { - if (!rootFqn) { - return buildAggregationStats(packages, relations, maxDepth); - } - const aggregatedRoot = getAggregatedFqn(rootFqn, aggregationDepth); - const relatedSet = collectRelatedSet(aggregatedRoot, relations); - const relatedPackages = packages.filter(item => relatedSet.has(getAggregatedFqn(item.fqn, aggregationDepth))); - const relatedRelations = relations.filter(relation => { - const from = getAggregatedFqn(relation.from, aggregationDepth); - const to = getAggregatedFqn(relation.to, aggregationDepth); - return relatedSet.has(from) && relatedSet.has(to); - }); - return buildAggregationStats(relatedPackages, relatedRelations, maxDepth); +function normalizePackageFilterValue(value) { + const trimmed = (value ?? '').trim(); + return trimmed ? trimmed : null; } -function renderPackageTable() { - const {packages, relations} = getPackageSummaryData(); - const incomingCounts = new Map(); - const outgoingCounts = new Map(); - relations.forEach(relation => { - outgoingCounts.set(relation.from, (outgoingCounts.get(relation.from) ?? 0) + 1); - incomingCounts.set(relation.to, (incomingCounts.get(relation.to) ?? 0) + 1); - }); - - const tbody = document.querySelector('#package-table tbody'); - - const input = document.getElementById('package-filter-input'); - const applyFilter = fqn => { - if (input) { - input.value = fqn; - } - packageFilterFqn = fqn; - renderDiagramAndTable(); - renderRelatedFilterTarget(); - }; - const applyRelatedFilterForRow = fqn => { - applyRelatedFilter(fqn); - }; - - packages.forEach(item => { - const tr = document.createElement('tr'); - - const actionTd = document.createElement('td'); - const actionButton = document.createElement('button'); - actionButton.type = 'button'; - actionButton.className = 'package-filter-icon'; - actionButton.setAttribute('aria-label', 'このパッケージで絞り込み'); - const actionText = document.createElement('span'); - actionText.className = 'screen-reader-only'; - actionText.textContent = '絞り込み'; - actionButton.appendChild(actionText); - actionButton.addEventListener('click', () => applyFilter(item.fqn)); - actionTd.appendChild(actionButton); - tr.appendChild(actionTd); - - const relatedTd = document.createElement('td'); - const relatedButton = document.createElement('button'); - relatedButton.type = 'button'; - relatedButton.className = 'related-icon'; - relatedButton.setAttribute('aria-label', '関連のみ表示'); - const relatedText = document.createElement('span'); - relatedText.className = 'screen-reader-only'; - relatedText.textContent = '関連のみ表示'; - relatedButton.appendChild(relatedText); - relatedButton.addEventListener('click', () => applyRelatedFilterForRow(item.fqn)); - relatedTd.appendChild(relatedButton); - tr.appendChild(relatedTd); - - const fqnTd = document.createElement('td'); - fqnTd.textContent = item.fqn; - fqnTd.className = 'fqn'; - tr.appendChild(fqnTd); - - const nameTd = document.createElement('td'); - nameTd.textContent = item.name; - tr.appendChild(nameTd); - - const classCountTd = document.createElement('td'); - classCountTd.textContent = String(item.classCount); - classCountTd.className = 'number'; - tr.appendChild(classCountTd); - - const incomingCountTd = document.createElement('td'); - incomingCountTd.textContent = String(incomingCounts.get(item.fqn) ?? 0); - incomingCountTd.className = 'number'; - tr.appendChild(incomingCountTd); - - const outgoingCountTd = document.createElement('td'); - outgoingCountTd.textContent = String(outgoingCounts.get(item.fqn) ?? 0); - outgoingCountTd.className = 'number'; - tr.appendChild(outgoingCountTd); +function normalizeAggregationDepthValue(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} - tbody.appendChild(tr); +function findDefaultPackageFilterCandidate(packages) { + const domainRoots = packages + .map(item => item.fqn) + .map(fqn => { + const parts = fqn.split('.'); + const domainIndex = parts.indexOf('domain'); + if (domainIndex === -1) return null; + return parts.slice(0, domainIndex + 1).join('.'); + }) + .filter(Boolean); + if (domainRoots.length === 0) return null; + return domainRoots.reduce((best, current) => { + const bestDepth = best.split('.').length; + const currentDepth = current.split('.').length; + return currentDepth < bestDepth ? current : best; }); } -function applyPackageFilterToTable(packageFilterFqn) { - const rows = document.querySelectorAll('#package-table tbody tr'); +function buildPackageRowVisibility(rowFqns, packageFilterFqn) { const filterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; - rows.forEach(row => { - const fqnCell = row.querySelector('td.fqn'); - const fqn = fqnCell ? fqnCell.textContent : ''; - const visible = !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(filterPrefix); - row.classList.toggle('hidden', !visible); - }); + return rowFqns.map(fqn => + !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(filterPrefix) + ); } -function applyRelatedFilterToTable(fqn) { - const rows = document.querySelectorAll('#package-table tbody tr'); +function buildRelatedRowVisibility(rowFqns, relations, packageFilterFqn, aggregationDepth, relatedFilterMode, relatedFilterFqn) { const packageFilterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; const withinPackageFilter = rowFqn => !packageFilterFqn || rowFqn === packageFilterFqn || rowFqn.startsWith(packageFilterPrefix); - if (!fqn) { - rows.forEach(row => { - const fqnCell = row.querySelector('td.fqn'); - const rowFqn = fqnCell ? fqnCell.textContent : ''; - row.classList.toggle('hidden', !withinPackageFilter(rowFqn)); - }); - return; + if (!relatedFilterFqn) { + return rowFqns.map(rowFqn => withinPackageFilter(rowFqn)); } - const {relations} = getPackageSummaryData(); + const filteredRelations = packageFilterFqn ? relations.filter(relation => withinPackageFilter(relation.from) && withinPackageFilter(relation.to) ) : relations; - const aggregatedRoot = getAggregatedFqn(fqn, aggregationDepth); - const relatedSet = collectRelatedSet(aggregatedRoot, filteredRelations); - rows.forEach(row => { - const fqnCell = row.querySelector('td.fqn'); - const rowFqn = fqnCell ? fqnCell.textContent : ''; + const aggregatedRoot = getAggregatedFqn(relatedFilterFqn, aggregationDepth); + const relatedSet = collectRelatedSet(aggregatedRoot, filteredRelations, aggregationDepth, relatedFilterMode); + return rowFqns.map(rowFqn => { const aggregatedRow = getAggregatedFqn(rowFqn, aggregationDepth); - const visible = withinPackageFilter(rowFqn) && relatedSet.has(aggregatedRow); - row.classList.toggle('hidden', !visible); + return withinPackageFilter(rowFqn) && relatedSet.has(aggregatedRow); }); } -function renderRelatedFilterTarget() { - const target = document.getElementById('related-filter-target'); - if (!target) return; - target.textContent = relatedFilterFqn ? relatedFilterFqn : '未選択'; -} - -function collectRelatedSet(root, relations) { +function collectRelatedSet(root, relations, aggregationDepth, relatedFilterMode) { if (!root) return new Set(); if (relatedFilterMode === 'direct') { const relatedSet = new Set([root]); @@ -397,20 +308,277 @@ function collectRelatedSet(root, relations) { return relatedSet; } -function renderDiagramAndTable() { - renderPackageDiagram(packageFilterFqn, relatedFilterFqn); - applyRelatedFilterToTable(relatedFilterFqn); - updateAggregationDepthOptions(getMaxPackageDepth()); +function buildVisibleDiagramRelations(packages, relations, causeRelationEvidence, packageFilterFqn, aggregationDepth, transitiveReductionEnabled) { + const packageFilterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; + const withinPackageFilter = fqn => + !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(packageFilterPrefix); + const visiblePackages = packageFilterFqn + ? packages.filter(item => withinPackageFilter(item.fqn)) + : packages; + const visibleSet = new Set(visiblePackages.map(item => getAggregatedFqn(item.fqn, aggregationDepth))); + const filteredRelations = packageFilterFqn + ? relations.filter(relation => withinPackageFilter(relation.from) && withinPackageFilter(relation.to)) + : relations; + const filteredCauseRelationEvidence = packageFilterFqn + ? causeRelationEvidence.filter(relation => { + const fromPackage = getPackageFqnFromTypeFqn(relation.from); + const toPackage = getPackageFqnFromTypeFqn(relation.to); + return withinPackageFilter(fromPackage) && withinPackageFilter(toPackage); + }) + : causeRelationEvidence; + const visibleRelations = filteredRelations + .map(relation => ({ + from: getAggregatedFqn(relation.from, aggregationDepth), + to: getAggregatedFqn(relation.to, aggregationDepth), + })) + .filter(relation => relation.from !== relation.to); + const uniqueRelationMap = new Map(); + visibleRelations.forEach(relation => { + uniqueRelationMap.set(`${relation.from}::${relation.to}`, relation); + }); + let uniqueRelations = Array.from(uniqueRelationMap.values()); + + if (transitiveReductionEnabled) { + uniqueRelations = transitiveReduction(uniqueRelations); + } + + return {uniqueRelations, visibleSet, filteredCauseRelationEvidence}; } -function renderMutualDependencyList(mutualPairs, causeRelationEvidence) { - const container = document.getElementById('mutual-dependency-list'); - if (!container) return; - if (!mutualPairs || mutualPairs.size === 0) { - container.style.display = 'none'; - container.innerHTML = ''; - return; +function filterRelatedDiagramRelations(uniqueRelations, visibleSet, aggregatedRoot, aggregationDepth, relatedFilterMode) { + const nextVisibleSet = new Set(visibleSet); + let nextRelations = uniqueRelations; + if (aggregatedRoot) { + const relatedSet = collectRelatedSet(aggregatedRoot, uniqueRelations, aggregationDepth, relatedFilterMode); + if (relatedFilterMode === 'direct') { + nextRelations = uniqueRelations.filter(relation => + relation.from === aggregatedRoot || relation.to === aggregatedRoot + ); + } else { + nextRelations = uniqueRelations.filter(relation => + relatedSet.has(relation.from) && relatedSet.has(relation.to) + ); + } + nextVisibleSet.clear(); + relatedSet.forEach(value => nextVisibleSet.add(value)); } + nextRelations.forEach(relation => { + nextVisibleSet.add(relation.from); + nextVisibleSet.add(relation.to); + }); + return {uniqueRelations: nextRelations, visibleSet: nextVisibleSet}; +} + +function buildVisibleDiagramElements(packages, relations, causeRelationEvidence, packageFilterFqn, relatedFilterFqn, aggregationDepth, relatedFilterMode, transitiveReductionEnabled) { + const base = buildVisibleDiagramRelations( + packages, + relations, + causeRelationEvidence, + packageFilterFqn, + aggregationDepth, + transitiveReductionEnabled + ); + const aggregatedRoot = relatedFilterFqn ? getAggregatedFqn(relatedFilterFqn, aggregationDepth) : null; + const {uniqueRelations, visibleSet} = filterRelatedDiagramRelations( + base.uniqueRelations, + base.visibleSet, + aggregatedRoot, + aggregationDepth, + relatedFilterMode + ); + return { + uniqueRelations, + visibleSet, + filteredCauseRelationEvidence: base.filteredCauseRelationEvidence, + }; +} + +function buildPackageTableRowData(packages, relations) { + const incomingCounts = new Map(); + const outgoingCounts = new Map(); + relations.forEach(relation => { + outgoingCounts.set(relation.from, (outgoingCounts.get(relation.from) ?? 0) + 1); + incomingCounts.set(relation.to, (incomingCounts.get(relation.to) ?? 0) + 1); + }); + return packages.map(item => ({ + ...item, + incomingCount: incomingCounts.get(item.fqn) ?? 0, + outgoingCount: outgoingCounts.get(item.fqn) ?? 0, + })); +} + +function buildPackageTableRowSpecs(rows) { + return rows.map(item => ({ + fqn: item.fqn, + name: item.name, + classCount: item.classCount, + incomingCount: item.incomingCount ?? 0, + outgoingCount: item.outgoingCount ?? 0, + })); +} + +function buildPackageTableActionSpecs() { + return { + filter: { + ariaLabel: 'このパッケージで絞り込み', + screenReaderText: '絞り込み', + }, + related: { + ariaLabel: '関連のみ表示', + screenReaderText: '関連のみ表示', + }, + }; +} + +function buildPackageTableRowElement(spec, applyFilter, applyRelatedFilterForRow) { + const tr = document.createElement('tr'); + const actionSpecs = buildPackageTableActionSpecs(); + + const actionTd = document.createElement('td'); + const actionButton = document.createElement('button'); + actionButton.type = 'button'; + actionButton.className = 'package-filter-icon'; + actionButton.setAttribute('aria-label', actionSpecs.filter.ariaLabel); + const actionText = document.createElement('span'); + actionText.className = 'screen-reader-only'; + actionText.textContent = actionSpecs.filter.screenReaderText; + actionButton.appendChild(actionText); + actionButton.addEventListener('click', () => applyFilter(spec.fqn)); + actionTd.appendChild(actionButton); + tr.appendChild(actionTd); + + const relatedTd = document.createElement('td'); + const relatedButton = document.createElement('button'); + relatedButton.type = 'button'; + relatedButton.className = 'related-icon'; + relatedButton.setAttribute('aria-label', actionSpecs.related.ariaLabel); + const relatedText = document.createElement('span'); + relatedText.className = 'screen-reader-only'; + relatedText.textContent = actionSpecs.related.screenReaderText; + relatedButton.appendChild(relatedText); + relatedButton.addEventListener('click', () => applyRelatedFilterForRow(spec.fqn)); + relatedTd.appendChild(relatedButton); + tr.appendChild(relatedTd); + + const fqnTd = document.createElement('td'); + fqnTd.textContent = spec.fqn; + fqnTd.className = 'fqn'; + tr.appendChild(fqnTd); + + const nameTd = document.createElement('td'); + nameTd.textContent = spec.name; + tr.appendChild(nameTd); + + const classCountTd = document.createElement('td'); + classCountTd.textContent = String(spec.classCount); + classCountTd.className = 'number'; + tr.appendChild(classCountTd); + + const incomingCountTd = document.createElement('td'); + incomingCountTd.textContent = String(spec.incomingCount ?? 0); + incomingCountTd.className = 'number'; + tr.appendChild(incomingCountTd); + + const outgoingCountTd = document.createElement('td'); + outgoingCountTd.textContent = String(spec.outgoingCount ?? 0); + outgoingCountTd.className = 'number'; + tr.appendChild(outgoingCountTd); + + return tr; +} + +function renderPackageTable(context) { + const {packages, relations} = getPackageSummaryData(context); + const rows = buildPackageTableRowData(packages, relations); + const rowSpecs = buildPackageTableRowSpecs(rows); + + const tbody = dom.getPackageTableBody(); + + const input = dom.getPackageFilterInput(); + const applyFilter = fqn => { + if (input) { + input.value = fqn; + } + context.packageFilterFqn = fqn; + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); + }; + const applyRelatedFilterForRow = fqn => { + setRelatedFilterAndRender(fqn, context); + }; + + rowSpecs.forEach(spec => { + const tr = buildPackageTableRowElement(spec, applyFilter, applyRelatedFilterForRow); + tbody.appendChild(tr); + }); +} + +function filterPackageTableRows(packageFilterFqn) { + const rows = dom.getPackageTableRows(); + const rowFqns = Array.from(rows, row => { + const fqnCell = row.querySelector('td.fqn'); + return fqnCell ? fqnCell.textContent : ''; + }); + const visibility = buildPackageRowVisibility(rowFqns, packageFilterFqn); + rows.forEach((row, index) => { + row.classList.toggle('hidden', !visibility[index]); + }); +} + +function filterRelatedTableRows(fqn, context) { + const rows = dom.getPackageTableRows(); + const {relations} = getPackageSummaryData(context); + const rowFqns = Array.from(rows, row => { + const fqnCell = row.querySelector('td.fqn'); + return fqnCell ? fqnCell.textContent : ''; + }); + const visibility = buildRelatedRowVisibility( + rowFqns, + relations, + context.packageFilterFqn, + context.aggregationDepth, + context.relatedFilterMode, + fqn + ); + rows.forEach((row, index) => { + row.classList.toggle('hidden', !visibility[index]); + }); +} + +function renderRelatedFilterLabel(context) { + const target = dom.getRelatedFilterTarget(); + dom.setRelatedFilterTargetText(target, context.relatedFilterFqn ? context.relatedFilterFqn : '未選択'); +} + +function setRelatedFilterAndRender(fqn, context) { + context.relatedFilterFqn = fqn; + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); +} + +function registerDiagramClickHandler(context, applyRelatedFilter = setRelatedFilterAndRender) { + if (typeof window === 'undefined') return; + window[DIAGRAM_CLICK_HANDLER_NAME] = function (nodeId) { + const fqn = context.diagramNodeIdToFqn.get(nodeId); + if (!fqn) return; + applyRelatedFilter(fqn, context); + }; +} + +function applyDefaultPackageFilterIfPresent(context) { + const input = dom.getPackageFilterInput(); + if (!input || input.value.trim()) return false; + const {packages} = getPackageSummaryData(context); + const candidate = findDefaultPackageFilterCandidate(packages); + if (!candidate) return false; + input.value = candidate; + context.packageFilterFqn = candidate; + renderDiagramAndTable(context); + return true; +} + +function buildMutualDependencyItems(mutualPairs, causeRelationEvidence, aggregationDepth) { + if (!mutualPairs || mutualPairs.size === 0) return []; const relationMap = new Map(); causeRelationEvidence.forEach(relation => { const fromPackage = getAggregatedFqn(getPackageFqnFromTypeFqn(relation.from), aggregationDepth); @@ -422,32 +590,15 @@ function renderMutualDependencyList(mutualPairs, causeRelationEvidence) { } relationMap.get(key).add(`${relation.from} -> ${relation.to}`); }); - - container.style.display = ''; - const details = document.createElement('details'); - const summary = document.createElement('summary'); - summary.textContent = '相互依存と原因'; - const list = document.createElement('ul'); - Array.from(mutualPairs).sort().forEach(key => { + return Array.from(mutualPairs).sort().map(key => { const parts = key.split('::'); const pairLabel = `${parts[0]} <-> ${parts[1]}`; - const item = document.createElement('li'); - const pair = document.createElement('div'); - pair.className = 'pair'; - pair.textContent = pairLabel; - item.appendChild(pair); const causes = relationMap.get(key); - if (causes && causes.size > 0) { - const detailBody = document.createElement('pre'); - detailBody.textContent = Array.from(causes).sort().join('\n'); - item.appendChild(detailBody); - } - list.appendChild(item); + return { + pairLabel, + causes: causes ? Array.from(causes).sort() : [], + }; }); - container.innerHTML = ''; - details.appendChild(summary); - details.appendChild(list); - container.appendChild(details); } function detectStronglyConnectedComponents(graph) { @@ -545,100 +696,83 @@ function transitiveReduction(relations) { return relations.filter(edge => !toRemove.has(`${edge.from}::${edge.to}`)); } -function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { - const diagram = document.getElementById('package-relation-diagram'); - if (!diagram) return; - diagramElement = diagram; +function buildMutualDependencyPairs(relations) { + const relationKey = (from, to) => `${from}::${to}`; + const canonicalPairKey = (from, to) => (from < to ? `${from}::${to}` : `${to}::${from}`); + const relationSet = new Set(relations.map(relation => relationKey(relation.from, relation.to))); + const mutualPairs = new Set(); + relations.forEach(relation => { + if (relationSet.has(relationKey(relation.to, relation.from))) { + mutualPairs.add(canonicalPairKey(relation.from, relation.to)); + } + }); + return mutualPairs; +} + +function buildParentFqns(visibleSet) { + const parentFqns = new Set(); + Array.from(visibleSet).sort().forEach(fqn => { + const parts = fqn.split('.'); + for (let i = 1; i < parts.length; i += 1) { + const prefix = parts.slice(0, i).join('.'); + if (visibleSet.has(prefix)) parentFqns.add(prefix); + } + }); + return parentFqns; +} - const {packages, relations, causeRelationEvidence} = getPackageSummaryData(); +function buildMermaidDiagramSource(visibleSet, uniqueRelations, nameByFqn, diagramDirection) { const escapeMermaidText = text => text.replace(/"/g, '\\"'); - const nameByFqn = new Map(packages.map(item => [item.fqn, item.name || item.fqn])); const lines = [`graph ${diagramDirection}`]; - const aggregatedRoot = relatedFilterFqn ? getAggregatedFqn(relatedFilterFqn, aggregationDepth) : null; - const packageFilterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; - const withinPackageFilter = fqn => - !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(packageFilterPrefix); - const visiblePackages = packageFilterFqn - ? packages.filter(item => withinPackageFilter(item.fqn)) - : packages; - const visibleSet = new Set(visiblePackages.map(item => getAggregatedFqn(item.fqn, aggregationDepth))); - const filteredRelations = packageFilterFqn - ? relations.filter(relation => withinPackageFilter(relation.from) && withinPackageFilter(relation.to)) - : relations; - const filteredCauseRelationEvidence = packageFilterFqn - ? causeRelationEvidence.filter(relation => { - const fromPackage = getPackageFqnFromTypeFqn(relation.from); - const toPackage = getPackageFqnFromTypeFqn(relation.to); - return withinPackageFilter(fromPackage) && withinPackageFilter(toPackage); - }) - : causeRelationEvidence; - const visibleRelations = filteredRelations - .map(relation => ({ - from: getAggregatedFqn(relation.from, aggregationDepth), - to: getAggregatedFqn(relation.to, aggregationDepth), - })) - .filter(relation => relation.from !== relation.to); - const uniqueRelationMap = new Map(); - visibleRelations.forEach(relation => { - uniqueRelationMap.set(`${relation.from}::${relation.to}`, relation); - }); - let uniqueRelations = Array.from(uniqueRelationMap.values()); + const {nodeIdByFqn, nodeIdToFqn, nodeLabelById, ensureNodeId} = buildDiagramNodeMaps(visibleSet, nameByFqn); + const {edgeLines, linkStyles, mutualPairs} = buildDiagramEdgeLines(uniqueRelations, ensureNodeId); + const {nodeLines, hasParentStyle} = buildDiagramNodeLines( + visibleSet, + nodeIdByFqn, + nodeIdToFqn, + nodeLabelById, + escapeMermaidText + ); - if (transitiveReductionEnabled) { - uniqueRelations = transitiveReduction(uniqueRelations); + nodeLines.forEach(line => lines.push(line)); + if (hasParentStyle) { + lines.push('classDef parentPackage fill:#ffffde,stroke:#aaaa00,stroke-width:2px'); } + edgeLines.forEach(line => lines.push(line)); + linkStyles.forEach(styleLine => lines.push(styleLine)); - if (aggregatedRoot) { - const relatedSet = collectRelatedSet(aggregatedRoot, uniqueRelations); - if (relatedFilterMode === 'direct') { - uniqueRelations = uniqueRelations.filter(relation => - relation.from === aggregatedRoot || relation.to === aggregatedRoot - ); - } else { - uniqueRelations = uniqueRelations.filter(relation => - relatedSet.has(relation.from) && relatedSet.has(relation.to) - ); - } - visibleSet.clear(); - relatedSet.forEach(value => visibleSet.add(value)); - } - uniqueRelations.forEach(relation => { - visibleSet.add(relation.from); - visibleSet.add(relation.to); - }); + return {source: lines.join('\n'), nodeIdToFqn, mutualPairs}; +} +function buildDiagramNodeMaps(visibleSet, nameByFqn) { const nodeIdByFqn = new Map(); - diagramNodeIdToFqn = new Map(); + const nodeIdToFqn = new Map(); const nodeLabelById = new Map(); let nodeIndex = 0; const ensureNodeId = fqn => { if (nodeIdByFqn.has(fqn)) return nodeIdByFqn.get(fqn); const nodeId = `P${nodeIndex++}`; nodeIdByFqn.set(fqn, nodeId); - diagramNodeIdToFqn.set(nodeId, fqn); + nodeIdToFqn.set(nodeId, fqn); const label = nameByFqn.get(fqn) || fqn; nodeLabelById.set(nodeId, label); return nodeId; }; - Array.from(visibleSet).sort().forEach(ensureNodeId); - const relationKey = (from, to) => `${from}::${to}`; - const canonicalPairKey = (from, to) => (from < to ? `${from}::${to}` : `${to}::${from}`); - const relationSet = new Set(uniqueRelations.map(relation => relationKey(relation.from, relation.to))); - const mutualPairs = new Set(); - uniqueRelations.forEach(relation => { - if (relationSet.has(relationKey(relation.to, relation.from))) { - mutualPairs.add(canonicalPairKey(relation.from, relation.to)); - } - }); + return {nodeIdByFqn, nodeIdToFqn, nodeLabelById, ensureNodeId}; +} +function buildDiagramEdgeLines(uniqueRelations, ensureNodeId) { + const mutualPairs = buildMutualDependencyPairs(uniqueRelations); const linkStyles = []; let linkIndex = 0; const edgeLines = []; uniqueRelations.forEach(relation => { const fromId = ensureNodeId(relation.from); const toId = ensureNodeId(relation.to); - const pairKey = canonicalPairKey(relation.from, relation.to); + const pairKey = relation.from < relation.to + ? `${relation.from}::${relation.to}` + : `${relation.to}::${relation.from}`; if (mutualPairs.has(pairKey)) { if (relation.from > relation.to) { return; @@ -651,35 +785,43 @@ function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { edgeLines.push(`${fromId} --> ${toId}`); linkIndex += 1; }); + return {edgeLines, linkStyles, mutualPairs}; +} +function buildDiagramNodeLines(visibleSet, nodeIdByFqn, nodeIdToFqn, nodeLabelById, escapeMermaidText) { const visibleFqns = Array.from(visibleSet).sort(); - const parentFqns = new Set(); - visibleFqns.forEach(fqn => { - const parts = fqn.split('.'); - for (let i = 1; i < parts.length; i += 1) { - const prefix = parts.slice(0, i).join('.'); - if (visibleSet.has(prefix)) parentFqns.add(prefix); - } - }); - - const addNodeLines = (nodeId, parentSubgraphFqn) => { - const fqn = diagramNodeIdToFqn.get(nodeId); - let displayLabel = nodeLabelById.get(nodeId); - - if (displayLabel === fqn && parentSubgraphFqn && fqn.startsWith(`${parentSubgraphFqn}.`)) { - displayLabel = fqn.substring(parentSubgraphFqn.length + 1); - } + const parentFqns = buildParentFqns(visibleSet); + const rootGroup = buildDiagramGroupTree(visibleFqns, nodeIdByFqn); + const addNodeLines = (lines, nodeId, parentSubgraphFqn) => { + const fqn = nodeIdToFqn.get(nodeId); + const displayLabel = buildDiagramNodeLabel(nodeLabelById.get(nodeId), fqn, parentSubgraphFqn); lines.push(`${nodeId}["${escapeMermaidText(displayLabel)}"]`); - const tooltip = fqn ? escapeMermaidText(fqn) : ''; - lines.push(`click ${nodeId} filterPackageDiagram "${tooltip}"`); + const tooltip = escapeMermaidText(buildDiagramNodeTooltip(fqn)); + lines.push(`click ${nodeId} ${DIAGRAM_CLICK_HANDLER_NAME} "${tooltip}"`); if (fqn && parentFqns.has(fqn)) { lines.push(`class ${nodeId} parentPackage`); } }; + const nodeLines = buildSubgraphLines(rootGroup, addNodeLines, escapeMermaidText); + + return {nodeLines, hasParentStyle: parentFqns.size > 0}; +} +function buildDiagramNodeLabel(displayLabel, fqn, parentSubgraphFqn) { + if (!fqn) return displayLabel ?? ''; + if (displayLabel === fqn && parentSubgraphFqn && fqn.startsWith(`${parentSubgraphFqn}.`)) { + return fqn.substring(parentSubgraphFqn.length + 1); + } + return displayLabel ?? ''; +} + +function buildDiagramNodeTooltip(fqn) { + return fqn ?? ''; +} + +function buildDiagramGroupTree(visibleFqns, nodeIdByFqn) { const prefixDepth = getCommonPrefixDepth(visibleFqns); const baseDepth = Math.max(prefixDepth - 1, 0); - let groupIndex = 0; const createGroupNode = key => ({key, children: new Map(), nodes: []}); const rootGroup = createGroupNode(''); visibleFqns.forEach(fqn => { @@ -695,8 +837,14 @@ function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { } current.nodes.push(nodeIdByFqn.get(fqn)); }); + return rootGroup; +} + +function buildSubgraphLines(rootGroup, addNodeLines, escapeMermaidText) { + const lines = []; + let groupIndex = 0; const renderGroup = (group, isRoot, parentSubgraphFqnForNodes) => { - group.nodes.forEach(nodeId => addNodeLines(nodeId, parentSubgraphFqnForNodes)); + group.nodes.forEach(nodeId => addNodeLines(lines, nodeId, parentSubgraphFqnForNodes)); const childKeys = Array.from(group.children.keys()).sort(); if (isRoot && group.nodes.length === 0 && childKeys.length === 1) { renderGroup(group.children.get(childKeys[0]), false, parentSubgraphFqnForNodes); @@ -716,28 +864,137 @@ function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { }); }; renderGroup(rootGroup, true, rootGroup.key); - if (parentFqns.size > 0) { - lines.push('classDef parentPackage fill:#ffffde,stroke:#aaaa00,stroke-width:2px'); + return lines; +} + +function getOrCreateDiagramErrorBox(diagram) { + let errorBox = dom.getDiagramErrorBox(); + if (errorBox) return errorBox; + return dom.createDiagramErrorBox(diagram); +} + +function showDiagramErrorMessage(message, withAction, err, hash, context) { + const diagram = context.diagramElement; + if (!diagram) return; + console.error(message); + if (err) { + console.error(err); } + if (hash) { + console.error('Mermaid error location:', hash.line, hash.loc); + } + const errorBox = getOrCreateDiagramErrorBox(diagram); + const messageNode = dom.getDiagramErrorMessageNode(); + const actionNode = dom.getDiagramErrorActionNode(); + dom.setNodeTextContent(messageNode, message); + if (actionNode) { + dom.setNodeDisplay(actionNode, withAction ? '' : 'none'); + if (withAction) { + dom.setNodeOnClick(actionNode, function () { + if (!context.pendingDiagramRender) return; + renderDiagramWithMermaid(context.pendingDiagramRender.text, context.pendingDiagramRender.maxEdges, context); + context.pendingDiagramRender = null; + }); + } else { + dom.setNodeOnClick(actionNode, null); + } + } + dom.setNodeDisplay(errorBox, ''); + dom.setDiagramElementDisplay(diagram, 'none'); +} - edgeLines.forEach(line => lines.push(line)); - linkStyles.forEach(styleLine => lines.push(styleLine)); - renderMutualDependencyList(mutualPairs, filteredCauseRelationEvidence); +function hideDiagramErrorMessage(diagram) { + const errorBox = getOrCreateDiagramErrorBox(diagram); + const messageNode = dom.getDiagramErrorMessageNode(); + const actionNode = dom.getDiagramErrorActionNode(); + dom.setNodeTextContent(messageNode, ''); + dom.setNodeDisplay(actionNode, 'none'); + dom.setNodeOnClick(actionNode, null); + dom.setNodeDisplay(errorBox, 'none'); + dom.setDiagramElementDisplay(diagram, ''); +} + +function renderDiagramWithMermaid(text, maxEdges, context) { + const diagram = context.diagramElement; + if (!diagram || !window.mermaid) return; + hideDiagramErrorMessage(diagram); + dom.removeDiagramAttribute(diagram, 'data-processed'); + dom.setDiagramContent(diagram, text); + mermaid.initialize({startOnLoad: false, securityLevel: 'loose', maxEdges: maxEdges}); + mermaid.run({nodes: [diagram]}); +} + +function renderMutualDependencyList(mutualPairs, causeRelationEvidence, context) { + const container = dom.getMutualDependencyList(); + if (!container) return; + const items = buildMutualDependencyItems(mutualPairs, causeRelationEvidence, context.aggregationDepth); + if (items.length === 0) { + container.style.display = 'none'; + container.innerHTML = ''; + return; + } - lastDiagramSource = lines.join('\n'); - lastDiagramEdgeCount = uniqueRelations.length; - if (lastDiagramEdgeCount > DEFAULT_MAX_EDGES) { - pendingDiagramRender = {text: lastDiagramSource, maxEdges: lastDiagramEdgeCount}; + container.style.display = ''; + const details = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = '相互依存と原因'; + const list = document.createElement('ul'); + items.forEach(item => { + const itemNode = document.createElement('li'); + const pair = document.createElement('div'); + pair.className = 'pair'; + pair.textContent = item.pairLabel; + itemNode.appendChild(pair); + if (item.causes.length > 0) { + const detailBody = document.createElement('pre'); + detailBody.textContent = item.causes.join('\n'); + itemNode.appendChild(detailBody); + } + list.appendChild(itemNode); + }); + container.innerHTML = ''; + details.appendChild(summary); + details.appendChild(list); + container.appendChild(details); +} + +function renderPackageDiagram(context, packageFilterFqn, relatedFilterFqn) { + const diagram = dom.getDiagram(); + if (!diagram) return; + context.diagramElement = diagram; + + const {packages, relations, causeRelationEvidence} = getPackageSummaryData(context); + + const { + uniqueRelations, + visibleSet, + filteredCauseRelationEvidence + } = buildVisibleDiagramElements(packages, relations, causeRelationEvidence, packageFilterFqn, relatedFilterFqn, context.aggregationDepth, context.relatedFilterMode, context.transitiveReductionEnabled); + + const nameByFqn = new Map(packages.map(item => [item.fqn, item.name || item.fqn])); + const {source, nodeIdToFqn, mutualPairs} = buildMermaidDiagramSource( + visibleSet, + uniqueRelations, + nameByFqn, + context.diagramDirection + ); + context.diagramNodeIdToFqn = nodeIdToFqn; + renderMutualDependencyList(mutualPairs, filteredCauseRelationEvidence, context); + + context.lastDiagramSource = source; + context.lastDiagramEdgeCount = uniqueRelations.length; + if (context.lastDiagramEdgeCount > context.DEFAULT_MAX_EDGES) { + context.pendingDiagramRender = {text: context.lastDiagramSource, maxEdges: context.lastDiagramEdgeCount}; const message = [ '関連数が多すぎるため描画を省略しました。', - `エッジ数: ${lastDiagramEdgeCount}(上限: ${DEFAULT_MAX_EDGES})`, + `エッジ数: ${context.lastDiagramEdgeCount}(上限: ${context.DEFAULT_MAX_EDGES})`, '描画する場合はボタンを押してください。', ].join('\n'); - showDiagramErrorMessage(message, true); + showDiagramErrorMessage(message, true, null, null, context); return; } diagram.removeAttribute('data-processed'); - diagram.textContent = lastDiagramSource; + diagram.textContent = context.lastDiagramSource; if (window.mermaid) { if (!mermaid.parseError) { mermaid.parseError = function (err, hash) { @@ -745,47 +1002,37 @@ function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { const location = hash ? `\nLine: ${hash.line} Column: ${hash.loc}` : ''; const isEdgeLimit = message.includes('Edge limit exceeded'); if (isEdgeLimit) { - pendingDiagramRender = {text: lastDiagramSource, maxEdges: lastDiagramEdgeCount}; + context.pendingDiagramRender = {text: context.lastDiagramSource, maxEdges: context.lastDiagramEdgeCount}; } - showDiagramErrorMessage(`Mermaid parse error: ${message}${location}`, isEdgeLimit, err, hash); + showDiagramErrorMessage(`Mermaid parse error: ${message}${location}`, isEdgeLimit, err, hash, context); }; } - renderDiagramSvg(lastDiagramSource, DEFAULT_MAX_EDGES); + renderDiagramWithMermaid(context.lastDiagramSource, context.DEFAULT_MAX_EDGES, context); } } -function applyRelatedFilter(fqn) { - relatedFilterFqn = fqn; - renderDiagramAndTable(); - renderRelatedFilterTarget(); -} - -if (typeof window !== 'undefined') { - window.filterPackageDiagram = function (nodeId) { - const fqn = diagramNodeIdToFqn.get(nodeId); - if (!fqn) return; - applyRelatedFilter(fqn); - }; +function renderDiagramAndTable(context) { + renderPackageDiagram(context, context.packageFilterFqn, context.relatedFilterFqn); + filterRelatedTableRows(context.relatedFilterFqn, context); + renderAggregationDepthSelectOptions(getMaxPackageDepth(context), context); } -function setupPackageFilterControls() { - const input = document.getElementById('package-filter-input'); - const applyButton = document.getElementById('apply-package-filter'); - const clearPackageButton = document.getElementById('clear-package-filter'); - const depthSelect = document.getElementById('package-depth-select'); +function setupPackageFilterControl(context) { + const input = dom.getPackageFilterInput(); + const applyButton = dom.getApplyPackageFilterButton(); + const clearPackageButton = dom.getClearPackageFilterButton(); if (!input || !applyButton || !clearPackageButton) return; const applyFilter = () => { - const value = input.value.trim(); - packageFilterFqn = value || null; - renderDiagramAndTable(); - renderRelatedFilterTarget(); + context.packageFilterFqn = normalizePackageFilterValue(input.value); + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); }; const clearPackageFilter = () => { input.value = ''; - packageFilterFqn = null; - renderDiagramAndTable(); - renderRelatedFilterTarget(); + context.packageFilterFqn = null; + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); }; applyButton.addEventListener('click', applyFilter); @@ -798,126 +1045,117 @@ function setupPackageFilterControls() { }); } -function setupAggregationDepthControl() { - const select = document.getElementById('package-depth-select'); +function setupAggregationDepthControl(context) { + const select = dom.getDepthSelect(); if (!select) return; - const {packages} = getPackageSummaryData(); + const {packages} = getPackageSummaryData(context); const maxDepth = packages.reduce((max, item) => Math.max(max, getPackageDepth(item.fqn)), 0); - updateAggregationDepthOptions(maxDepth); - select.value = String(aggregationDepth); + renderAggregationDepthSelectOptions(maxDepth, context); + select.value = String(context.aggregationDepth); select.addEventListener('change', () => { - aggregationDepth = Number(select.value); - renderDiagramAndTable(); - renderRelatedFilterTarget(); - updateAggregationDepthOptions(maxDepth); + context.aggregationDepth = normalizeAggregationDepthValue(select.value); + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); + renderAggregationDepthSelectOptions(maxDepth, context); }); } -function updateAggregationDepthOptions(maxDepth) { - const select = document.getElementById('package-depth-select'); +function renderAggregationDepthSelectOptions(maxDepth, context) { + const select = dom.getDepthSelect(); if (!select) return; - const {packages, relations} = getPackageSummaryData(); - let aggregationStats; - aggregationStats = buildAggregationStatsForFilters( + const {packages, relations} = getPackageSummaryData(context); + const aggregationStats = buildAggregationStatsForFilters( packages, relations, - packageFilterFqn, - relatedFilterFqn, - maxDepth + context.packageFilterFqn, + context.relatedFilterFqn, + maxDepth, + context.aggregationDepth, + context.relatedFilterMode ); - select.innerHTML = ''; - const noAggregationOption = document.createElement('option'); - noAggregationOption.value = '0'; + const options = buildAggregationDepthOptions(aggregationStats, maxDepth); + renderAggregationDepthOptionsIntoSelect(select, options, context.aggregationDepth, maxDepth); +} + +function buildAggregationDepthOptions(aggregationStats, maxDepth) { + const options = []; const noAggregationStats = aggregationStats.get(0); - noAggregationOption.textContent = `集約なし(P${noAggregationStats.packageCount} / R${noAggregationStats.relationCount})`; - select.appendChild(noAggregationOption); + options.push({ + value: '0', + text: `集約なし(P${noAggregationStats.packageCount} / R${noAggregationStats.relationCount})`, + }); for (let depth = 1; depth <= maxDepth; depth += 1) { - const option = document.createElement('option'); - option.value = String(depth); const stats = aggregationStats.get(depth); if (!stats || stats.relationCount === 0) { continue; } - option.textContent = `深さ${depth}(P${stats.packageCount} / R${stats.relationCount})`; - select.appendChild(option); + options.push({ + value: String(depth), + text: `深さ${depth}(P${stats.packageCount} / R${stats.relationCount})`, + }); } - const value = Math.min(aggregationDepth, maxDepth); - select.value = String(value); + return options; } -function applyDefaultPackageFilterIfPresent() { - const input = document.getElementById('package-filter-input'); - if (!input || input.value.trim()) return false; - const {packages} = getPackageSummaryData(); - const domainRoots = packages - .map(item => item.fqn) - .map(fqn => { - const parts = fqn.split('.'); - const domainIndex = parts.indexOf('domain'); - if (domainIndex === -1) return null; - return parts.slice(0, domainIndex + 1).join('.'); - }) - .filter(Boolean); - if (domainRoots.length === 0) return false; - const candidate = domainRoots.reduce((best, current) => { - const bestDepth = best.split('.').length; - const currentDepth = current.split('.').length; - return currentDepth < bestDepth ? current : best; +function renderAggregationDepthOptionsIntoSelect(select, options, aggregationDepth, maxDepth) { + select.innerHTML = ''; + options.forEach(option => { + const node = document.createElement('option'); + node.value = option.value; + node.textContent = option.text; + select.appendChild(node); }); - if (!candidate) return false; - input.value = candidate; - packageFilterFqn = candidate; - renderDiagramAndTable(); - return true; + const value = Math.min(aggregationDepth, maxDepth); + select.value = String(value); } -function setupRelatedFilterControls() { - const select = document.getElementById('related-mode-select'); - const clearButton = document.getElementById('clear-related-filter'); +function setupRelatedFilterControl(context) { + const select = dom.getRelatedModeSelect(); + const clearButton = dom.getClearRelatedFilterButton(); if (!select) return; - select.value = relatedFilterMode; + select.value = context.relatedFilterMode; select.addEventListener('change', () => { - relatedFilterMode = select.value; - if (relatedFilterFqn) { - renderDiagramAndTable(); + context.relatedFilterMode = select.value; + if (context.relatedFilterFqn) { + renderDiagramAndTable(context); } }); if (clearButton) { clearButton.addEventListener('click', () => { - relatedFilterFqn = null; - packageFilterFqn = document.getElementById('package-filter-input')?.value.trim() || null; - renderDiagramAndTable(); - renderRelatedFilterTarget(); + context.relatedFilterFqn = null; + context.packageFilterFqn = normalizePackageFilterValue(dom.getPackageFilterInput()?.value); + renderDiagramAndTable(context); + renderRelatedFilterLabel(context); }); } } -function setupDiagramDirectionControls() { - const radios = document.querySelectorAll('input[name="diagram-direction"]'); +function setupDiagramDirectionControl(context) { + const radios = dom.getDiagramDirectionRadios(); radios.forEach(radio => { - if (radio.value === diagramDirection) { + if (radio.value === context.diagramDirection) { radio.checked = true; } radio.addEventListener('change', () => { if (!radio.checked) return; - diagramDirection = radio.value; - renderDiagramAndTable(); + context.diagramDirection = radio.value; + renderDiagramAndTable(context); }); }); } -function setupTransitiveReductionControl() { - const container = document.querySelector('input[name="diagram-direction"]')?.parentNode?.parentNode; +function setupTransitiveReductionControl(context) { + const container = dom.getDiagramDirectionRadio()?.parentNode?.parentNode; if (!container) return; const controlContainer = document.createElement('div'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = 'transitive-reduction-toggle'; - checkbox.checked = transitiveReductionEnabled; + checkbox.checked = context.transitiveReductionEnabled; checkbox.addEventListener('change', () => { - transitiveReductionEnabled = checkbox.checked; - renderDiagramAndTable(); + context.transitiveReductionEnabled = checkbox.checked; + renderDiagramAndTable(context); }); const label = document.createElement('label'); @@ -932,88 +1170,91 @@ function setupTransitiveReductionControl() { if (typeof document !== 'undefined') { document.addEventListener("DOMContentLoaded", function () { - if (!document.body.classList.contains("package-list")) return; + const body = dom.getDocumentBody(); + if (!body || !body.classList.contains("package-list")) return; setupSortableTables(); - renderPackageTable(); - setupPackageFilterControls(); - setupAggregationDepthControl(); - setupRelatedFilterControls(); - setupDiagramDirectionControls(); - setupTransitiveReductionControl(); - const applied = applyDefaultPackageFilterIfPresent(); + renderPackageTable(packageContext); + setupPackageFilterControl(packageContext); + setupAggregationDepthControl(packageContext); + setupRelatedFilterControl(packageContext); + setupDiagramDirectionControl(packageContext); + setupTransitiveReductionControl(packageContext); + registerDiagramClickHandler(packageContext); + const applied = applyDefaultPackageFilterIfPresent(packageContext); if (!applied) { - renderDiagramAndTable(); + renderDiagramAndTable(packageContext); } - renderRelatedFilterTarget(); + renderRelatedFilterLabel(packageContext); }); } // Test-only exports for Node; no-op in browsers. if (typeof module !== 'undefined' && module.exports) { module.exports = { - setAggregationDepth(value) { - aggregationDepth = value; - }, - setPackageFilterFqn(value) { - packageFilterFqn = value; - }, - setRelatedFilterMode(value) { - relatedFilterMode = value; - }, - setRelatedFilterFqn(value) { - relatedFilterFqn = value; - }, - setDiagramDirection(value) { - diagramDirection = value; - }, - setTransitiveReductionEnabled(value) { - transitiveReductionEnabled = value; - }, - setDiagramElement(value) { - diagramElement = value; - }, - getPackageFilterFqn() { - return packageFilterFqn; - }, - getRelatedFilterFqn() { - return relatedFilterFqn; - }, - getDiagramDirection() { - return diagramDirection; - }, - resetPackageSummaryCache() { - packageSummaryCache = null; - }, - getAggregatedFqn, - collectRelatedSet, + // public + packageContext, + DIAGRAM_CLICK_HANDLER_NAME, + dom, + + // private getPackageSummaryData, + parsePackageSummaryData, getPackageDepth, getMaxPackageDepth, + getAggregatedFqn, getCommonPrefixDepth, + getPackageFqnFromTypeFqn, buildAggregationStats, - buildAggregationStatsForFilters, buildAggregationStatsForPackageFilter, buildAggregationStatsForRelated, + buildAggregationStatsForFilters, + normalizePackageFilterValue, + normalizeAggregationDepthValue, + findDefaultPackageFilterCandidate, + buildPackageRowVisibility, + buildRelatedRowVisibility, + collectRelatedSet, + buildVisibleDiagramRelations, + filterRelatedDiagramRelations, + buildVisibleDiagramElements, + buildPackageTableRowData, + buildPackageTableRowSpecs, + buildPackageTableActionSpecs, + buildPackageTableRowElement, + renderPackageTable, + filterPackageTableRows, + filterRelatedTableRows, + renderRelatedFilterLabel, + setRelatedFilterAndRender, + applyDefaultPackageFilterIfPresent, + buildMutualDependencyItems, + detectStronglyConnectedComponents, + transitiveReduction, + buildMutualDependencyPairs, + buildParentFqns, + buildMermaidDiagramSource, + buildDiagramNodeMaps, + buildDiagramEdgeLines, + buildDiagramNodeLines, + buildDiagramNodeLabel, + buildDiagramNodeTooltip, + buildDiagramGroupTree, + buildSubgraphLines, getOrCreateDiagramErrorBox, showDiagramErrorMessage, hideDiagramErrorMessage, - renderDiagramSvg, - renderPackageTable, - applyPackageFilterToTable, - applyRelatedFilterToTable, - renderRelatedFilterTarget, - renderDiagramAndTable, + renderDiagramWithMermaid, renderMutualDependencyList, renderPackageDiagram, - applyRelatedFilter, - setupPackageFilterControls, + renderDiagramAndTable, + registerDiagramClickHandler, + setupPackageFilterControl, setupAggregationDepthControl, - updateAggregationDepthOptions, - applyDefaultPackageFilterIfPresent, - setupRelatedFilterControls, - setupDiagramDirectionControls, + renderAggregationDepthSelectOptions, + buildAggregationDepthOptions, + renderAggregationDepthOptionsIntoSelect, + setupRelatedFilterControl, + setupDiagramDirectionControl, setupTransitiveReductionControl, - detectStronglyConnectedComponents, - transitiveReduction, }; } diff --git a/jig-core/src/test/js/package.test.js b/jig-core/src/test/js/package.test.js index 18b479d4c..e69dc326a 100644 --- a/jig-core/src/test/js/package.test.js +++ b/jig-core/src/test/js/package.test.js @@ -2,20 +2,15 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const pkg = require('../../main/resources/templates/assets/package.js'); +const originalDom = {...pkg.dom}; + +let testContext; class ClassList { constructor() { this.values = new Set(); } - add(value) { - this.values.add(value); - } - - remove(value) { - this.values.delete(value); - } - toggle(value, force) { if (force === undefined) { if (this.values.has(value)) { @@ -163,11 +158,28 @@ function buildPackageRows(doc, fqns) { return rows; } -function setPackageData(doc, data) { - const dataElement = new Element('script', doc); - dataElement.textContent = JSON.stringify(data); - doc.elementsById.set('package-data', dataElement); - pkg.resetPackageSummaryCache(); +function setPackageData(data, context) { + const mockDataContent = JSON.stringify(data); + const doc = global.document; + if (doc && doc.elementsById) { + const dataElement = new Element('script', doc); + dataElement.textContent = mockDataContent; + doc.elementsById.set('package-data', dataElement); + } else { + const mockDataElement = { textContent: mockDataContent }; + test.mock.method(pkg.dom, 'getPackageDataScript', test.mock.fn(() => mockDataElement)); + test.mock.method(pkg.dom, 'getNodeTextContent', test.mock.fn((el) => el.textContent)); + } + + context.packageSummaryCache = null; // Reset cache +} + +function setupDomMocks() { + const methods = Object.keys(originalDom); + methods.forEach(name => { + if (typeof originalDom[name] !== 'function') return; + test.mock.method(pkg.dom, name, test.mock.fn((...args) => originalDom[name](...args))); + }); } function withConsoleErrorCapture(callback) { @@ -214,11 +226,12 @@ function createDepthSelect(doc) { return select; } -function setupDiagramEnvironment(doc) { +function setupDiagramEnvironment(doc, context) { const container = doc.createElement('div'); const diagram = doc.createElement('div'); diagram.id = 'package-relation-diagram'; container.appendChild(diagram); + context.diagramElement = diagram; const mutual = doc.createElement('div'); mutual.id = 'mutual-dependency-list'; doc.elementsById.set('mutual-dependency-list', mutual); @@ -235,44 +248,48 @@ function setupDiagramEnvironment(doc) { } test.describe('package.js', () => { - test.describe('データ/ヘルパー', () => { - test.describe('collectRelatedSet', () => { - test('directモード: 隣接のみを含める', () => { - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('direct'); - const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.domain.b', to: 'app.domain.c'}, - ]; - - const related = pkg.collectRelatedSet('app.domain.a', relations); - - assert.deepEqual(Array.from(related).sort(), ['app.domain.a', 'app.domain.b']); - }); - - test('allモード: 推移的に辿る', () => { - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('all'); - const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.domain.b', to: 'app.domain.c'}, - ]; - - const related = pkg.collectRelatedSet('app.domain.a', relations); + test.beforeEach(() => { + // Reset the context for each test to ensure isolation + testContext = { + packageSummaryCache: null, + diagramNodeIdToFqn: new Map(), + aggregationDepth: 0, + diagramElement: null, + pendingDiagramRender: null, + lastDiagramSource: '', + lastDiagramEdgeCount: 0, + DEFAULT_MAX_EDGES: 500, + packageFilterFqn: null, + relatedFilterMode: 'direct', + relatedFilterFqn: null, + diagramDirection: 'TD', + transitiveReductionEnabled: true, + }; + setupDomMocks(); + }); - assert.deepEqual( - Array.from(related).sort(), - ['app.domain.a', 'app.domain.b', 'app.domain.c'] - ); + test.describe('データ取得/整形', () => { + test.describe('ロジック', () => { + test('parsePackageSummaryData: 配列/オブジェクトに対応する', () => { + const arrayData = pkg.parsePackageSummaryData(JSON.stringify([ + {fqn: 'app.a', name: 'A', classCount: 1, description: ''}, + ])); + assert.equal(arrayData.packages.length, 1); + assert.equal(arrayData.relations.length, 0); + + const objectData = pkg.parsePackageSummaryData(JSON.stringify({ + packages: [{fqn: 'app.b', name: 'B', classCount: 2, description: ''}], + relations: [{from: 'app.b', to: 'app.c'}], + })); + assert.equal(objectData.packages.length, 1); + assert.equal(objectData.relations.length, 1); }); - }); - test.describe('データ取得', () => { - test('getPackageSummaryData: 配列/オブジェクト両対応', () => { - const doc = setupDocument(); - setPackageData(doc, [{fqn: 'app.a', name: 'A', classCount: 1, description: ''}]); + test('getPackageSummaryData: 配列/オブジェクトに対応する', () => { + setupDocument(); + setPackageData([{fqn: 'app.a', name: 'A', classCount: 1, description: ''}], testContext); - const data = pkg.getPackageSummaryData(); + const data = pkg.getPackageSummaryData(testContext); assert.equal(data.packages.length, 1); assert.equal(data.relations.length, 0); @@ -286,16 +303,16 @@ test.describe('package.js', () => { test('getMaxPackageDepth: 最大深さを返す', () => { const doc = setupDocument(); - setPackageData(doc, { + setPackageData({ packages: [ {fqn: 'app.domain.a'}, {fqn: 'app.b'}, {fqn: 'app.domain.core.c'}, ], relations: [], - }); + }, testContext); - assert.equal(pkg.getMaxPackageDepth(), 4); + assert.equal(pkg.getMaxPackageDepth(testContext), 4); }); test('getCommonPrefixDepth: 共通プレフィックス深さを返す', () => { @@ -307,130 +324,243 @@ test.describe('package.js', () => { }); test.describe('集計', () => { - test('buildAggregationStatsForPackageFilter: 対象のみ数える', () => { - pkg.setAggregationDepth(0); - const packages = [ - {fqn: 'app.domain.a'}, - {fqn: 'app.domain.b'}, - {fqn: 'app.other.c'}, - ]; - const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.other.c', to: 'app.domain.a'}, - ]; + test.describe('ロジック', () => { + test('buildAggregationStatsForPackageFilter: 対象のみ数える', () => { + testContext.aggregationDepth = 0; + const packages = [ + {fqn: 'app.domain.a'}, + {fqn: 'app.domain.b'}, + {fqn: 'app.other.c'}, + ]; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.other.c', to: 'app.domain.a'}, + ]; - const stats = pkg.buildAggregationStatsForPackageFilter(packages, relations, 'app.domain', 0); - const depth0 = stats.get(0); + const stats = pkg.buildAggregationStatsForPackageFilter(packages, relations, 'app.domain', 0); + const depth0 = stats.get(0); - assert.equal(depth0.packageCount, 2); - assert.equal(depth0.relationCount, 1); - }); + assert.equal(depth0.packageCount, 2); + assert.equal(depth0.relationCount, 1); + }); - test('buildAggregationStatsForRelated: 集計深さを反映する', () => { - pkg.setAggregationDepth(1); - pkg.setRelatedFilterMode('all'); - const packages = [ - {fqn: 'app.domain.a'}, - {fqn: 'app.domain.b'}, - {fqn: 'app.other.c'}, - ]; - const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.domain.b', to: 'app.other.c'}, - ]; + test('buildAggregationStatsForRelated: 集計深さを反映する', () => { + const aggregationDepth = 1; + const relatedFilterMode = 'all'; + const packages = [ + {fqn: 'app.domain.a'}, + {fqn: 'app.domain.b'}, + {fqn: 'app.other.c'}, + ]; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.domain.b', to: 'app.other.c'}, + ]; - const stats = pkg.buildAggregationStatsForRelated(packages, relations, 'app.domain.a', 1); - const depth1 = stats.get(1); + const stats = pkg.buildAggregationStatsForRelated(packages, relations, 'app.domain.a', 1, aggregationDepth, relatedFilterMode); + const depth1 = stats.get(1); - assert.equal(depth1.packageCount, 1); - assert.equal(depth1.relationCount, 0); - }); + assert.equal(depth1.packageCount, 1); + assert.equal(depth1.relationCount, 0); + }); - test('buildAggregationStatsForFilters: directモードの複合集計', () => { - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('direct'); - const packages = [ - {fqn: 'app.domain.a'}, - {fqn: 'app.domain.b'}, - {fqn: 'app.domain.c'}, - {fqn: 'app.other.d'}, - ]; - const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.domain.b', to: 'app.domain.c'}, - {from: 'app.domain.c', to: 'app.other.d'}, - {from: 'app.other.d', to: 'app.domain.a'}, - ]; + test('buildAggregationStatsForFilters: directモードの複合集計を行う', () => { + const packages = [ + {fqn: 'app.domain.a'}, + {fqn: 'app.domain.b'}, + {fqn: 'app.domain.c'}, + {fqn: 'app.other.d'}, + ]; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.domain.b', to: 'app.domain.c'}, + {from: 'app.domain.c', to: 'app.other.d'}, + {from: 'app.other.d', to: 'app.domain.a'}, + ]; + + const stats = pkg.buildAggregationStatsForFilters( + packages, + relations, + 'app.domain', + 'app.domain.a', + 0, + 0, + 'direct' + ); + const depth0 = stats.get(0); - const stats = pkg.buildAggregationStatsForFilters( - packages, - relations, - 'app.domain', - 'app.domain.a', - 0 - ); - const depth0 = stats.get(0); - - assert.equal(depth0.packageCount, 2); - assert.equal(depth0.relationCount, 1); + assert.equal(depth0.packageCount, 2); + assert.equal(depth0.relationCount, 1); + }); + + test('buildAggregationStatsForFilters: allモードの複合集計を行う', () => { + const packages = [ + {fqn: 'app.domain.a'}, + {fqn: 'app.domain.b'}, + {fqn: 'app.domain.c'}, + {fqn: 'app.other.d'}, + ]; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.domain.b', to: 'app.domain.c'}, + {from: 'app.domain.c', to: 'app.other.d'}, + {from: 'app.other.d', to: 'app.domain.a'}, + ]; + + const stats = pkg.buildAggregationStatsForFilters( + packages, + relations, + 'app.domain', + 'app.domain.a', + 0, + 0, + 'all' + ); + const depth0 = stats.get(0); + + assert.equal(depth0.packageCount, 3); + assert.equal(depth0.relationCount, 2); + }); }); + }); + + test.describe('フィルタ', () => { + test.describe('ロジック', () => { + test('normalizePackageFilterValue: 空文字はnullを返す', () => { + assert.equal(pkg.normalizePackageFilterValue(''), null); + assert.equal(pkg.normalizePackageFilterValue(' '), null); + assert.equal(pkg.normalizePackageFilterValue('app.domain'), 'app.domain'); + }); + + test('normalizeAggregationDepthValue: 数値化する', () => { + assert.equal(pkg.normalizeAggregationDepthValue('2'), 2); + assert.equal(pkg.normalizeAggregationDepthValue('0'), 0); + assert.equal(pkg.normalizeAggregationDepthValue('abc'), 0); + }); + + test('findDefaultPackageFilterCandidate: ドメイン候補を返す', () => { + const candidate = pkg.findDefaultPackageFilterCandidate([ + {fqn: 'app.domain.core'}, + {fqn: 'app.domain.sub'}, + ]); + assert.equal(candidate, 'app.domain'); + }); + + test('buildPackageRowVisibility: パッケージフィルタのみを表示する', () => { + const visibility = pkg.buildPackageRowVisibility( + ['app.domain', 'app.other'], + 'app.domain' + ); + assert.deepEqual(visibility, [true, false]); + }); + + test('buildRelatedRowVisibility: 関連フィルタ未指定はパッケージフィルタのみを表示する', () => { + const rowFqns = ['app.domain', 'app.other']; + const visibility = pkg.buildRelatedRowVisibility( + rowFqns, + [], + 'app.domain', + 0, + 'direct', + null + ); + assert.deepEqual(visibility, [true, false]); + }); + + test('buildRelatedRowVisibility: 関係する行のみ表示する', () => { + const rowFqns = ['app.a', 'app.b', 'app.c']; + const relations = [{from: 'app.a', to: 'app.b'}]; + const visibility = pkg.buildRelatedRowVisibility( + rowFqns, + relations, + null, + 0, + 'direct', + 'app.a' + ); + assert.deepEqual(visibility, [true, true, false]); + }); - test('buildAggregationStatsForFilters: allモードの複合集計', () => { - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('all'); const packages = [ - {fqn: 'app.domain.a'}, - {fqn: 'app.domain.b'}, - {fqn: 'app.domain.c'}, - {fqn: 'app.other.d'}, + {fqn: 'app.a'}, + {fqn: 'app.b'}, + {fqn: 'app.c'}, + {fqn: 'lib.d'}, ]; const relations = [ - {from: 'app.domain.a', to: 'app.domain.b'}, - {from: 'app.domain.b', to: 'app.domain.c'}, - {from: 'app.domain.c', to: 'app.other.d'}, - {from: 'app.other.d', to: 'app.domain.a'}, + {from: 'app.a', to: 'app.b'}, + {from: 'app.b', to: 'app.c'}, + {from: 'app.c', to: 'lib.d'}, ]; - const stats = pkg.buildAggregationStatsForFilters( - packages, - relations, - 'app.domain', - 'app.domain.a', - 0 - ); - const depth0 = stats.get(0); - - assert.equal(depth0.packageCount, 3); - assert.equal(depth0.relationCount, 2); - }); - }); + test('collectRelatedSet: directモードは隣接のみを含める', () => { + const aggregationDepth = 0; + const relatedFilterMode = 'direct'; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.domain.b', to: 'app.domain.c'}, + ]; - test.describe('フィルタ', () => { - test.describe('テーブル', () => { - test('applyPackageFilterToTable: 行の表示/非表示を切り替える', () => { - const doc = setupDocument(); - const rows = buildPackageRows(doc, ['app.domain', 'app.other']); + const related = pkg.collectRelatedSet('app.domain.a', relations, aggregationDepth, relatedFilterMode); - pkg.applyPackageFilterToTable('app.domain'); + assert.deepEqual(Array.from(related).sort(), ['app.domain.a', 'app.domain.b']); + }); - assert.equal(rows[0].classList.contains('hidden'), false); - assert.equal(rows[1].classList.contains('hidden'), true); + test('collectRelatedSet: allモードは推移的に辿る', () => { + const aggregationDepth = 0; + const relatedFilterMode = 'all'; + const relations = [ + {from: 'app.domain.a', to: 'app.domain.b'}, + {from: 'app.domain.b', to: 'app.domain.c'}, + ]; + + const related = pkg.collectRelatedSet('app.domain.a', relations, aggregationDepth, relatedFilterMode); + + assert.deepEqual( + Array.from(related).sort(), + ['app.domain.a', 'app.domain.b', 'app.domain.c'] + ); }); - test('applyRelatedFilterToTable: 未指定ならパッケージフィルタのみ', () => { - const doc = setupDocument(); - const rows = buildPackageRows(doc, ['app.domain', 'app.other']); - pkg.setPackageFilterFqn('app.domain'); + test('buildVisibleDiagramRelations: パッケージフィルタを適用する', () => { + const base = pkg.buildVisibleDiagramRelations(packages, relations, [], 'app', 0, false); + assert.deepEqual(Array.from(base.visibleSet).sort(), ['app.a', 'app.b', 'app.c']); + assert.equal(base.uniqueRelations.length, 2); + }); - pkg.applyRelatedFilterToTable(null); + test('filterRelatedDiagramRelations: relatedSetで絞り込む', () => { + const base = pkg.buildVisibleDiagramRelations(packages, relations, [], null, 0, false); + const filtered = pkg.filterRelatedDiagramRelations( + base.uniqueRelations, + base.visibleSet, + 'app.b', + 0, + 'direct' + ); + assert.deepEqual(Array.from(filtered.visibleSet).sort(), ['app.a', 'app.b', 'app.c']); + assert.equal(filtered.uniqueRelations.length, 2); + }); - assert.equal(rows[0].classList.contains('hidden'), false); - assert.equal(rows[1].classList.contains('hidden'), true); + test('buildVisibleDiagramElements: packageFilterは配下のみを表示する', () => { + const {visibleSet} = pkg.buildVisibleDiagramElements(packages, relations, [], 'app', null, 0, 'direct', false); + assert.deepEqual(Array.from(visibleSet).sort(), ['app.a', 'app.b', 'app.c']); }); - test('applyRelatedFilterToTable: 関係する行のみ表示', () => { + test('buildVisibleDiagramElements: relatedFilter(direct)は隣接のみを表示する', () => { + const {visibleSet} = pkg.buildVisibleDiagramElements(packages, relations, [], null, 'app.b', 0, 'direct', false); + assert.deepEqual(Array.from(visibleSet).sort(), ['app.a', 'app.b', 'app.c']); + }); + + test('buildVisibleDiagramElements: relatedFilter(all)は到達可能なものを表示する', () => { + const {visibleSet} = pkg.buildVisibleDiagramElements(packages, relations, [], null, 'app.a', 0, 'all', false); + assert.deepEqual(Array.from(visibleSet).sort(), ['app.a', 'app.b', 'app.c', 'lib.d']); + }); + }); + + test.describe('UI', () => { + test('filterRelatedTableRows: 関係する行のみ表示する', () => { const doc = setupDocument(); - setPackageData(doc, { + setPackageData({ packages: [ {fqn: 'app.a'}, {fqn: 'app.b'}, @@ -439,66 +569,118 @@ test.describe('package.js', () => { relations: [ {from: 'app.a', to: 'app.b'}, ], - }); + }, testContext); const rows = buildPackageRows(doc, ['app.a', 'app.b', 'app.c']); - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('direct'); - pkg.setPackageFilterFqn(null); + testContext.aggregationDepth = 0; + testContext.relatedFilterMode = 'direct'; + testContext.packageFilterFqn = null; - pkg.applyRelatedFilterToTable('app.a'); + pkg.filterRelatedTableRows('app.a', testContext); assert.equal(rows[0].classList.contains('hidden'), false); assert.equal(rows[1].classList.contains('hidden'), false); assert.equal(rows[2].classList.contains('hidden'), true); }); - }); - }); - test.describe('描画', () => { - test.describe('UI表示', () => { - test('renderRelatedFilterTarget: 対象表示を更新する', () => { + test('renderRelatedFilterLabel: 対象表示を更新する', () => { + const mockTarget = { textContent: '' }; + const getRelatedFilterTargetMock = test.mock.fn(() => mockTarget); + const setRelatedFilterTargetTextMock = test.mock.fn((element, text) => { element.textContent = text; }); + + test.mock.method(pkg.dom, 'getRelatedFilterTarget', getRelatedFilterTargetMock); + test.mock.method(pkg.dom, 'setRelatedFilterTargetText', setRelatedFilterTargetTextMock); + + testContext.relatedFilterFqn = null; + pkg.renderRelatedFilterLabel(testContext); + assert.equal(mockTarget.textContent, '未選択'); + assert.equal(setRelatedFilterTargetTextMock.mock.calls.length, 1); + assert.deepEqual(setRelatedFilterTargetTextMock.mock.calls[0].arguments, [mockTarget, '未選択']); + + testContext.relatedFilterFqn = 'app.domain'; + pkg.renderRelatedFilterLabel(testContext); + assert.equal(mockTarget.textContent, 'app.domain'); + assert.equal(setRelatedFilterTargetTextMock.mock.calls.length, 2); + assert.deepEqual(setRelatedFilterTargetTextMock.mock.calls[1].arguments, [mockTarget, 'app.domain']); + }); + + test('applyDefaultPackageFilterIfPresent: ドメインがあれば適用する', () => { const doc = setupDocument(); - const target = new Element('span'); - doc.elementsById.set('related-filter-target', target); + setupDiagramEnvironment(doc, testContext); + setPackageData({ + packages: [ + {fqn: 'app.domain.core'}, + {fqn: 'app.domain.sub'}, + ], + relations: [], + }, testContext); + doc.selectorsAll.set('#package-table tbody tr', []); + const {input} = createPackageFilterControls(doc); + createDepthSelect(doc); // for renderDiagramAndTable - pkg.setRelatedFilterFqn(null); - pkg.renderRelatedFilterTarget(); - assert.equal(target.textContent, '未選択'); + const applied = pkg.applyDefaultPackageFilterIfPresent(testContext); - pkg.setRelatedFilterFqn('app.domain'); - pkg.renderRelatedFilterTarget(); - assert.equal(target.textContent, 'app.domain'); + assert.equal(applied, true); + assert.equal(testContext.packageFilterFqn, 'app.domain'); + assert.equal(input.value, 'app.domain'); }); - test('updateAggregationDepthOptions: 選択肢を更新する', () => { + test('setupPackageFilterControl: 適用/解除を扱う', () => { const doc = setupDocument(); - const select = new Element('select'); - doc.elementsById.set('package-depth-select', select); - setPackageData(doc, { - packages: [ - {fqn: 'app.domain'}, - {fqn: 'lib.core'}, - ], - relations: [ - {from: 'app.domain', to: 'lib.core'}, - ], - }); - pkg.setAggregationDepth(1); - pkg.setPackageFilterFqn(null); - pkg.setRelatedFilterFqn(null); + setupDiagramEnvironment(doc, testContext); + setPackageData({ + packages: [{fqn: 'app.domain', name: 'Domain', classCount: 1}], + relations: [], + }, testContext); + doc.selectorsAll.set('#package-table tbody tr', []); + createDepthSelect(doc); + + const {input, applyButton, clearButton} = createPackageFilterControls(doc); + + pkg.setupPackageFilterControl(testContext); - pkg.updateAggregationDepthOptions(2); + input.value = 'app.domain'; + applyButton.eventListeners.get('click')(); + assert.equal(testContext.packageFilterFqn, 'app.domain'); - assert.equal(select.children.length >= 2, true); - assert.equal(select.children[0].textContent.includes('集約なし'), true); - assert.equal(select.value, '1'); + clearButton.eventListeners.get('click')(); + assert.equal(testContext.packageFilterFqn, null); + assert.equal(input.value, ''); + }); + }); + }); + + test.describe('テーブル', () => { + test.describe('ロジック', () => { + test('buildPackageTableRowSpecs: 行データを整形する', () => { + const rows = [ + {fqn: 'app.a', name: 'A', classCount: 2, incomingCount: 0, outgoingCount: 1}, + ]; + + const specs = pkg.buildPackageTableRowSpecs(rows); + + assert.deepEqual(specs, [{ + fqn: 'app.a', + name: 'A', + classCount: 2, + incomingCount: 0, + outgoingCount: 1, + }]); + }); + + test('buildPackageTableActionSpecs: ボタン文言を返す', () => { + const specs = pkg.buildPackageTableActionSpecs(); + + assert.equal(specs.filter.ariaLabel, 'このパッケージで絞り込み'); + assert.equal(specs.filter.screenReaderText, '絞り込み'); + assert.equal(specs.related.ariaLabel, '関連のみ表示'); + assert.equal(specs.related.screenReaderText, '関連のみ表示'); }); }); - test.describe('一覧/補助', () => { + test.describe('UI', () => { test('renderPackageTable: 行とカウントを描画する', () => { const doc = setupDocument(); - setPackageData(doc, { + setPackageData({ packages: [ {fqn: 'app.a', name: 'A', classCount: 2}, {fqn: 'app.b', name: 'B', classCount: 1}, @@ -507,11 +689,11 @@ test.describe('package.js', () => { {from: 'app.a', to: 'app.b'}, {from: 'app.a', to: 'app.b'}, ], - }); + }, testContext); const tbody = new Element('tbody', doc); doc.selectors.set('#package-table tbody', tbody); - pkg.renderPackageTable(); + pkg.renderPackageTable(testContext); assert.equal(tbody.children.length, 2); assert.equal(tbody.children[0].children[3].textContent, 'A'); @@ -519,148 +701,190 @@ test.describe('package.js', () => { assert.equal(tbody.children[0].children[5].textContent, '0'); assert.equal(tbody.children[0].children[6].textContent, '2'); }); + }); + }); - test('getOrCreateDiagramErrorBox: エラーボックスを作成/再利用する', () => { - const doc = setupDocument(); - const container = new Element('div', doc); - const diagram = new Element('div', doc); - container.appendChild(diagram); + test.describe('ダイアグラム', () => { + test.describe('ロジック', () => { + test('buildMutualDependencyItems: 相互依存の原因を整形する', () => { + const items = pkg.buildMutualDependencyItems( + new Set(['app.alpha::app.beta']), + [ + {from: 'app.alpha.A', to: 'app.beta.B'}, + {from: 'app.beta.B', to: 'app.alpha.A'}, + ], + 0 + ); - const first = pkg.getOrCreateDiagramErrorBox(diagram); - const second = pkg.getOrCreateDiagramErrorBox(diagram); + assert.equal(items.length, 1); + assert.equal(items[0].pairLabel, 'app.alpha <-> app.beta'); + assert.equal(items[0].causes.length, 2); + }); - assert.equal(first, second); - assert.equal(first.id, 'package-diagram-error'); - assert.equal(container.children[0], first); + test('detectStronglyConnectedComponents: 循環を検出する', () => { + const graph = new Map([ + ['a', ['b']], + ['b', ['c']], + ['c', ['a', 'd']], + ['d', ['e']], + ['e', ['f']], + ['f', ['d']], + ]); + const sccs = pkg.detectStronglyConnectedComponents(graph); + const sortedSccs = sccs.map(scc => scc.sort()).sort((a, b) => a[0].localeCompare(b[0])); + assert.deepEqual(sortedSccs, [['a', 'b', 'c'], ['d', 'e', 'f']]); }); - test('showDiagramErrorMessage/hideDiagramErrorMessage: 表示を切り替える', () => { - const doc = setupDocument(); - const container = new Element('div', doc); - const diagram = new Element('div', doc); - container.appendChild(diagram); - pkg.setDiagramElement(diagram); + test('transitiveReduction: 単純な推移関係を簡約する', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'c'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'b>c']); + }); - const errors = withConsoleErrorCapture(() => { - pkg.showDiagramErrorMessage('test-error-message', false); - }); - const errorBox = doc.getElementById('package-diagram-error'); - const messageNode = doc.getElementById('package-diagram-error-message'); + test('transitiveReduction: 循環参照は対象外とする', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'a'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a']); + }); - assert.equal(errorBox.style.display, ''); - assert.equal(diagram.style.display, 'none'); - assert.equal(messageNode.textContent, 'test-error-message'); - assert.equal(errors.some(line => line.includes('test-error-message')), true); + test('transitiveReduction: 循環ではないが簡約対象でもない', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'c', to: 'd'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'c>d']); + }); - pkg.hideDiagramErrorMessage(diagram); - assert.equal(errorBox.style.display, 'none'); - assert.equal(diagram.style.display, ''); + test('transitiveReduction: 循環からの関係は簡約対象にしない', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'a'}, // cycle + {from: 'b', to: 'c'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a', 'b>c']); }); - test('renderDiagramSvg: Mermaid描画を実行する', () => { - const doc = setupDocument(); - const container = new Element('div', doc); - const diagram = new Element('div', doc); - container.appendChild(diagram); - pkg.setDiagramElement(diagram); - - let runCalled = false; - global.window = { - mermaid: { - initialize() { - }, - run() { - runCalled = true; - }, - }, - }; - global.mermaid = global.window.mermaid; + test('buildDiagramEdgeLines: 相互依存の双方向リンクを生成する', () => { + const {ensureNodeId} = pkg.buildDiagramNodeMaps(new Set(['a', 'b']), new Map()); + const result = pkg.buildDiagramEdgeLines( + [{from: 'a', to: 'b'}, {from: 'b', to: 'a'}], + ensureNodeId + ); + assert.equal(result.edgeLines.some(line => line.includes('<-->')), true); + assert.equal(result.linkStyles.length, 1); + }); - pkg.renderDiagramSvg('graph TD', 100); + test('buildDiagramNodeLabel: サブグラフ配下のラベルを短縮する', () => { + const label = pkg.buildDiagramNodeLabel( + 'com.example.domain.model', + 'com.example.domain.model', + 'com.example.domain' + ); + assert.equal(label, 'model'); + }); - assert.equal(diagram.textContent, 'graph TD'); - assert.equal(runCalled, true); + test('buildDiagramNodeTooltip: FQNを返す', () => { + assert.equal(pkg.buildDiagramNodeTooltip('com.example.domain'), 'com.example.domain'); + assert.equal(pkg.buildDiagramNodeTooltip(null), ''); }); - }); - test.describe('既定フィルタ', () => { - test('applyDefaultPackageFilterIfPresent: ドメインがあれば適用', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [ - {fqn: 'app.domain.core'}, - {fqn: 'app.domain.sub'}, - ], - relations: [], - }); - doc.selectorsAll.set('#package-table tbody tr', []); - const {input} = createPackageFilterControls(doc); + test('buildDiagramGroupTree: 共通プレフィックスでグループ化する', () => { + const visibleFqns = ['com.example.a', 'com.example.b']; + const nodeIdByFqn = new Map([ + ['com.example.a', 'P0'], + ['com.example.b', 'P1'], + ]); - const applied = pkg.applyDefaultPackageFilterIfPresent(); + const rootGroup = pkg.buildDiagramGroupTree(visibleFqns, nodeIdByFqn); - assert.equal(applied, true); - assert.equal(pkg.getPackageFilterFqn(), 'app.domain'); - assert.equal(input.value, 'app.domain'); + assert.equal(rootGroup.children.has('com.example'), true); }); - test('applyDefaultPackageFilterIfPresent: 入力済みなら適用しない', () => { - const doc = setupDocument(); - setPackageData(doc, { - packages: [{fqn: 'app.domain.core'}], - relations: [], - }); - const input = doc.createElement('input'); - input.id = 'package-filter-input'; - input.value = 'app'; - doc.elementsById.set('package-filter-input', input); + test('buildSubgraphLines: サブグラフ行を生成する', () => { + const rootGroup = { + key: '', + nodes: [], + children: new Map([ + ['com.example', {key: 'com.example', nodes: ['P0', 'P1'], children: new Map()}], + ]), + }; + const addNodeLines = (lines, nodeId) => { + lines.push(`node ${nodeId}`); + }; - const applied = pkg.applyDefaultPackageFilterIfPresent(); + const lines = pkg.buildSubgraphLines(rootGroup, addNodeLines, text => text); - assert.equal(applied, false); + assert.equal(lines.some(line => line.includes('node P0')), true); }); - }); - }); - test.describe('ダイアグラム', () => { - test.describe('相互依存', () => { - test('renderMutualDependencyList: なしの場合は非表示', () => { - const doc = setupDocument(); - const container = new Element('div', doc); - doc.elementsById.set('mutual-dependency-list', container); + test('buildAggregationDepthOptions: 集約オプションを組み立てる', () => { + const stats = new Map([ + [0, {packageCount: 2, relationCount: 1}], + [1, {packageCount: 1, relationCount: 1}], + [2, {packageCount: 1, relationCount: 0}], + ]); - pkg.renderMutualDependencyList(new Set(), []); + const options = pkg.buildAggregationDepthOptions(stats, 2); - assert.equal(container.style.display, 'none'); - assert.equal(container.innerHTML, ''); + assert.deepEqual(options, [ + {value: '0', text: '集約なし(P2 / R1)'}, + {value: '1', text: '深さ1(P1 / R1)'}, + ]); }); - test('renderMutualDependencyList: 相互依存と原因を一覧化', () => { - const doc = setupDocument(); - const container = new Element('div', doc); - doc.elementsById.set('mutual-dependency-list', container); - pkg.setAggregationDepth(0); + test('buildDiagramNodeLines: クリックハンドラ名を埋め込む', () => { + const visibleSet = new Set(['app.a']); + const {nodeIdByFqn, nodeIdToFqn, nodeLabelById} = pkg.buildDiagramNodeMaps(visibleSet, new Map([['app.a', 'A']])); + const {nodeLines} = pkg.buildDiagramNodeLines( + visibleSet, + nodeIdByFqn, + nodeIdToFqn, + nodeLabelById, + text => text + ); + const clickLine = nodeLines.find(line => line.startsWith('click ')); + assert.ok(clickLine); + assert.equal(clickLine.includes(pkg.DIAGRAM_CLICK_HANDLER_NAME), true); + }); + }); - pkg.renderMutualDependencyList( - new Set(['app.alpha::app.beta']), - [ - {from: 'app.alpha.A', to: 'app.beta.B'}, - {from: 'app.beta.B', to: 'app.alpha.A'}, - ] - ); + test.describe('UI', () => { + test('renderMutualDependencyList: 相互依存と原因を一覧化する', () => { + const doc = setupDocument(); + const container = new Element('div', doc); + doc.elementsById.set('mutual-dependency-list', container); + testContext.aggregationDepth = 0; + + pkg.renderMutualDependencyList( + new Set(['app.alpha::app.beta']), + [ + {from: 'app.alpha.A', to: 'app.beta.B'}, + {from: 'app.beta.B', to: 'app.alpha.A'}, + ], + testContext + ); assert.equal(container.style.display, ''); - assert.equal(container.children.length, 1); - assert.equal(container.children[0].tagName, 'details'); - assert.equal(container.children[0].children[1].tagName, 'ul'); + assert.equal(container.children.length, 1); + assert.equal(container.children[0].tagName, 'details'); + assert.equal(container.children[0].children[1].tagName, 'ul'); }); - }); - test.describe('描画', () => { - test('renderPackageDiagram: 相互依存を含む描画', () => { + test('renderPackageDiagram: 相互依存を含めて描画する', () => { const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { + setupDiagramEnvironment(doc, testContext); + setPackageData({ packages: [ {fqn: 'app.a', name: 'A', classCount: 1}, {fqn: 'app.b', name: 'B', classCount: 1}, @@ -669,9 +893,9 @@ test.describe('package.js', () => { {from: 'app.a', to: 'app.b'}, {from: 'app.b', to: 'app.a'}, ], - }); + }, testContext); - pkg.renderPackageDiagram(null, null); + pkg.renderPackageDiagram(testContext, null, null); const diagram = doc.getElementById('package-relation-diagram'); assert.equal(diagram.textContent.includes('graph'), true); @@ -680,46 +904,26 @@ test.describe('package.js', () => { assert.equal(mutual.children.length > 0, true); }); - test('renderPackageDiagram: サブグラフ内のFQNノードラベルが省略される', () => { - const doc = setupDocument(); - const diagram = setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [ - {fqn: 'com.example', name: 'example', classCount: 1}, - {fqn: 'com.example.domain', name: 'domain', classCount: 1}, - {fqn: 'com.example.domain.model', classCount: 1}, // No 'name' property - {fqn: 'com.example.domain.repository', name: 'repository', classCount: 1}, - {fqn: 'com.example.service', name: 'service', classCount: 1}, - ], - relations: [ - {from: 'com.example.domain.model', to: 'com.example.domain.repository'}, - {from: 'com.example.service', to: 'com.example.domain'}, - ], - }); - - pkg.setAggregationDepth(0); // Set to no aggregation to ensure full hierarchy is built - pkg.renderPackageDiagram(null, null); - - const diagramContent = diagram.textContent; - - // Match subgraph creation for com.example.domain (using regex for groupId) - assert.ok(/subgraph G\d+\["com\.example\.domain"]/.test(diagramContent), 'Expected subgraph for com.example.domain'); - - // Check for nodes inside this subgraph: - // com.example.domain.model (FQN, should be shortened to 'model') - assert.ok(/P\d+\["model"]/.test(diagramContent), 'Expected "com.example.domain.model" to be shortened to "model"'); - // com.example.domain.repository (has 'name', should remain 'repository') - assert.ok(/P\d+\["repository"]/.test(diagramContent), 'Expected "com.example.domain.repository" to remain "repository"'); - - // Verify that 'com.example.service' is also present and correctly labeled - assert.ok(/P\d+\["service"]/.test(diagramContent), 'Expected "com.example.service" to be labeled "service"'); - }); - }); - - test.describe('分岐/エラー', () => { - test('renderPackageDiagram: エッジ数超過で保留/エラー表示', () => { + test('renderPackageDiagram: エッジ数超過時は保留/エラー表示する', () => { const doc = setupDocument(); - setupDiagramEnvironment(doc); + // setupDiagramEnvironmentはtestContext.diagramElementを設定する。 + // そのdiagramElementがdomヘルパーによって操作されることをモックする。 + const diagramMock = setupDiagramEnvironment(doc, testContext); // testContext.diagramElementも設定される + + // Mock dom helpers used by showDiagramErrorMessage internally + const errorBoxMock = { style: { display: 'none' } }; + const messageNodeMock = { textContent: '' }; + const actionNodeMock = { style: { display: 'none' }, onclick: null }; + test.mock.method(pkg.dom, 'getDiagramErrorBox', test.mock.fn(() => errorBoxMock)); + test.mock.method(pkg.dom, 'createDiagramErrorBox', test.mock.fn(() => errorBoxMock)); // called by getOrCreate + test.mock.method(pkg.dom, 'getDiagramErrorMessageNode', test.mock.fn(() => messageNodeMock)); + test.mock.method(pkg.dom, 'getDiagramErrorActionNode', test.mock.fn(() => actionNodeMock)); + test.mock.method(pkg.dom, 'setNodeTextContent', test.mock.fn((el, text) => { el.textContent = text; })); + test.mock.method(pkg.dom, 'setNodeDisplay', test.mock.fn((el, display) => { el.style.display = display; })); + test.mock.method(pkg.dom, 'setNodeOnClick', test.mock.fn((el, handler) => { el.onclick = handler; })); + test.mock.method(pkg.dom, 'setDiagramElementDisplay', test.mock.fn((el, display) => { el.style.display = display; })); + + // Data setup (same as before) const packages = []; const relations = []; for (let i = 0; i < 501; i += 1) { @@ -729,289 +933,69 @@ test.describe('package.js', () => { packages.push({fqn: to, name: to, classCount: 1}); relations.push({from, to}); } - setPackageData(doc, {packages, relations}); + setPackageData({packages, relations}, testContext); const errors = withConsoleErrorCapture(() => { - pkg.renderPackageDiagram(null, null); + pkg.renderPackageDiagram(testContext, null, null); }); - const errorBox = doc.getElementById('package-diagram-error'); - assert.equal(errorBox.style.display, ''); + assert.equal(errorBoxMock.style.display, '', 'errorBox should be displayed by showDiagramErrorMessage'); // Check display set by showDiagramErrorMessage + assert.equal(diagramMock.style.display, 'none', 'diagram should be hidden by showDiagramErrorMessage'); // Check display set by showDiagramErrorMessage assert.equal(errors.some(line => line.includes('関連数が多すぎるため描画を省略しました。')), true); + assert.ok(actionNodeMock.onclick, 'actionNode should have onclick handler'); + assert.equal(actionNodeMock.style.display, '', 'actionNode should be displayed'); }); - test('mermaid.parseError: エラー内容を表示', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [{fqn: 'app.a', name: 'A', classCount: 1}], - relations: [], - }); - pkg.renderPackageDiagram(null, null); - - // Mermaidはパース失敗時のみ呼ばれるため、テストでは直接呼び出す。 - const errors = withConsoleErrorCapture(() => { - global.mermaid.parseError( - {message: 'Edge limit exceeded'}, - {line: 10, loc: 2} - ); - }); - - const messageNode = doc.getElementById('package-diagram-error-message'); - assert.equal(messageNode.textContent.includes('Mermaid parse error:'), true); - assert.equal(messageNode.textContent.includes('Line: 10 Column: 2'), true); - assert.equal(errors.some(line => line.includes('Mermaid parse error:')), true); - assert.equal(errors.some(line => line.includes('Edge limit exceeded')), true); - assert.equal(errors.some(line => line.includes('Mermaid error location: 10 2')), true); - }); + test('registerDiagramClickHandler: クリックで関連フィルタへ切り替える', () => { + global.window = {}; + testContext.diagramNodeIdToFqn = new Map([['P1', 'app.example']]); + let called = null; + const applyRelatedFilter = (fqn, context) => { + called = {fqn, context}; + }; - test('renderDiagramAndTable: 描画とフィルタ適用を行う', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [ - {fqn: 'app.a', name: 'A', classCount: 1}, - {fqn: 'app.b', name: 'B', classCount: 1}, - ], - relations: [ - {from: 'app.a', to: 'app.b'}, - ], - }); - const rows = buildPackageRows(doc, ['app.a', 'app.b']); - doc.selectors.set('#package-table tbody', doc.createElement('tbody')); - const select = doc.createElement('select'); - select.id = 'package-depth-select'; - doc.elementsById.set('package-depth-select', select); - pkg.setRelatedFilterMode('direct'); - pkg.setRelatedFilterFqn('app.a'); - pkg.setPackageFilterFqn(null); - pkg.setAggregationDepth(0); - - pkg.renderDiagramAndTable(); + pkg.registerDiagramClickHandler(testContext, applyRelatedFilter); - assert.equal(rows[1].classList.contains('hidden'), false); - assert.equal(select.children.length > 0, true); - }); - }); - }); + global.window[pkg.DIAGRAM_CLICK_HANDLER_NAME]('P1'); - test.describe('UI制御', () => { - test('setupPackageFilterControls: 適用/解除をハンドリング', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [{fqn: 'app.domain', name: 'Domain', classCount: 1}], - relations: [], + assert.deepEqual(called, {fqn: 'app.example', context: testContext}); }); - doc.selectorsAll.set('#package-table tbody tr', []); - const {input, applyButton, clearButton} = createPackageFilterControls(doc); - - pkg.setupPackageFilterControls(); - - input.value = 'app.domain'; - applyButton.eventListeners.get('click')(); - assert.equal(pkg.getPackageFilterFqn(), 'app.domain'); - - clearButton.eventListeners.get('click')(); - assert.equal(pkg.getPackageFilterFqn(), null); - assert.equal(input.value, ''); - }); + test('setupTransitiveReductionControl: UIをセットアップする', () => { + const doc = setupDocument(); + const container = doc.createElement('div'); + const pp = doc.createElement('div'); + const input = doc.createElement('input'); + input.name = 'diagram-direction'; + pp.appendChild(input); + container.appendChild(pp); + doc.selectors.set('input[name="diagram-direction"]', input); + input.parentNode = pp; + pp.parentNode = container; + + // renderDiagramAndTableの副作用をチェックするための準備 + setupDiagramEnvironment(doc, testContext); + setPackageData({packages: [{fqn: 'a'}], relations: []}, testContext); + const depthSelect = createDepthSelect(doc); + const dummyOption = doc.createElement('option'); + dummyOption.id = 'dummy-option-for-test'; + depthSelect.appendChild(dummyOption); + doc.selectorsAll.set('#package-table tbody tr', []); - test('setupPackageFilterControls: Enterキーで適用', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [{fqn: 'app.domain', name: 'Domain', classCount: 1}], - relations: [], - }); - doc.selectorsAll.set('#package-table tbody tr', []); - const {input} = createPackageFilterControls(doc); - pkg.setupPackageFilterControls(); + pkg.setupTransitiveReductionControl(testContext); - let prevented = false; - input.value = 'app.domain'; - input.eventListeners.get('keydown')({ - key: 'Enter', - preventDefault() { - prevented = true; - }, - }); + const checkbox = doc.getElementById('transitive-reduction-toggle'); + assert.ok(checkbox, 'checkbox should be created'); + assert.equal(checkbox.checked, true); + assert.equal(testContext.transitiveReductionEnabled, true); - assert.equal(prevented, true); - assert.equal(pkg.getPackageFilterFqn(), 'app.domain'); - }); + // changeイベントを発火させる + checkbox.checked = false; + checkbox.eventListeners.get('change')(); - test('setupAggregationDepthControl: 変更を反映する', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [ - {fqn: 'app.domain.a'}, - {fqn: 'app.domain.b'}, - ], - relations: [], + assert.equal(testContext.transitiveReductionEnabled, false); }); - doc.selectorsAll.set('#package-table tbody tr', []); - const select = createDepthSelect(doc); - - pkg.setAggregationDepth(0); - pkg.setupAggregationDepthControl(); - - select.value = '1'; - select.eventListeners.get('change')(); - assert.equal(select.value, '1'); - }); - - test('setupRelatedFilterControls: モード変更を反映', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [ - {fqn: 'app.a'}, - {fqn: 'app.b'}, - {fqn: 'app.c'}, - ], - relations: [ - {from: 'app.a', to: 'app.b'}, - {from: 'app.b', to: 'app.c'}, - ], - }); - const {select, clearButton} = createRelatedFilterControls(doc); - const input = doc.createElement('input'); - input.id = 'package-filter-input'; - doc.elementsById.set('package-filter-input', input); - - pkg.setAggregationDepth(0); - pkg.setRelatedFilterMode('direct'); - pkg.setRelatedFilterFqn('app.a'); - pkg.setupRelatedFilterControls(); - select.value = 'all'; - select.eventListeners.get('change')(); - - const related = pkg.collectRelatedSet('app.a', [ - {from: 'app.a', to: 'app.b'}, - {from: 'app.b', to: 'app.c'}, - ]); - assert.equal(related.has('app.c'), true); - - clearButton.eventListeners.get('click')(); - assert.equal(pkg.getRelatedFilterFqn(), null); - }); - - test('setupDiagramDirectionControls: 向きを切り替える', () => { - const doc = setupDocument(); - setupDiagramEnvironment(doc); - setPackageData(doc, { - packages: [{fqn: 'app.a'}], - relations: [], - }); - doc.selectorsAll.set('#package-table tbody tr', []); - const td = doc.createElement('input'); - td.value = 'TD'; - const lr = doc.createElement('input'); - lr.value = 'LR'; - doc.selectorsAll.set('input[name=\"diagram-direction\"]', [td, lr]); - - pkg.setupDiagramDirectionControls(); - - lr.checked = true; - lr.eventListeners.get('change')(); - assert.equal(pkg.getDiagramDirection(), 'LR'); - }); - }); - - test.describe('推移簡約', () => { - test('detectStronglyConnectedComponents: 循環を検出する', () => { - const graph = new Map([ - ['a', ['b']], - ['b', ['c']], - ['c', ['a', 'd']], - ['d', ['e']], - ['e', ['f']], - ['f', ['d']], - ]); - const sccs = pkg.detectStronglyConnectedComponents(graph); - const sortedSccs = sccs.map(scc => scc.sort()).sort((a, b) => a[0].localeCompare(b[0])); - assert.deepEqual(sortedSccs, [['a', 'b', 'c'], ['d', 'e', 'f']]); - }); - - test('transitiveReduction: 単純な推移関係を簡約する', () => { - const relations = [ - {from: 'a', to: 'b'}, - {from: 'b', to: 'c'}, - {from: 'a', to: 'c'}, - ]; - const result = pkg.transitiveReduction(relations); - assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'b>c']); - }); - - test('transitiveReduction: 循環参照は対象外とする', () => { - const relations = [ - {from: 'a', to: 'b'}, - {from: 'b', to: 'a'}, - {from: 'a', to: 'c'}, - ]; - const result = pkg.transitiveReduction(relations); - assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a']); - }); - - test('transitiveReduction: 循環ではないが簡約対象でもない', () => { - const relations = [ - {from: 'a', to: 'b'}, - {from: 'c', to: 'd'}, - ]; - const result = pkg.transitiveReduction(relations); - assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'c>d']); - }); - - test('transitiveReduction: 循環からの関係は簡約対象にしない', () => { - const relations = [ - {from: 'a', to: 'b'}, - {from: 'b', to: 'a'}, // cycle - {from: 'b', to: 'c'}, - {from: 'a', to: 'c'}, - ]; - const result = pkg.transitiveReduction(relations); - assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a', 'b>c']); - }); - - test('setupTransitiveReductionControl: UIをセットアップする', () => { - const doc = setupDocument(); - const container = doc.createElement('div'); - const pp = doc.createElement('div'); - const input = doc.createElement('input'); - input.name = 'diagram-direction'; - pp.appendChild(input); - container.appendChild(pp); - doc.selectors.set('input[name="diagram-direction"]', input); - input.parentNode = pp; - pp.parentNode = container; - - // renderDiagramAndTableの副作用をチェックするための準備 - setupDiagramEnvironment(doc); - setPackageData(doc, {packages: [{fqn: 'a'}], relations: []}); - const depthSelect = createDepthSelect(doc); - const dummyOption = doc.createElement('option'); - dummyOption.id = 'dummy-option-for-test'; - depthSelect.appendChild(dummyOption); - - pkg.setupTransitiveReductionControl(); - - const checkbox = doc.getElementById('transitive-reduction-toggle'); - assert.ok(checkbox, 'checkbox should be created'); - assert.equal(checkbox.checked, true); - - // 事前確認:ダミー要素が存在する - assert.strictEqual(depthSelect.children.some(c => c.id === 'dummy-option-for-test'), true); - - // changeイベントを発火させる - checkbox.checked = false; - checkbox.eventListeners.get('change')(); - - // 事後確認:`renderDiagramAndTable`が呼ばれ、selectの中身が再構築され、dummy-optionが消えているはず - assert.strictEqual(depthSelect.children.some(c => c.id === 'dummy-option-for-test'), false); }); }); });