diff --git a/README.md b/README.md index 16583fc..99853c0 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ Diff two AUTOSAR code-generation output folders (MATLAB/Simulink Embedded Coder) Regenerating a Simulink model rewrites timestamps, UUIDs, comment banners and auto-generated variable names even when the behaviour is identical. A plain `git diff` or Beyond Compare run drowns the reviewer in that noise. This tool classifies every hunk as *real* or *ignorable*, then renders a self-contained HTML report with an AUTOSAR-level summary on top of the text diff. -**Zero dependencies** — Python 3.8+ standard library only. No pip install required, no server, no internet access. +**Zero dependencies** — Python 3.8+ standard library only. No pip install required, no server, no internet access. (The optional [side-by-side viewer](#side-by-side-viewer) adds PySide6; the CLI, GUI and HTML report stay dependency-free.) - [Install](#install) - [Quick start](#quick-start) - [Command line](#command-line) - [GUI](#gui) +- [Side-by-side viewer](#side-by-side-viewer) - [What counts as noise](#what-counts-as-noise) - [Moved block detection](#moved-block-detection) - [AUTOSAR semantic summary](#autosar-semantic-summary) @@ -68,6 +69,7 @@ Exit `2` is never silent: the terminal prints `!!`, the report gets a red banner | `--exit-zero` | Always exit 0 even when real changes exist (report-only mode for pipelines). Compare errors still exit 2 | | `--arxml-only` | Scan only `.arxml`/`.xml`/`.a2l` and write a compact report (default `arxml_update.html`): a **per-type verdict** (`ARXML updated: …` / `A2L updated: …`, or `no changes` / `no files found`), the updated files split per type, and the AUTOSAR/A2L changes. The report is **always written** — when nothing changed it says "No ARXML or A2L updates" rather than skipping the file, so a missing file is never confused with a crashed run | | `--gui` | Open the GUI window instead of running in the terminal. `old_dir`/`new_dir` become optional and prefill the folder fields when given | +| `--qt` | Open the **side-by-side viewer** (PySide6): a folder tree plus a two-pane old/new diff with a change minimap, Beyond-Compare style. `old_dir`/`new_dir` are optional; when omitted the viewer prompts for them. Needs the `viewer` extra (see below) | ## GUI @@ -79,6 +81,30 @@ A tkinter front panel (stdlib, no server) covering every CLI mode: browse for th The scan runs on a worker thread — the window stays responsive and shows a progress bar. On completion you get a colour-coded verdict (green = no real change, orange = real changes, red = COMPARE INCOMPLETE), the same log the terminal prints, and an **Open report** button. It shares the `run_compare()` core with the CLI, so fail-safe semantics are identical: a worker that dies mid-run shows a red `RUN FAILED` instead of a half-finished result. +## Side-by-side viewer + +```bash +pip install "codegen-compare-tool[viewer]" # or: pip install PySide6 +python -m compare_tool --qt +``` + +A Beyond-Compare-style desktop app (PySide6) for reviewing changes interactively instead of scrolling an HTML report: + +- **Folder tree** on the left, each file coloured by verdict (Modified / Unimportant / Added / Deleted / Identical / **NOT compared**). A path filter and *Show: Identical / Unimportant* toggles narrow it down; by default both are off, so the tree opens on real changes only. +- **Two-pane diff** on the right: old and new aligned line-for-line and scrolled in lockstep, real changes in red/green, generator noise in yellow, moved blocks in blue, with the exact changed characters highlighted inside each line — the same classification the report uses. +- **Change minimap** down the right edge: the whole file compressed to one bar per change, with a viewport box; click or drag to jump. +- **`F7` / `F8`** step to the previous / next real change (noise is skipped). For `.arxml`/`.a2l` files the header shows the AUTOSAR / A2L rollup (`+1 port · ~1 event`, …). + +PySide6 is imported only under `--qt`, so the CLI and the HTML report keep working on a headless box with no Qt installed. Fail-safe is unchanged: an uncompared path raises a red **COMPARE INCOMPLETE** banner and a scan crash shows a loud failure — never an empty, clean-looking tree. + +**Standalone `.exe`** — to hand the viewer to colleagues who have no Python, build a single self-contained binary with [PyInstaller](https://pyinstaller.org): + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build-viewer.ps1 +``` + +That produces `dist\CodeGenCompareViewer.exe` (~45 MB) from [`packaging/compare-viewer.spec`](packaging/compare-viewer.spec) — double-click to open, or pass two folder paths as arguments to prefill OLD/NEW. PyInstaller does not cross-compile, so build on the OS you are targeting. + ## What counts as noise | Kind | Rule | Files | diff --git a/compare_tool/main.py b/compare_tool/main.py index 027ed10..964eeda 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -137,6 +137,11 @@ def main(argv=None): help='open the graphical front panel (tkinter) instead of ' 'running in the terminal; old_dir/new_dir are ' 'optional and prefill the folder fields when given') + ap.add_argument('--qt', action='store_true', + help='open the side-by-side compare viewer (PySide6): a ' + 'folder tree with a two-pane old/new diff, like Beyond ' + 'Compare. old_dir/new_dir are optional; when omitted ' + 'the viewer prompts for them') ap.add_argument('--report', metavar='OUT.html', default=None, help='HTML report output path (default: compare_report.html, ' 'or arxml_update.html with --arxml-only)') @@ -159,6 +164,10 @@ def main(argv=None): if args.gui: from .gui import run_gui # deferred: tkinter may be absent headless return run_gui(args.old_dir, args.new_dir) + if args.qt: + from .qtviewer import run_viewer # deferred: PySide6 may be absent + return run_viewer(args.old_dir, args.new_dir, exclude=args.exclude, + arxml_only=args.arxml_only) if not args.old_dir or not args.new_dir: ap.error('old_dir and new_dir are required (or use --gui)') # Windows consoles often run a legacy codepage (cp1252/cp437) that cannot diff --git a/compare_tool/qtviewer/__init__.py b/compare_tool/qtviewer/__init__.py new file mode 100644 index 0000000..a5eee69 --- /dev/null +++ b/compare_tool/qtviewer/__init__.py @@ -0,0 +1,15 @@ +"""PySide6 side-by-side compare viewer (Beyond-Compare style). + +Optional feature: PySide6 is imported lazily so the CLI and the HTML report +keep working on a headless box with no Qt installed. Launch: + + python -m compare_tool --qt [old_dir] [new_dir] + +``run_viewer`` lives in :mod:`compare_tool.qtviewer.app`; importing it pulls in +PySide6, so it is imported there, not here. +""" + + +def run_viewer(*args, **kwargs): + from .app import run_viewer as _run + return _run(*args, **kwargs) diff --git a/compare_tool/qtviewer/app.py b/compare_tool/qtviewer/app.py new file mode 100644 index 0000000..d7169c3 --- /dev/null +++ b/compare_tool/qtviewer/app.py @@ -0,0 +1,260 @@ +"""Viewer main window: folder tree on the left, diff pane on the right. + +The scan runs on a :class:`ScanWorker` thread; the window only reacts to its +signals. Fail-safe stays first-class: a worker crash or any uncompared path +raises a loud red banner -- an incomplete compare must never look clean. +""" + +import sys + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QBrush, QColor, QPalette +from PySide6.QtWidgets import (QApplication, QCheckBox, QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QMainWindow, QProgressBar, + QSplitter, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget) + +from ..diff_engine import RULES +from ..scanner import summarize +from .diffpane import DiffPane +from .tree import STATUS, build_nodes, filter_nodes +from .worker import ScanWorker + +REL_ROLE = Qt.UserRole # QTreeWidgetItem data slot holding a file's rel path + + +def _arxml_include(): + """Same include globs run_compare uses for --arxml-only.""" + return tuple('*' + ext for ext, rs in RULES.items() if rs in ('arxml', 'a2l')) + + +class MainWindow(QMainWindow): + def __init__(self, old=None, new=None, exclude=(), arxml_only=False): + super().__init__() + self.old = old + self.new = new + self.exclude = tuple(exclude) + self.include = _arxml_include() if arxml_only else () + self.results = {} + self.worker = None + + self.setWindowTitle('AUTOSAR CodeGen Compare — viewer') + self.resize(1200, 800) + + self.banner = QLabel() + self.banner.setVisible(False) + self.banner.setWordWrap(True) + self.banner.setStyleSheet('background:#4a1d1d; color:#ffd6d6; padding:6px 10px;' + 'font-weight:bold; border-bottom:1px solid #b04a4a;') + + self.tree = QTreeWidget() + self.tree.setHeaderLabels(['File', 'Status']) + self.tree.setColumnWidth(0, 380) + self.tree.setUniformRowHeights(True) + self.tree.itemSelectionChanged.connect(self._on_select) + + # filter row: path search + status toggles. Defaults hide noise + # (identical + unimportant) so the tree opens on real changes. + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText('Filter by path…') + self.filter_edit.setClearButtonEnabled(True) + self.filter_edit.textChanged.connect(self._refresh_tree) + self.cb_identical = QCheckBox('Identical') + self.cb_unimportant = QCheckBox('Unimportant') + self.cb_identical.toggled.connect(self._refresh_tree) + self.cb_unimportant.toggled.connect(self._refresh_tree) + toggles = QHBoxLayout() + toggles.setContentsMargins(0, 0, 0, 0) + toggles.addWidget(QLabel('Show:')) + toggles.addWidget(self.cb_identical) + toggles.addWidget(self.cb_unimportant) + toggles.addStretch(1) + left = QWidget() + lv = QVBoxLayout(left) + lv.setContentsMargins(6, 6, 6, 0) + lv.setSpacing(4) + lv.addWidget(self.filter_edit) + lv.addLayout(toggles) + lv.addWidget(self.tree, 1) + + self.diff = DiffPane() + + split = QSplitter(Qt.Horizontal) + split.addWidget(left) + split.addWidget(self.diff) + split.setStretchFactor(0, 0) + split.setStretchFactor(1, 1) + split.setSizes([400, 800]) + + central = QWidget() + v = QVBoxLayout(central) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(0) + v.addWidget(self.banner) + v.addWidget(split) + self.setCentralWidget(central) + + self.progress = QProgressBar() + self.progress.setMaximumWidth(240) + self.progress.setVisible(False) + self.statusBar().addPermanentWidget(self.progress) + + tb = self.addToolBar('main') + tb.setMovable(False) + act_open = QAction('Open folders…', self) + act_open.triggered.connect(self._pick_folders) + act_rescan = QAction('Rescan', self) + act_rescan.triggered.connect(self._start_scan) + tb.addAction(act_open) + tb.addAction(act_rescan) + tb.addSeparator() + act_prev = QAction('◀ Prev change', self) + act_prev.setShortcut('F7') + act_prev.triggered.connect(lambda: self.diff.prev_change()) + act_next = QAction('Next change ▶', self) + act_next.setShortcut('F8') + act_next.triggered.connect(lambda: self.diff.next_change()) + tb.addAction(act_prev) + tb.addAction(act_next) + + if self.old and self.new: + self._start_scan() + else: + self._pick_folders() + + # --- folder selection --- + + def _pick_folders(self): + o = QFileDialog.getExistingDirectory(self, 'Select OLD folder', self.old or '') + if not o: + return + n = QFileDialog.getExistingDirectory(self, 'Select NEW folder', self.new or o) + if not n: + return + self.old, self.new = o, n + self._start_scan() + + # --- scan lifecycle --- + + def _start_scan(self): + if not (self.old and self.new): + return + if self.worker and self.worker.isRunning(): + return + self.banner.setVisible(False) + self.tree.clear() + self.diff.clear() + self.results = {} + self.progress.setVisible(True) + self.progress.setRange(0, 0) # busy/indeterminate until first tick + self.setWindowTitle('AUTOSAR CodeGen Compare — {} → {}'.format(self.old, self.new)) + self.statusBar().showMessage('Scanning…') + self.worker = ScanWorker(self.old, self.new, self.exclude, self.include) + self.worker.progressed.connect(self._on_progress) + self.worker.done.connect(self._on_done) + self.worker.failed.connect(self._on_fail) + self.worker.start() + + def _on_progress(self, done, total, rel): + self.progress.setRange(0, max(total, 1)) + self.progress.setValue(done) + self.statusBar().showMessage('Scanning {}/{}: {}'.format(done, total, rel)) + + def _on_done(self, results): + self.results = results + self._refresh_tree() + counts = summarize(results) + self.progress.setRange(0, 1) + self.progress.setValue(1) + self.progress.setVisible(False) + if counts['error']: + errs = sorted(rel for rel, r in results.items() if r['status'] == 'error') + shown = ', '.join(errs[:20]) + (' …' if len(errs) > 20 else '') + self.banner.setText('⚠ COMPARE INCOMPLETE — {} path(s) NOT compared ' + '(treat as potentially changed): {}'.format(len(errs), shown)) + self.banner.setVisible(True) + self.statusBar().showMessage( + '{real-change} modified · {ignorable-only} unimportant · {added} added · ' + '{deleted} deleted · {identical} identical · {error} error(s)'.format(**counts)) + + def _on_fail(self, msg): + self.progress.setVisible(False) + self.banner.setText('‼ SCAN FAILED — no results (treat everything as ' + 'potentially changed): {}'.format(msg)) + self.banner.setVisible(True) + self.statusBar().showMessage('SCAN FAILED') + + # --- tree fill + selection --- + + def _refresh_tree(self): + """Rebuild the tree from results under the current filter + toggles. + Cheap enough to run on every keystroke; selection is not preserved.""" + self.tree.clear() + if not self.results: + return + nodes = filter_nodes(build_nodes(self.results), + show_identical=self.cb_identical.isChecked(), + show_unimportant=self.cb_unimportant.isChecked(), + text=self.filter_edit.text()) + self._fill_tree(nodes) + + def _fill_tree(self, nodes): + def add(parent, node): + marker, label, color = STATUS[node.status] + item = QTreeWidgetItem(['{} {}'.format(marker, node.name), label]) + brush = QBrush(QColor(color)) + item.setForeground(0, brush) + item.setForeground(1, brush) + if not node.is_dir: + item.setData(0, REL_ROLE, node.rel) + for ch in node.children: + add(item, ch) + if parent is None: + self.tree.addTopLevelItem(item) + else: + parent.addChild(item) + if node.is_dir: + item.setExpanded(True) + + for n in nodes: + add(None, n) + + def _on_select(self): + items = self.tree.selectedItems() + if not items: + return + rel = items[0].data(0, REL_ROLE) + if rel and rel in self.results: + self.diff.show_file(rel, self.results[rel], self.old, self.new) + + +def _apply_dark(app): + """Fusion dark palette so the viewer matches the report's dark identity.""" + app.setStyle('Fusion') + p = QPalette() + bg, base, text = QColor('#1e1f22'), QColor('#232427'), QColor('#d4d4d4') + p.setColor(QPalette.Window, bg) + p.setColor(QPalette.Base, base) + p.setColor(QPalette.AlternateBase, bg) + p.setColor(QPalette.Text, text) + p.setColor(QPalette.WindowText, text) + p.setColor(QPalette.Button, base) + p.setColor(QPalette.ButtonText, text) + p.setColor(QPalette.Highlight, QColor('#3a5a7a')) + p.setColor(QPalette.HighlightedText, QColor('#ffffff')) + app.setPalette(p) + + +def run_viewer(old=None, new=None, exclude=(), arxml_only=False): + app = QApplication.instance() + owns = app is None + if owns: + app = QApplication(sys.argv[:1]) + _apply_dark(app) + win = MainWindow(old, new, exclude, arxml_only) + win.show() + return app.exec() if owns else 0 + + +if __name__ == '__main__': + sys.exit(run_viewer()) diff --git a/compare_tool/qtviewer/diffpane.py b/compare_tool/qtviewer/diffpane.py new file mode 100644 index 0000000..f992928 --- /dev/null +++ b/compare_tool/qtviewer/diffpane.py @@ -0,0 +1,383 @@ +"""Right-hand side of the viewer: the two-pane side-by-side diff. + +The whole-file alignment from :func:`compare_tool.view_model.aligned_rows` +gives one row per aligned line, so every row maps to the SAME line number in +both editors (a padded side becomes a blank line). That equal block count is +what makes the two panes scroll in lockstep with a trivial scrollbar mirror. + +Row backgrounds follow the report's palette (real = red/green, minor = yellow, +moved = blue, absent side = dim filler); the changed characters inside a line +are highlighted at the exact offsets :func:`view_model.char_span` reports, so +the pane and the HTML report mark identical spans. +""" + +from pathlib import Path + +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import (QColor, QFont, QPainter, QTextBlockFormat, + QTextCharFormat, QTextCursor) +from PySide6.QtWidgets import (QHBoxLayout, QLabel, QPlainTextEdit, QSplitter, + QStackedWidget, QVBoxLayout, QWidget) + +from ..scanner import looks_binary, read_text +from ..view_model import aligned_rows, char_span +from .minimap import Minimap + +_HINT = 'Select a file in the tree to view its diff.' + + +def _pm(label, added, removed, changed=0): + """'+2/−1 port' style chip; empty string when nothing changed.""" + bits = [] + if added: + bits.append('+{}'.format(added)) + if removed: + bits.append('−{}'.format(removed)) + if changed: + bits.append('~{}'.format(changed)) + return '{} {}'.format('/'.join(bits), label) if bits else '' + + +def _semantic_summary(result): + """Compact AUTOSAR / A2L change rollup for the file header, reusing the + semantic diffs the scanner already attached (interfaces, SWC ports / + runnables / events, RTE access points, A2L objects). '' when none.""" + chips = [] + s = result.get('swc') + if s: + chips.append(_pm('SWC', len(s['swcs']['added']), len(s['swcs']['removed']))) + for cat, label in (('ports', 'port'), ('runnables', 'runnable'), + ('events', 'event')): + chips.append(_pm(label, len(s[cat]['added']), len(s[cat]['removed']), + len(s[cat]['changed']))) + d = result.get('ifaces') + if d: + chips.append(_pm('interface', len(d['added']), len(d['removed']))) + t = result.get('rte') + if t: + chips.append(_pm('RTE', len(t['added']), len(t['removed']))) + a = result.get('a2l') + if a: + chips.append(_pm('A2L', len(a['added']), len(a['removed']))) + chips = [c for c in chips if c] + return 'AUTOSAR / A2L: ' + ' · '.join(chips) if chips else '' + +# per-side row background by mode; None = context (editor base colour) +_ROW_BG = { + ('real', 'old'): '#3a2222', ('real', 'new'): '#1f3a24', + ('minor', 'old'): '#3c3418', ('minor', 'new'): '#3c3418', + ('moved', 'old'): '#1d2f3e', ('moved', 'new'): '#1d2f3e', +} +# inline changed-span background by mode/side +_SEG_BG = { + ('real', 'old'): '#7a2f2f', ('real', 'new'): '#2f6e3d', + ('minor', 'old'): '#8a6d1f', ('minor', 'new'): '#8a6d1f', + ('moved', 'old'): '#2f5a7a', ('moved', 'new'): '#2f5a7a', +} +_FILLER_BG = '#26272b' # the absent side of an insert/delete +_ADD_BG = '#1f3a24' +_DEL_BG = '#3a2222' +_BASE_BG = '#232427' + + +class _Gutter(QWidget): + """Line-number margin painted by its owning editor.""" + + def __init__(self, editor): + super().__init__(editor) + self._editor = editor + + def sizeHint(self): + return QSize(self._editor.gutter_width(), 0) + + def paintEvent(self, event): + self._editor.paint_gutter(event) + + +class DiffEditor(QPlainTextEdit): + """Read-only monospace pane with a per-side line-number gutter. The gutter + shows each row's ORIGINAL file line number (blank on padded rows), not the + visual row index.""" + + def __init__(self): + super().__init__() + self.setReadOnly(True) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.setFrameStyle(0) + f = QFont('Consolas', 10) + f.setStyleHint(QFont.Monospace) + self.setFont(f) + self.setStyleSheet('QPlainTextEdit{{background:{};color:#d4d4d4;' + 'border:none;}}'.format(_BASE_BG)) + self._nos = [] # per block: line-number string ('' for padding) + self._gutter = _Gutter(self) + self.blockCountChanged.connect(lambda _n: self._update_gutter_width()) + self.updateRequest.connect(self._on_update_request) + self._update_gutter_width() + + def set_numbers(self, nos): + self._nos = nos + self._update_gutter_width() + self._gutter.update() + + def gutter_width(self): + digits = max((len(s) for s in self._nos), default=1) + digits = max(digits, 2) + return 12 + self.fontMetrics().horizontalAdvance('9') * digits + + def _update_gutter_width(self): + self.setViewportMargins(self.gutter_width(), 0, 0, 0) + + def _on_update_request(self, rect, dy): + if dy: + self._gutter.scroll(0, dy) + else: + self._gutter.update(0, rect.y(), self._gutter.width(), rect.height()) + + def resizeEvent(self, event): + super().resizeEvent(event) + cr = self.contentsRect() + self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height())) + + def paint_gutter(self, event): + painter = QPainter(self._gutter) + painter.fillRect(event.rect(), QColor('#1e1f22')) + block = self.firstVisibleBlock() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + painter.setPen(QColor('#6a6a6a')) + h = self.fontMetrics().height() + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + idx = block.blockNumber() + num = self._nos[idx] if idx < len(self._nos) else '' + if num: + painter.drawText(0, int(top), self._gutter.width() - 6, h, + Qt.AlignRight, num) + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + + +class DiffPane(QStackedWidget): + """Message page (identical / added / deleted / binary / error) OR the + two-editor side-by-side page. ``show_file`` / ``clear`` are the seam the + main window drives.""" + + def __init__(self): + super().__init__() + self._msg = QLabel(_HINT) + self._msg.setAlignment(Qt.AlignCenter) + self._msg.setWordWrap(True) + msg_page = QWidget() + ml = QVBoxLayout(msg_page) + ml.addWidget(self._msg) + + self._header = QLabel('') + self._header.setStyleSheet('color:#e8e8e8; padding:6px 10px 0; font-weight:bold;') + self._sem = QLabel('') + self._sem.setWordWrap(True) + self._sem.setStyleSheet('color:#9a9a9a; padding:0 10px 6px; font-size:12px;') + self._sem.setVisible(False) + self.old_edit = DiffEditor() + self.new_edit = DiffEditor() + self._split = QSplitter(Qt.Horizontal) + self._split.addWidget(self.old_edit) + self._split.addWidget(self.new_edit) + self._split.setSizes([500, 500]) + self.minimap = Minimap(self.old_edit) + body = QWidget() + bl = QHBoxLayout(body) + bl.setContentsMargins(0, 0, 0, 0) + bl.setSpacing(0) + bl.addWidget(self._split, 1) + bl.addWidget(self.minimap) + diff_page = QWidget() + dl = QVBoxLayout(diff_page) + dl.setContentsMargins(0, 0, 0, 0) + dl.setSpacing(0) + dl.addWidget(self._header) + dl.addWidget(self._sem) + dl.addWidget(body) + + self.addWidget(msg_page) # index 0 + self.addWidget(diff_page) # index 1 + + self.rows = [] + self._stops = [] # first row of each real/moved change block + self._syncing = False + self._link_scrolls() + + # --- scroll sync: equal block counts make it a straight mirror --- + + def _link_scrolls(self): + ov, nv = self.old_edit.verticalScrollBar(), self.new_edit.verticalScrollBar() + oh, nh = self.old_edit.horizontalScrollBar(), self.new_edit.horizontalScrollBar() + ov.valueChanged.connect(lambda v: self._mirror(nv, v)) + nv.valueChanged.connect(lambda v: self._mirror(ov, v)) + oh.valueChanged.connect(lambda v: self._mirror(nh, v)) + nh.valueChanged.connect(lambda v: self._mirror(oh, v)) + + def _mirror(self, bar, value): + if self._syncing: + return + self._syncing = True + bar.setValue(value) + self._syncing = False + + # --- public seam --- + + def clear(self): + self._msg.setText(_HINT) + self.setCurrentIndex(0) + + def show_file(self, rel, result, old_root, new_root): + try: + self._show_file(rel, result, old_root, new_root) + except Exception as e: + # rendering re-reads from disk; a failure must stay loud and never + # masquerade as an empty (== unchanged-looking) diff + self._message('{}\n\nCould not render — treat as potentially ' + 'changed.\n{}: {}'.format(rel, type(e).__name__, e)) + + # --- internals --- + + def _message(self, text): + self.rows = [] + self._stops = [] + self.minimap.set_rows([]) + self._msg.setText(text) + self.setCurrentIndex(0) + + def _show_file(self, rel, result, old_root, new_root): + status = result.get('status') + old_p, new_p = Path(old_root) / rel, Path(new_root) / rel + if status == 'error': + self._message('{}\n\nNOT compared — treat as potentially changed.\n{}' + .format(rel, '; '.join(result.get('notes', [])))) + return + if result.get('binary'): + self._message('{}\n\nBinary file differs.'.format(rel)) + return + if status == 'identical': + self._message('{}\n\nIdentical.'.format(rel)) + return + if status == 'added': + if looks_binary(new_p): + self._message('{}\n\nBinary file added.'.format(rel)) + return + self._load_one_side(rel, 'Added', read_text(new_p).split('\n'), 'new') + return + if status == 'deleted': + if looks_binary(old_p): + self._message('{}\n\nBinary file deleted.'.format(rel)) + return + self._load_one_side(rel, 'Deleted', read_text(old_p).split('\n'), 'old') + return + # real-change / ignorable-only + old_lines = read_text(old_p).split('\n') + new_lines = read_text(new_p).split('\n') + self.rows = aligned_rows(old_lines, new_lines, result['hunks']) + self._load_rows(rel, status, result) + + def _load_rows(self, rel, status, result=None): + rows = self.rows + n_moved = sum(1 for r in rows if r.mode == 'moved') + head = '{} · {}'.format(rel, status) + if n_moved: + head += ' · {} moved line(s)'.format(n_moved) + self._header.setText(head) + sem = _semantic_summary(result or {}) + self._sem.setText(sem) + self._sem.setVisible(bool(sem)) + self.old_edit.setPlainText('\n'.join(r.old_txt or '' for r in rows)) + self.new_edit.setPlainText('\n'.join(r.new_txt or '' for r in rows)) + self.old_edit.set_numbers([str(r.old_no) if r.old_no else '' for r in rows]) + self.new_edit.set_numbers([str(r.new_no) if r.new_no else '' for r in rows]) + self.minimap.set_rows(rows) + + for i, r in enumerate(rows): + if r.mode == 'ctx': + continue + # old side + if r.old_txt is None: + self._block_bg(self.old_edit, i, _FILLER_BG) + else: + self._block_bg(self.old_edit, i, _ROW_BG.get((r.mode, 'old'))) + # new side + if r.new_txt is None: + self._block_bg(self.new_edit, i, _FILLER_BG) + else: + self._block_bg(self.new_edit, i, _ROW_BG.get((r.mode, 'new'))) + # inline highlight only when both sides present + if r.old_txt is not None and r.new_txt is not None: + (o_lo, o_hi), (n_lo, n_hi) = char_span(r.old_txt, r.new_txt) + self._seg_bg(self.old_edit, i, o_lo, o_hi, _SEG_BG.get((r.mode, 'old'))) + self._seg_bg(self.new_edit, i, n_lo, n_hi, _SEG_BG.get((r.mode, 'new'))) + # navigation stops: first row of each contiguous real/moved block + self._stops = [] + was_change = False + for i, r in enumerate(rows): + is_change = r.mode in ('real', 'moved') + if is_change and not was_change: + self._stops.append(i) + was_change = is_change + self.setCurrentIndex(1) + if self._stops: + self._center(self._stops[0]) + + def _load_one_side(self, rel, label, lines, side): + self.rows = [] + self._stops = [] + self.minimap.set_rows([]) + self._sem.setVisible(False) + self._header.setText('{} · {}'.format(rel, label)) + edit = self.old_edit if side == 'old' else self.new_edit + other = self.new_edit if side == 'old' else self.old_edit + bg = _DEL_BG if side == 'old' else _ADD_BG + edit.setPlainText('\n'.join(lines)) + edit.set_numbers([str(i + 1) for i in range(len(lines))]) + other.setPlainText('') + other.set_numbers([]) + for i in range(len(lines)): + self._block_bg(edit, i, bg) + self.setCurrentIndex(1) + + def _block_bg(self, editor, block_no, color): + if not color: + return + block = editor.document().findBlockByNumber(block_no) + cursor = QTextCursor(block) + fmt = QTextBlockFormat() + fmt.setBackground(QColor(color)) + cursor.setBlockFormat(fmt) + + def _seg_bg(self, editor, block_no, lo, hi, color): + if not color or lo >= hi: + return + block = editor.document().findBlockByNumber(block_no) + cursor = QTextCursor(block) + cursor.setPosition(block.position() + lo) + cursor.setPosition(block.position() + hi, QTextCursor.KeepAnchor) + fmt = QTextCharFormat() + fmt.setBackground(QColor(color)) + cursor.setCharFormat(fmt) + + def _center(self, row): + block = self.old_edit.document().findBlockByNumber(row) + self.old_edit.setTextCursor(QTextCursor(block)) + self.old_edit.centerCursor() + + # --- change navigation (real/moved blocks; minor noise is skipped) --- + + def next_change(self): + if not self._stops: + return + cur = self.old_edit.textCursor().blockNumber() + self._center(next((s for s in self._stops if s > cur), self._stops[0])) + + def prev_change(self): + if not self._stops: + return + cur = self.old_edit.textCursor().blockNumber() + self._center(next((s for s in reversed(self._stops) if s < cur), self._stops[-1])) diff --git a/compare_tool/qtviewer/minimap.py b/compare_tool/qtviewer/minimap.py new file mode 100644 index 0000000..7c4e2dd --- /dev/null +++ b/compare_tool/qtviewer/minimap.py @@ -0,0 +1,87 @@ +"""Change minimap: the whole file compressed to a thin column so every change +is visible at a glance, with a viewport box and click/drag to jump. + +One minimap covers both panes: ``aligned_rows`` gives a single row sequence +shared by old and new, so row *i* is the same height position on both sides. +The map is driven off the old editor (its scrollbar mirrors the new one), and +jumping centres that editor -- the mirror carries the new pane along. +""" + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter, QTextCursor +from PySide6.QtWidgets import QWidget + +_WIDTH = 16 +_BG = '#1a1b1e' +# mark colour per change mode (ctx rows draw nothing) +_MARK = {'real': '#d9524f', 'minor': '#c8a030', 'moved': '#3f7fb0'} +_VIEW_FILL = QColor(255, 255, 255, 28) +_VIEW_BORDER = QColor(190, 190, 190, 130) + + +class Minimap(QWidget): + def __init__(self, editor): + super().__init__() + self._editor = editor + self._rows = [] + self.setFixedWidth(_WIDTH) + self.setCursor(Qt.PointingHandCursor) + # repaint the viewport box whenever the driven editor scrolls or grows + editor.verticalScrollBar().valueChanged.connect(self.update) + editor.blockCountChanged.connect(lambda _n: self.update()) + + def set_rows(self, rows): + self._rows = rows + self.update() + + # --- painting --- + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(_BG)) + n = len(self._rows) + if not n: + return + h = max(self.height(), 1) + w = self.width() + mark_h = max(2.0, h / n) + for i, r in enumerate(self._rows): + color = _MARK.get(r.mode) + if not color: + continue + y = i / n * h + painter.fillRect(1, int(y), w - 2, int(mark_h) + 1, QColor(color)) + self._paint_viewport(painter, n, h, w) + + def _paint_viewport(self, painter, n, h, w): + first = self._editor.firstVisibleBlock().blockNumber() + count = self._visible_rows() + y0 = first / n * h + y1 = min(first + count, n) / n * h + painter.fillRect(0, int(y0), w, int(y1 - y0), _VIEW_FILL) + painter.setPen(_VIEW_BORDER) + painter.drawRect(0, int(y0), w - 1, max(int(y1 - y0) - 1, 1)) + + def _visible_rows(self): + block = self._editor.firstVisibleBlock() + bh = self._editor.blockBoundingRect(block).height() or self._editor.fontMetrics().height() + return max(1, int(self._editor.viewport().height() / bh)) + + # --- interaction --- + + def mousePressEvent(self, event): + self._jump(event.position().y()) + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: + self._jump(event.position().y()) + + def _jump(self, y): + n = len(self._rows) + if not n: + return + row = int(y / max(self.height(), 1) * n) + row = max(0, min(n - 1, row)) + block = self._editor.document().findBlockByNumber(row) + self._editor.setTextCursor(QTextCursor(block)) + self._editor.centerCursor() diff --git a/compare_tool/qtviewer/tree.py b/compare_tool/qtviewer/tree.py new file mode 100644 index 0000000..27d18a5 --- /dev/null +++ b/compare_tool/qtviewer/tree.py @@ -0,0 +1,96 @@ +"""Folder-tree model for the viewer: pure logic, NO Qt import. + +Keeping this Qt-free means the nesting + folder-status aggregation can be unit +tested on a headless box without PySide6, and the Qt layer (app.py) only has +to walk the returned Node list and paint it. +""" + +from collections import namedtuple + +# status -> (tree marker, display label, hex colour). Mirrors the HTML +# report's verdict vocabulary (Modified / Unimportant / Added / Deleted / +# Identical) and its colours so the viewer and the report read the same. +STATUS = { + 'real-change': ('≠', 'Modified', '#ff7b7b'), # not-equal sign + 'ignorable-only': ('≈', 'Unimportant', '#e6c85c'), # almost-equal + 'added': ('+', 'Added', '#7bd88a'), + 'deleted': ('−', 'Deleted', '#c88ad8'), # minus sign + 'identical': ('=', 'Identical', '#8a8a8a'), + 'error': ('!', 'NOT compared', '#ff5c5c'), +} + +# folder verdict = most significant child verdict; an uncompared 'error' path +# outranks everything so a folder hiding one can never look clean +PRIO = {'error': 5, 'real-change': 4, 'ignorable-only': 3, 'added': 2, + 'deleted': 2, 'identical': 1} + +# a directory node's .rel is None; a file node carries its relative path +Node = namedtuple('Node', 'name is_dir status rel children') + + +def _agg_status(nodes): + best = 'identical' + for n in nodes: + if PRIO[n.status] > PRIO[best]: + best = n.status + return best + + +def build_nodes(results): + """Nested Node list from a scanner ``results`` dict (``{rel: {...}}``). + Directories come before files at each level and both are sorted by name, + matching the HTML report's folder tree. Directory status is the highest + priority among its descendants.""" + root = {} + for rel in results: + parts = rel.replace('\\', '/').split('/') + node = root + for d in parts[:-1]: + nxt = node.get(d) + if not isinstance(nxt, dict): + nxt = {} + node[d] = nxt + node = nxt + node[parts[-1]] = rel # leaf: relative path string + + def walk(node): + out = [] + dirs = sorted(k for k, v in node.items() if isinstance(v, dict)) + files = sorted(k for k, v in node.items() if not isinstance(v, dict)) + for d in dirs: + children = walk(node[d]) + out.append(Node(d, True, _agg_status(children), None, children)) + for f in files: + rel = node[f] + out.append(Node(f, False, results[rel]['status'], rel, ())) + return out + + return walk(root) + + +def filter_nodes(nodes, show_identical=True, show_unimportant=True, text=''): + """Prune a Node list for the tree view. Files with a hidden status + ('identical' / 'ignorable-only') or not matching the path substring drop + out; a directory survives only if it still has a surviving descendant, so + empty folders collapse away. Directory status/markers are left untouched so + a folder still shows the worst verdict living under it.""" + text = text.strip().lower() + + def keep_file(n): + if not show_identical and n.status == 'identical': + return False + if not show_unimportant and n.status == 'ignorable-only': + return False + if text and text not in (n.rel or '').lower(): + return False + return True + + out = [] + for n in nodes: + if n.is_dir: + kids = filter_nodes(n.children, show_identical, show_unimportant, text) + if kids: + out.append(n._replace(children=kids)) + elif keep_file(n): + out.append(n) + return out diff --git a/compare_tool/qtviewer/worker.py b/compare_tool/qtviewer/worker.py new file mode 100644 index 0000000..eac9b26 --- /dev/null +++ b/compare_tool/qtviewer/worker.py @@ -0,0 +1,34 @@ +"""Background scan thread. The scan walks the disk and diffs every pair, so +it must never run on the GUI thread. Results cross back to the UI through +signals only (Qt queues cross-thread signals), never by touching widgets. +""" + +from PySide6.QtCore import QThread, Signal + +from ..scanner import scan + + +class ScanWorker(QThread): + progressed = Signal(int, int, str) # done, total, current rel path + done = Signal(dict) # results + failed = Signal(str) # loud failure -> red banner + + def __init__(self, old, new, exclude=(), include=()): + super().__init__() + self.old = old + self.new = new + self.exclude = tuple(exclude) + self.include = tuple(include) + + def run(self): + try: + results = scan(self.old, self.new, progress=self._progress, + exclude=self.exclude, include=self.include) + self.done.emit(results) + except Exception as e: + # scan is internally fail-safe, but a crash here must still be + # loud -- never a silent empty tree that reads as "no changes" + self.failed.emit('{}: {}'.format(type(e).__name__, e)) + + def _progress(self, done, total, rel): + self.progressed.emit(done, total, rel) diff --git a/compare_tool/report.py b/compare_tool/report.py index 910b13d..cae01cb 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -8,6 +8,7 @@ from .diff_engine import ruleset_for from .scanner import (looks_binary, read_text, summarize, summarize_a2l, summarize_ifaces, summarize_rte, summarize_swcs) +from .view_model import char_span CONTEXT = 3 MAX_CONTENT = 400 # max lines shown for added/deleted file content @@ -236,23 +237,17 @@ def _char_diff(old_txt, new_txt): suffix stay plain, everything between the FIRST and LAST differing char is one contiguous highlighted span per side. A per-opcode diff would fragment into many tiny segments (equal chars like '_' or 'e' between - renamed identifiers), which is hard on the eyes.""" - pre = 0 - limit = min(len(old_txt), len(new_txt)) - while pre < limit and old_txt[pre] == new_txt[pre]: - pre += 1 - suf = 0 - while suf < limit - pre and old_txt[len(old_txt) - 1 - suf] == new_txt[len(new_txt) - 1 - suf]: - suf += 1 - - def mark(txt): - mid = txt[pre:len(txt) - suf] - if not mid: + renamed identifiers), which is hard on the eyes. Span offsets come from the + shared view model so the Qt viewer highlights the exact same characters.""" + (o_lo, o_hi), (n_lo, n_hi) = char_span(old_txt, new_txt) + + def mark(txt, lo, hi): + if lo >= hi: return _esc(txt) - return (_esc(txt[:pre]) + '' + _esc(mid) + - '' + _esc(txt[len(txt) - suf:])) + return (_esc(txt[:lo]) + '' + _esc(txt[lo:hi]) + + '' + _esc(txt[hi:])) - return mark(old_txt), mark(new_txt) + return mark(old_txt, o_lo, o_hi), mark(new_txt, n_lo, n_hi) _MODE_CLS = {'real': ('del', 'add'), 'minor': ('delm', 'addm'), 'moved': ('mvd', 'mva')} diff --git a/compare_tool/view_model.py b/compare_tool/view_model.py new file mode 100644 index 0000000..2fe27ba --- /dev/null +++ b/compare_tool/view_model.py @@ -0,0 +1,87 @@ +"""Renderer-agnostic diff view model shared by the HTML report and the Qt +side-by-side viewer. + +Two primitives, both free of any HTML/Qt specifics so either renderer can +consume them: + +* ``char_span`` -- the intra-line highlight as plain character offsets (one + contiguous changed span per side, common prefix/suffix excluded). The HTML + report wraps the span in ````; the Qt viewer applies a + ``QTextCharFormat`` over the same offsets. Keeping the offsets here means the + two renderers can never disagree on WHAT changed inside a line. + +* ``aligned_rows`` -- whole-file alignment of an old/new pair given its + classified hunks: every line emitted once, changed blocks padded on the + shorter side so old and new stay row-for-row aligned. This is the natural + Beyond-Compare two-pane model. (The HTML report keeps its own grouped + context-window rendering; it only shares ``char_span``.) +""" + +from collections import namedtuple + +# mode: how a row is painted. 'ctx' = equal line (context), 'real' = real +# change (red/green), 'minor' = ignorable noise (yellow), 'moved' = moved +# block (blue). kind = the underlying hunk kind ('equal' for ctx rows, +# otherwise 'real'/'moved'/'comment'/'uuid'/... straight from the hunk). +Row = namedtuple('Row', 'old_no old_txt new_no new_txt mode kind') + + +def char_span(old_txt, new_txt): + """Character offsets of the single changed span on each side of one line + pair. Returns ``((o_lo, o_hi), (n_lo, n_hi))``: text before ``lo`` and + from ``hi`` on is the common prefix/suffix and stays plain; ``txt[lo:hi]`` + is the changed middle (empty span ``lo == hi`` for a pure insert/delete on + that side). Mirrors the report's old first-to-last-differing-char rule: a + single contiguous span, never fragmented into per-opcode pieces.""" + pre = 0 + limit = min(len(old_txt), len(new_txt)) + while pre < limit and old_txt[pre] == new_txt[pre]: + pre += 1 + suf = 0 + while (suf < limit - pre + and old_txt[len(old_txt) - 1 - suf] == new_txt[len(new_txt) - 1 - suf]): + suf += 1 + return (pre, len(old_txt) - suf), (pre, len(new_txt) - suf) + + +def _mode_of(kind): + if kind == 'real': + return 'real' + if kind == 'moved': + return 'moved' + return 'minor' + + +def aligned_rows(old_lines, new_lines, hunks): + """Whole-file row alignment for a compared pair. + + ``old_lines`` / ``new_lines`` are the raw line lists (``text.split('\\n')``); + ``hunks`` is the classified hunk list from ``diff_engine.compare_pair``. + Returns a list of :class:`Row`. Equal regions between hunks become 'ctx' + rows advancing both sides together; each hunk's changed block is padded on + the shorter side (the padded cell has ``None`` line number and text) so the + two panes line up row-for-row. + + Intended for real-change / ignorable-only pairs. Added/deleted files (one + side only, no hunks) are rendered one-sided by the caller, not here.""" + rows = [] + oi = nj = 0 + for h in hunks: + i1, i2 = h['old_range'] + j1, j2 = h['new_range'] + # equal region [oi, i1) on old aligns 1-1 with [nj, j1) on new + for k in range(i1 - oi): + rows.append(Row(oi + k + 1, old_lines[oi + k], + nj + k + 1, new_lines[nj + k], 'ctx', 'equal')) + mode = _mode_of(h['kind']) + span = max(i2 - i1, j2 - j1) + for k in range(span): + o_no, o_txt = (i1 + k + 1, old_lines[i1 + k]) if i1 + k < i2 else (None, None) + n_no, n_txt = (j1 + k + 1, new_lines[j1 + k]) if j1 + k < j2 else (None, None) + rows.append(Row(o_no, o_txt, n_no, n_txt, mode, h['kind'])) + oi, nj = i2, j2 + # trailing equal region (old[oi:] aligns 1-1 with new[nj:]) + for k in range(len(old_lines) - oi): + rows.append(Row(oi + k + 1, old_lines[oi + k], + nj + k + 1, new_lines[nj + k], 'ctx', 'equal')) + return rows diff --git a/packaging/build-viewer.ps1 b/packaging/build-viewer.ps1 new file mode 100644 index 0000000..9c382e6 --- /dev/null +++ b/packaging/build-viewer.ps1 @@ -0,0 +1,20 @@ +# Build the standalone CodeGen Compare viewer into a single .exe. +# Run from the repo root on Windows (PyInstaller does not cross-compile -- +# build on the OS you want the binary for): +# powershell -ExecutionPolicy Bypass -File packaging\build-viewer.ps1 +# Result: dist\CodeGenCompareViewer.exe + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +python -m pip install --upgrade "pyinstaller" "PySide6>=6.5" +python -m PyInstaller --noconfirm --clean packaging\compare-viewer.spec + +$exe = Join-Path $root 'dist\CodeGenCompareViewer.exe' +if (Test-Path $exe) { + $mb = [math]::Round((Get-Item $exe).Length / 1MB, 1) + Write-Host "Built $exe ($mb MB)" +} else { + throw "Build finished but $exe is missing" +} diff --git a/packaging/compare-viewer.spec b/packaging/compare-viewer.spec new file mode 100644 index 0000000..350ece4 --- /dev/null +++ b/packaging/compare-viewer.spec @@ -0,0 +1,63 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec for the CodeGen Compare side-by-side viewer. +# +# Build (on the SAME OS you want the binary for -- PyInstaller does not +# cross-compile): +# pyinstaller --noconfirm --clean packaging/compare-viewer.spec +# Output: +# dist/CodeGenCompareViewer(.exe) -- one self-contained file, no Python +# needed on the target machine. + +import os + +# SPECPATH is the folder holding this spec (…/packaging); the repo root one +# level up must be on the path so `import compare_tool` resolves. +_here = SPECPATH +_root = os.path.dirname(_here) + +# The viewer only touches QtCore / QtGui / QtWidgets. PySide6's addons bundle +# QtQuick, WebEngine, 3D, Charts, multimedia, … none of which we use -- exclude +# them so the binary stays as small as PySide6 allows. +_EXCLUDES = [ + 'PySide6.QtQml', 'PySide6.QtQuick', 'PySide6.QtQuick3D', + 'PySide6.QtQuickWidgets', 'PySide6.QtQuickControls2', + 'PySide6.QtWebEngineCore', 'PySide6.QtWebEngineWidgets', + 'PySide6.QtWebEngineQuick', 'PySide6.QtWebChannel', 'PySide6.QtWebSockets', + 'PySide6.QtMultimedia', 'PySide6.QtMultimediaWidgets', + 'PySide6.Qt3DCore', 'PySide6.Qt3DRender', 'PySide6.Qt3DExtras', + 'PySide6.Qt3DInput', 'PySide6.Qt3DAnimation', 'PySide6.Qt3DLogic', + 'PySide6.QtCharts', 'PySide6.QtDataVisualization', 'PySide6.QtGraphs', + 'PySide6.QtBluetooth', 'PySide6.QtPositioning', 'PySide6.QtNfc', + 'PySide6.QtSql', 'PySide6.QtTest', 'PySide6.QtSensors', + 'PySide6.QtSerialPort', 'PySide6.QtSerialBus', 'PySide6.QtPdf', + 'PySide6.QtPdfWidgets', 'PySide6.QtDesigner', 'PySide6.QtHelp', + 'PySide6.QtUiTools', 'PySide6.QtSvgWidgets', 'PySide6.QtNetwork', + 'tkinter', +] + +a = Analysis( + [os.path.join(_here, 'viewer_entry.py')], + pathex=[_root], + binaries=[], + datas=[], + hiddenimports=[], + excludes=_EXCLUDES, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='CodeGenCompareViewer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + runtime_tmpdir=None, + console=False, # GUI app: no console window +) diff --git a/packaging/viewer_entry.py b/packaging/viewer_entry.py new file mode 100644 index 0000000..e320ee8 --- /dev/null +++ b/packaging/viewer_entry.py @@ -0,0 +1,20 @@ +"""Frozen-app entry point for the side-by-side viewer (PyInstaller). + +Double-clicking the built binary opens the viewer; two optional CLI args +prefill the OLD / NEW folders. Kept separate from compare_tool.main so the +frozen build is GUI-only (no console) and does not drag the CLI along. +""" + +import sys + +from compare_tool.qtviewer import run_viewer + + +def main(): + old = sys.argv[1] if len(sys.argv) > 1 else None + new = sys.argv[2] if len(sys.argv) > 2 else None + return run_viewer(old, new) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 384b6a8..38c27f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,11 @@ classifiers = [ ] dynamic = ["version"] +# core tool is stdlib-only; the side-by-side viewer (--qt) needs PySide6. +# install with: pip install codegen-compare-tool[viewer] +[project.optional-dependencies] +viewer = ["PySide6>=6.5"] + [project.urls] Homepage = "https://github.com/longvo92/codegen-compare-tool" Issues = "https://github.com/longvo92/codegen-compare-tool/issues" @@ -31,7 +36,7 @@ Issues = "https://github.com/longvo92/codegen-compare-tool/issues" compare-tool = "compare_tool.main:main" [tool.setuptools] -packages = ["compare_tool"] +packages = ["compare_tool", "compare_tool.qtviewer"] [tool.setuptools.dynamic] version = { attr = "compare_tool.__version__" } diff --git a/tests/test_qtviewer.py b/tests/test_qtviewer.py new file mode 100644 index 0000000..e9e3acc --- /dev/null +++ b/tests/test_qtviewer.py @@ -0,0 +1,100 @@ +"""Viewer folder-tree model tests. Only the Qt-free tree logic is exercised +here so the suite runs on a headless box without PySide6 installed.""" + +import unittest + +from compare_tool.qtviewer.tree import (PRIO, STATUS, build_nodes, + filter_nodes) + + +def _res(mapping): + return {rel: {'status': st} for rel, st in mapping.items()} + + +class TestBuildNodes(unittest.TestCase): + def test_dirs_before_files_both_sorted(self): + nodes = build_nodes(_res({ + 'z.c': 'identical', 'a.c': 'identical', 'src/b.c': 'identical'})) + # directory 'src' first, then files a.c, z.c + self.assertEqual([n.name for n in nodes], ['src', 'a.c', 'z.c']) + self.assertTrue(nodes[0].is_dir) + self.assertFalse(nodes[1].is_dir) + + def test_file_node_carries_rel_and_status(self): + nodes = build_nodes(_res({'src/ctrl.c': 'real-change'})) + src = nodes[0] + self.assertEqual(src.name, 'src') + leaf = src.children[0] + self.assertEqual(leaf.rel, 'src/ctrl.c') + self.assertEqual(leaf.status, 'real-change') + self.assertIsNone(src.rel) + + def test_folder_status_is_most_significant_child(self): + nodes = build_nodes(_res({ + 'm/a.c': 'identical', 'm/b.c': 'real-change', 'm/c.c': 'added'})) + self.assertEqual(nodes[0].status, 'real-change') # real-change outranks + + def test_error_outranks_everything_in_folder(self): + nodes = build_nodes(_res({'m/a.c': 'real-change', 'm/bad.c': 'error'})) + self.assertEqual(nodes[0].status, 'error') + + def test_nested_dirs_aggregate_upward(self): + nodes = build_nodes(_res({'a/b/c.c': 'deleted'})) + self.assertEqual(nodes[0].name, 'a') + self.assertEqual(nodes[0].status, 'deleted') + self.assertEqual(nodes[0].children[0].name, 'b') + self.assertEqual(nodes[0].children[0].status, 'deleted') + + def test_backslash_paths_split_like_posix(self): + nodes = build_nodes({'src\\ctrl.c': {'status': 'identical'}}) + self.assertEqual(nodes[0].name, 'src') + self.assertEqual(nodes[0].children[0].name, 'ctrl.c') + + def test_every_status_has_metadata(self): + for st in PRIO: + self.assertIn(st, STATUS) + marker, label, color = STATUS[st] + self.assertTrue(marker and label and color.startswith('#')) + + +class TestFilterNodes(unittest.TestCase): + def _nodes(self, mapping): + return build_nodes(_res(mapping)) + + def _rels(self, nodes): + out = [] + for n in nodes: + if n.is_dir: + out.extend(self._rels(n.children)) + else: + out.append(n.rel) + return out + + def test_hides_identical_when_off(self): + nodes = self._nodes({'a.c': 'identical', 'b.c': 'real-change'}) + kept = filter_nodes(nodes, show_identical=False) + self.assertEqual(self._rels(kept), ['b.c']) + + def test_hides_unimportant_when_off(self): + nodes = self._nodes({'a.c': 'ignorable-only', 'b.c': 'added'}) + kept = filter_nodes(nodes, show_unimportant=False) + self.assertEqual(self._rels(kept), ['b.c']) + + def test_empty_folder_collapses_away(self): + nodes = self._nodes({'noise/a.c': 'identical', 'real/b.c': 'real-change'}) + kept = filter_nodes(nodes, show_identical=False) + self.assertEqual([n.name for n in kept], ['real']) + + def test_text_filter_matches_path_substring(self): + nodes = self._nodes({'src/ctrl.c': 'real-change', 'src/plant.c': 'real-change'}) + kept = filter_nodes(nodes, text='ctrl') + self.assertEqual(self._rels(kept), ['src/ctrl.c']) + + def test_error_never_hidden_by_status_filters(self): + nodes = self._nodes({'bad.c': 'error'}) + kept = filter_nodes(nodes, show_identical=False, show_unimportant=False) + self.assertEqual(self._rels(kept), ['bad.c']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_view_model.py b/tests/test_view_model.py new file mode 100644 index 0000000..b9cd6b6 --- /dev/null +++ b/tests/test_view_model.py @@ -0,0 +1,120 @@ +"""Shared view-model tests: char_span offsets and whole-file aligned_rows. + +These back the Qt two-pane viewer (Phase 2) and guard that the extracted +char-span primitive stays byte-identical to the report's old highlighter.""" + +import unittest + +from compare_tool.diff_engine import compare_pair +from compare_tool.report import _char_diff +from compare_tool.view_model import Row, aligned_rows, char_span + + +class TestCharSpan(unittest.TestCase): + """char_span returns the offsets the report used to compute inline; it + must agree with _char_diff (which now consumes it) on every case the + report test suite pins.""" + + def _apply(self, txt, span): + lo, hi = span + if lo >= hi: + return txt + return txt[:lo] + '[' + txt[lo:hi] + ']' + txt[hi:] + + def test_equal_chars_between_diffs_swallowed_into_one_span(self): + o, n = char_span('rtb_Sum1_abc', 'rtb_Sum2_xbc') + self.assertEqual(self._apply('rtb_Sum1_abc', o), 'rtb_Sum[1_a]bc') + self.assertEqual(self._apply('rtb_Sum2_xbc', n), 'rtb_Sum[2_x]bc') + + def test_pure_insertion_empty_span_on_old_side(self): + (o_lo, o_hi), n = char_span('ab', 'axxb') + self.assertEqual(o_lo, o_hi) # nothing changed on old + self.assertEqual(self._apply('axxb', n), 'a[xx]b') + + def test_prefix_change(self): + o, _n = char_span('Xmid', 'Ymid') + self.assertEqual(self._apply('Xmid', o), '[X]mid') + + def test_suffix_change(self): + o, _n = char_span('midX', 'midY') + self.assertEqual(self._apply('midX', o), 'mid[X]') + + def test_agrees_with_report_char_diff(self): + # the report renderer must produce spans at exactly these offsets + cases = [('rtb_Sum1_abc', 'rtb_Sum2_xbc'), ('ab', 'axxb'), + ('aXbYcZd', 'aQbWcRd'), ('Xmid', 'Ymid'), ('midX', 'midY')] + for old, new in cases: + (o_lo, o_hi), (n_lo, n_hi) = char_span(old, new) + exp_old = (old if o_lo >= o_hi else + old[:o_lo] + '' + old[o_lo:o_hi] + + '' + old[o_hi:]) + html_old, _ = _char_diff(old, new) + self.assertEqual(html_old, exp_old, (old, new)) + + +class TestAlignedRows(unittest.TestCase): + def _rows(self, old, new, path='f.c'): + r = compare_pair(old, new, path) + return r, aligned_rows(old.split('\n'), new.split('\n'), r['hunks']) + + def test_real_change_rows_carry_both_sides(self): + r, rows = self._rows("int lim = 5;\nint keep = 0;\n", + "int lim = 10;\nint keep = 0;\n") + real = [row for row in rows if row.mode == 'real'] + self.assertTrue(real) + row = real[0] + self.assertEqual(row.old_txt, 'int lim = 5;') + self.assertEqual(row.new_txt, 'int lim = 10;') + # inline highlight offsets resolve against the same row text + (o_lo, o_hi), _ = char_span(row.old_txt, row.new_txt) + self.assertEqual(row.old_txt[o_lo:o_hi], '5') + + def test_context_rows_advance_both_sides_in_lockstep(self): + _r, rows = self._rows("a\nCHANGED_OLD\nb\n", "a\nCHANGED_NEW\nb\n") + for row in rows: + if row.mode == 'ctx': + self.assertEqual(row.old_txt, row.new_txt) + self.assertIsNotNone(row.old_no) + self.assertIsNotNone(row.new_no) + + def test_insertion_pads_old_side_with_none(self): + # new file gains a line -> the extra new row has no old counterpart + _r, rows = self._rows("x = 1;\ny = 2;\n", "x = 1;\nz = 9;\ny = 2;\n", 'f.c') + padded = [row for row in rows if row.old_txt is None and row.new_txt is not None] + self.assertTrue(padded) + self.assertTrue(all(row.old_no is None for row in padded)) + + def test_every_old_and_new_line_appears_exactly_once(self): + old = "l1\nl2\nl3\nl4\nl5\n" + new = "l1\nX2\nl3\nl4\nX5\n" + _r, rows = self._rows(old, new) + got_old = [row.old_txt for row in rows if row.old_no is not None] + got_new = [row.new_txt for row in rows if row.new_no is not None] + self.assertEqual(got_old, old.split('\n')) + self.assertEqual(got_new, new.split('\n')) + + def test_line_numbers_are_monotonic_and_gapless(self): + old = "a\nb\nc\nd\n" + new = "a\nB\nc\nD\n" + _r, rows = self._rows(old, new) + old_nos = [row.old_no for row in rows if row.old_no is not None] + new_nos = [row.new_no for row in rows if row.new_no is not None] + self.assertEqual(old_nos, list(range(1, len(old.split('\n')) + 1))) + self.assertEqual(new_nos, list(range(1, len(new.split('\n')) + 1))) + + def test_minor_hunk_rows_tagged_minor(self): + _r, rows = self._rows("/* gen Mon */\nint x = 1;\n", + "/* gen Tue */\nint x = 1;\n") + self.assertTrue(any(row.mode == 'minor' for row in rows)) + + def test_moved_block_rows_tagged_moved(self): + old = ("void Alpha(void)\n{\n a = 1;\n b = 2;\n}\n" + "void Beta(void)\n{\n c = 3;\n}\n") + new = ("void Beta(void)\n{\n c = 3;\n}\n" + "void Alpha(void)\n{\n a = 1;\n b = 2;\n}\n") + _r, rows = self._rows(old, new) + self.assertTrue(any(row.mode == 'moved' for row in rows)) + + +if __name__ == '__main__': + unittest.main()