diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2efc50f..4ce5fc2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,3 +19,21 @@ jobs:
python-version: ${{ matrix.python-version }}
- run: python -m pip install -e ".[dev]"
- run: python -m pytest -q
+
+ tests-linux:
+ runs-on: ubuntu-22.04
+ env:
+ QT_QPA_PLATFORM: offscreen
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - name: Install Qt runtime libraries
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-cursor0 \
+ libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \
+ libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1
+ - run: python -m pip install -e ".[dev]"
+ - run: python -m pytest -q
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 627a36c..53ec85f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,22 +1,47 @@
name: Release
+# workflow_dispatch builds and uploads the artifacts; a release is created only for v* tags.
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
- windows-exe:
- runs-on: windows-latest
+ build:
+ name: build (${{ matrix.artifact }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ artifact: ab1kit-windows
+ file: dist/ab1kit.exe
+ - os: ubuntu-22.04
+ artifact: ab1kit-linux-x86_64
+ file: ab1kit-linux-x86_64.tar.gz
permissions:
- contents: write
+ contents: read
+ env:
+ QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
+ - name: Install Qt runtime libraries (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-cursor0 \
+ libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \
+ libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1
- run: python -m pip install -e ".[dev]" "pyinstaller==6.22.3"
+ - name: Run tests
+ run: python -m pytest -q
+
- name: Build exe (errors surfaced as annotations)
+ if: runner.os == 'Windows'
shell: pwsh
run: |
python -m PyInstaller ab1kit.spec --noconfirm --workpath build-pyinstaller 2>&1 |
@@ -26,13 +51,80 @@ jobs:
Write-Host "::error::PyInstaller failed, log tail: $tail"
exit 1
}
+ - name: Build binary (errors surfaced as annotations)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -o pipefail
+ if ! python -m PyInstaller ab1kit.spec --noconfirm --workpath build-pyinstaller 2>&1 | tee build_log.txt; then
+ tail_log=$(tail -n 30 build_log.txt | tr '\n' '|')
+ echo "::error::PyInstaller failed, log tail: ${tail_log}"
+ exit 1
+ fi
+
+ - name: Smoke-test frozen exe (Windows)
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force smoke/reads | Out-Null
+ Copy-Item tests/data/real/Achl_ACHLO006-09_*.ab1 smoke/reads/
+ # The exe is windowed: Start-Process -Wait -PassThru is needed to get the exit code.
+ $p = Start-Process -FilePath dist/ab1kit.exe -Wait -PassThru -ArgumentList @(
+ "verify-clones", "--construct", "tests/data/refs/coi_HM417896.gb",
+ "smoke/reads", "-o", "smoke/report.xlsx")
+ if ($p.ExitCode -ne 0) {
+ Write-Host "::error::smoke test: ab1kit.exe verify-clones exited with $($p.ExitCode)"
+ exit 1
+ }
+ if (-not (Test-Path smoke/report.xlsx)) {
+ Write-Host "::error::smoke test: smoke/report.xlsx was not created"
+ exit 1
+ }
+ - name: Smoke-test frozen binary (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ mkdir -p smoke/reads
+ cp tests/data/real/Achl_ACHLO006-09_*.ab1 smoke/reads/
+ rc=0
+ dist/ab1kit verify-clones --construct tests/data/refs/coi_HM417896.gb \
+ smoke/reads -o smoke/report.xlsx || rc=$?
+ if [ "$rc" -ne 0 ]; then
+ echo "::error::smoke test: dist/ab1kit verify-clones exited with $rc"
+ exit 1
+ fi
+ if [ ! -f smoke/report.xlsx ]; then
+ echo "::error::smoke test: smoke/report.xlsx was not created"
+ exit 1
+ fi
+
+ - name: Package tarball (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ cp README.md LICENSE dist/
+ tar -czf ab1kit-linux-x86_64.tar.gz -C dist ab1kit README.md LICENSE
+
- uses: actions/upload-artifact@v4
with:
- name: ab1kit-windows
- path: dist/ab1kit.exe
+ name: ${{ matrix.artifact }}
+ path: ${{ matrix.file }}
+ if-no-files-found: error
+
+ release:
+ needs: build
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: artifacts
- name: Create GitHub release
- if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
- files: dist/ab1kit.exe
+ files: |
+ artifacts/ab1kit-windows/ab1kit.exe
+ artifacts/ab1kit-linux-x86_64/ab1kit-linux-x86_64.tar.gz
generate_release_notes: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f201bd..9c68348 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,44 @@ All notable changes to ab1kit are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is
[SemVer](https://semver.org/).
+## [1.1.0] — 2026-10-30
+
+Clone verification: check a plate of clones against a construct, in the GUI or
+from the command line.
+
+### Added
+- Reference loading from FASTA, GenBank and SnapGene files, with circular
+ constructs and reads spanning the origin
+- Normalised secondary-peak analysis at any read position
+- Grouping of reads into clones by file name (default rules or a custom regex),
+ per-clone coverage by strand, merged differences and verdicts
+ (`ok` / `errors` / `incomplete` / `mixed` / `no_data`)
+- Amino-acid consequences of differences inside CDS features
+- Clone report as CSV and Excel (sheets `Clones` and `Differences`; unusable
+ reads listed per clone in `read_issues` and on the CLI's stderr);
+ `.ab1proj` project files for verification sessions
+- Clone-verification workspace: summary bar, clone matrix, difference detail pane with stacked
+ chromatograms, grouping editor, drag-and-drop of reads and projects
+- Background tasks with progress and cancel
+- Command-line subcommands `verify-clones`, `qc` and `export` (no Qt import)
+- Real-data test corpus (sangeranalyseR reads, COI and pUC19 references)
+- Linux release build (`ab1kit-linux-x86_64.tar.gz`) alongside the Windows exe, built and smoke-tested by GitHub Actions
+- Application icon (window and Windows exe)
+
+### Changed
+- Refreshed UI: QSS theme, icons, one status palette everywhere
+- Quality colour thresholds are now <20 / 20-29 / >=30 everywhere
+- Main window split into the `ui.read_view` package (no behaviour change)
+- Alignment counts distinct placements; repeated hits are flagged as ambiguous
+
+### Fixed
+- Reverse-strand reads in expected-SNP verification and the reference overlay
+ were indexed in the wrong orientation (now correct)
+- Reverse-complementing a read misplaced peak positions; editing sessions saved
+ after a reverse-complement with 1.0.0 or earlier hold wrong peak positions —
+ re-open the original file and reverse-complement again
+- Untranslated verification notes; mean quality for reads without quality data
+
## [1.0.0] — 2026-10-24
First feature-complete release: the full cycle «open a chromatogram → inspect →
diff --git a/README.md b/README.md
index aa04b32..cd8be3a 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,11 @@
-# ab1kit
+#
ab1kit
Open-source desktop tool for viewing, editing and analyzing Sanger sequencing
chromatograms (`.ab1` files) — a free, offline alternative to FinchTV / Chromas
for everyday molecular biology lab work. English/Russian interface.

+
## Features
@@ -48,6 +49,73 @@ for everyday molecular biology lab work. English/Russian interface.


+## Clone verification
+
+Check a set of plasmid or amplicon clones against the expected construct from
+their Sanger reads: open the clone-verification workspace, load the reference (FASTA,
+GenBank or SnapGene), drop the `.ab1` reads of the whole plate, optionally set
+the region of interest, and read the verdict per clone off the colour matrix.
+
+
+
+**Scenario.** Load the construct, add the reads (files or a folder), check the
+automatic grouping into clones, choose the region; every clone gets its verdict right away. Click a
+difference in the matrix to see the stacked chromatograms of every read at that
+position. Export the result as Excel/CSV, or save the whole session as an
+`.ab1proj` file. FASTA constructs are treated as circular (reads may span the
+origin); GenBank/SnapGene files take the topology from the file; the *Circular*
+checkbox (`--linear` on the command line) overrides the topology.
+
+**File names.** Reads are grouped by name as `_`: the primer is
+the last known primer token (`F`, `R`, `T7`, `SP6`, `M13F`, `pUC-F`, ...); if
+the name has none, the last token is taken as the primer. The clone is what
+precedes the primer, minus a single-digit order token and a leading plate well
+(`A05`), the well being stripped only when another clone token follows it
+(`B2_F` is clone `B2`). `A05_clone3_2_R.ab1` is clone `clone3`, primer `R`;
+`Achl_ACHLO006-09_1_F.ab1` is clone `Achl_ACHLO006-09`. Files whose name yields
+no clone stay unassigned and are skipped. For other conventions give a regular
+expression with named groups `clone` and `primer` that matches the whole file
+name without the extension, e.g. `(?P\d+)-(?P[A-Za-z0-9]+)`.
+
+**Verdicts.** Checked in this order, the first match wins:
+
+| Verdict | Meaning |
+| --- | --- |
+| `no_data` (grey) | no read of the clone aligned to the construct |
+| `mixed` (purple) | at least 3 positions (default) with a double peak: probably a mixed colony |
+| `errors` (red) | at least one difference in the region that is confirmed or disputed |
+| `incomplete` (yellow) | no such difference, but part of the region is not covered by any read |
+| `ok` (green) | the region is fully covered and shows no reliable difference |
+
+A difference is *confirmed* (only supporting reads, Q >= min Q), *disputed*
+(another read shows the reference there) or *low quality* (seen only below
+min Q). Both confirmed and disputed differences give `errors`; only low-quality
+ones do not change the verdict. Defaults: min Q 20, double-peak ratio 0.40.
+
+**Command line** (no GUI, no Qt needed):
+
+```
+ab1kit verify-clones --construct pET28-gene.gb --region 120-1850 reads/ -o report.xlsx
+ab1kit qc reads/ -o qc.csv
+ab1kit export reads/ --trim --fasta -o trimmed/
+```
+
+`verify-clones` prints a table of clones and exits with 0 if at least one clone
+is `ok`, 1 if none is, 2 on invalid input; `-o report.csv` writes `report.csv`
+and `report_differences.csv`. The `differences` count (table and report) is the
+in-region differences that are not low quality, i.e. those behind `errors`.
+Reads that could not be used (unreadable, not aligned, ambiguous placement) are
+listed on stderr as `ab1kit: : ` and in the `read_issues` column. Options: `--region A-B` (1-based, inclusive),
+`--linear`, `--pattern REGEX`, `--min-q N`, `--het-ratio X`.
+
+The exit-code contract applies to the `ab1kit` command installed with pip. The
+Windows release `ab1kit.exe` is a windowed program: the shell returns to the
+prompt immediately, so exit codes and output ordering are unreliable and output
+may appear after the prompt. Wait for it explicitly: `start /wait ab1kit.exe
+verify-clones ...` (cmd), or `Start-Process -Wait -NoNewWindow` /
+`(Start-Process ab1kit.exe -ArgumentList ... -Wait -PassThru).ExitCode`
+(PowerShell).
+
## Getting started
Requires Python 3.11+:
@@ -56,12 +124,20 @@ Requires Python 3.11+:
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # Windows; on POSIX: .venv/bin/pip
.venv/Scripts/python -m pytest # run the test suite
+.venv/Scripts/python -m pytest -m perf # timing tests (skipped by default)
.venv/Scripts/ab1kit # launch the GUI
```
-`ab1kit` also accepts a file or folder argument. On Windows, a prebuilt
-executable is available on the [Releases](https://github.com/adrobi/ab1kit/releases)
-page.
+`ab1kit` also accepts a file or folder argument. Prebuilt binaries are on the
+[Releases](https://github.com/adrobi/ab1kit/releases) page:
+
+- **Windows:** `ab1kit.exe` (single file, no installation).
+- **Linux (x86_64):** `ab1kit-linux-x86_64.tar.gz`. Unpack with
+ `tar -xzf ab1kit-linux-x86_64.tar.gz` and run `./ab1kit`. Built on Ubuntu
+ 22.04, so it needs glibc 2.35 or newer. The GUI needs the usual Qt runtime
+ libraries; on Debian/Ubuntu:
+ `sudo apt install libegl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1 libfontconfig1 libdbus-1-3 libglib2.0-0 libxcb-xkb1 libxcb-xinput0 libxcb-shm0`.
+ The Linux binary prints CLI output to the terminal normally.
## Keyboard shortcuts
@@ -96,5 +172,6 @@ Example chromatograms in `examples/` come from the
## Status
-v1.0.0 — viewer, editing, trimming, batch mode, alignment, SNP calling and
-expected-SNP verification are all implemented. See [CHANGELOG.md](CHANGELOG.md).
+v1.1.0 — adds clone verification, a command-line interface, a refreshed UI
+and more accurate peak analysis on top of the 1.0 viewer, editing, batch mode,
+alignment, SNP calling and expected-SNP verification. See [CHANGELOG.md](CHANGELOG.md).
diff --git a/ab1kit.spec b/ab1kit.spec
index b21c6f7..06ffbeb 100644
--- a/ab1kit.spec
+++ b/ab1kit.spec
@@ -1,15 +1,21 @@
# -*- mode: python ; coding: utf-8 -*-
-"""PyInstaller spec for the ab1kit one-file Windows executable.
+"""PyInstaller spec for the ab1kit one-file executable (Windows exe, Linux ELF).
Build: pyinstaller ab1kit.spec --noconfirm
"""
+import sys
+
a = Analysis(
["chrom_cli.py"],
pathex=[],
binaries=[],
- datas=[],
- hiddenimports=["chrom_core", "ui", "i18n", "translations_en"],
+ datas=[
+ ("ui/style.qss", "ui"),
+ ("ui/style_dark.qss", "ui"),
+ ("ui/icons", "ui/icons"),
+ ],
+ hiddenimports=["chrom_core", "ui", "ui.read_view", "ui.construct", "ui.status", "ui.tables", "i18n", "translations_en"],
hookspath=[],
excludes=["tkinter", "matplotlib", "numpy.tests"],
noarchive=False,
@@ -26,5 +32,8 @@ exe = EXE(
debug=False,
strip=False,
upx=False,
+ # console=False only matters on Windows/macOS; a Linux ELF keeps stdout.
console=False,
+ # .ico is a Windows/macOS-only option; PyInstaller does not use it on Linux.
+ icon="ui/icons/app/ab1kit.ico" if sys.platform == "win32" else None,
)
diff --git a/chrom_cli.py b/chrom_cli.py
index 66f4fb1..56760d7 100644
--- a/chrom_cli.py
+++ b/chrom_cli.py
@@ -1,14 +1,220 @@
-"""ab1kit entry point: the ``ab1kit`` command launches the GUI."""
+"""ab1kit entry point: the GUI by default, plus ``verify-clones`` / ``qc`` / ``export``.
+
+The subcommands are a thin shell over ``chrom_core`` and never import Qt.
+"""
from __future__ import annotations
+import argparse
import sys
+from pathlib import Path
+
+COMMANDS = {"verify-clones", "qc", "export"}
+
+
+class CliError(Exception):
+ """Bad input: reported on stderr, exit code 2."""
+
+
+def _console_encoding(code_page: int) -> str:
+ """Python codec for a console code page; utf-8 when unknown (0 = query failed)."""
+ import codecs
+
+ if code_page:
+ try:
+ codecs.lookup(f"cp{code_page}")
+ return f"cp{code_page}"
+ except LookupError:
+ pass
+ return "utf-8"
+
+
+def _attach_console() -> None:
+ """Frozen windowed build: reuse the parent console, writing in its own code page."""
+ opened = []
+ try:
+ import ctypes
+
+ kernel32 = ctypes.windll.kernel32
+ if not kernel32.AttachConsole(-1):
+ return
+ encoding = _console_encoding(kernel32.GetConsoleOutputCP()) # never change it
+ wanted = [name for name in ("stdout", "stderr") if getattr(sys, name) is None]
+ for name in wanted:
+ opened.append((name, open("CONOUT$", "w", encoding=encoding, errors="replace")))
+ except (AttributeError, OSError, LookupError):
+ for _name, stream in opened: # all or nothing: keep both streams consistent
+ try:
+ stream.close()
+ except (AttributeError, OSError):
+ pass
+ return
+ for name, stream in opened:
+ setattr(sys, name, stream)
+
+
+def _prepare_streams() -> None:
+ """Print Cyrillic safely on any console."""
+ attached = sys.stdout is None or sys.stderr is None
+ if attached:
+ _attach_console()
+ for stream in (sys.stdout, sys.stderr):
+ # an attached console stream keeps the console's own code page
+ if stream is not None and hasattr(stream, "reconfigure") and not (
+ attached and getattr(stream, "name", "") == "CONOUT$"):
+ stream.reconfigure(encoding="utf-8", errors="replace")
+
+
+def _parse_region(text: str) -> tuple[int, int]:
+ try:
+ a, b = (int(x) for x in text.split("-"))
+ except ValueError:
+ raise CliError(f"invalid --region {text!r}: expected A-B (1-based, inclusive)") from None
+ if a < 1 or b < 1:
+ raise CliError(f"invalid --region {text!r}: positions start at 1")
+ return (a - 1, b)
+
+
+def _reads_in(folder: str) -> list[Path]:
+ path = Path(folder)
+ if not path.is_dir():
+ raise CliError(f"not a directory: {folder}")
+ return sorted(p for p in path.iterdir() if p.suffix.lower() == ".ab1")
+
+
+def _verify_clones(args: argparse.Namespace) -> int:
+ from chrom_core.clone_report import (
+ clone_report_xlsx, clones_csv, differences_csv, read_issue)
+ from chrom_core.clones import VerifyParams, check_region, group_reads, verify_all
+ from chrom_core.reference import load_construct
+
+ try:
+ construct = load_construct(args.construct)
+ except (ValueError, OSError) as exc:
+ raise CliError(f"cannot load construct: {exc}") from exc
+ if args.linear:
+ construct = construct.with_circular(False)
+ out = Path(args.output)
+ if out.suffix.lower() not in (".xlsx", ".csv"):
+ raise CliError("report file must end in .xlsx or .csv")
+ region = _parse_region(args.region) if args.region else None
+ try:
+ check_region(region, len(construct.seq), construct.circular)
+ paths = _reads_in(args.reads_dir)
+ if not paths:
+ raise CliError(f"no .ab1 files in {args.reads_dir}")
+ grouping = group_reads(paths, pattern=args.pattern)
+ except ValueError as exc:
+ raise CliError(str(exc)) from exc
+ for path in grouping.unassigned:
+ print(f"ab1kit: skipped (clone name not recognised): {path.name}", file=sys.stderr)
+ if not grouping.reads:
+ raise CliError("no reads could be assigned to a clone")
+ params = VerifyParams(min_q=args.min_q, het_ratio=args.het_ratio)
+ reports = verify_all(construct, region, grouping, params)
+ for report in reports:
+ for read in report.reads:
+ if read_issue(read):
+ print(f"ab1kit: {read.ref.path.name}: {read_issue(read)}", file=sys.stderr)
+
+ if out.suffix.lower() == ".xlsx":
+ clone_report_xlsx(out, reports, construct.name)
+ else:
+ out.write_text(clones_csv(reports), encoding="utf-8")
+ out.with_name(f"{out.stem}_differences.csv").write_text(
+ differences_csv(reports), encoding="utf-8")
+
+ width = max(len(r.clone) for r in reports)
+ for r in reports:
+ print(f"{r.clone:<{width}} {r.verdict:<10} coverage {r.region_coverage_pct:5.1f}% "
+ f"differences {len(r.error_differences)}")
+ return 0 if any(r.verdict == "ok" for r in reports) else 1
+
+
+def _qc(args: argparse.Namespace) -> int:
+ from chrom_core import batch
+
+ _reads_in(args.reads_dir) # validates the folder
+ text = batch.summary_csv(batch.scan_folder(args.reads_dir))
+ if args.output:
+ Path(args.output).write_text(text, encoding="utf-8")
+ else:
+ print(text, end="")
+ return 0
+
+
+def _export(args: argparse.Namespace) -> int:
+ from chrom_core import batch
+
+ if not args.trim:
+ raise CliError("export currently needs --trim")
+ reports = batch.scan_folder(args.reads_dir)
+ if not reports:
+ raise CliError(f"no .ab1 files in {args.reads_dir}")
+ results = batch.batch_export(reports, args.output, fmt="fasta" if args.fasta else "fastq")
+ failed = 0
+ for source, _dest, error in results:
+ if error:
+ failed += 1
+ print(f"ab1kit: {source.name}: {error}", file=sys.stderr)
+ print(f"exported {len(results) - failed} of {len(results)} reads to {args.output}")
+ return 1 if failed else 0
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="ab1kit")
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ v = sub.add_parser("verify-clones", help="verify clones against a construct")
+ v.add_argument("--construct", required=True, help="FASTA, GenBank or SnapGene file")
+ v.add_argument("--region", help="A-B, 1-based inclusive (default: whole construct)")
+ v.add_argument("--linear", action="store_true", help="treat the construct as linear")
+ v.add_argument("--pattern", help="regex with named groups 'clone' and 'primer'")
+ v.add_argument("--min-q", type=int, default=20)
+ v.add_argument("--het-ratio", type=float, default=0.40)
+ v.add_argument("reads_dir", metavar="READS_DIR")
+ v.add_argument("-o", "--output", required=True, help="report: .xlsx or .csv")
+ v.set_defaults(func=_verify_clones)
+
+ q = sub.add_parser("qc", help="quality summary of every .ab1 in a folder")
+ q.add_argument("reads_dir", metavar="READS_DIR")
+ q.add_argument("-o", "--output", help="CSV file (default: stdout)")
+ q.set_defaults(func=_qc)
+
+ e = sub.add_parser("export", help="export trimmed reads")
+ e.add_argument("reads_dir", metavar="READS_DIR")
+ e.add_argument("--trim", action="store_true")
+ fmt = e.add_mutually_exclusive_group()
+ fmt.add_argument("--fasta", action="store_true")
+ fmt.add_argument("--fastq", action="store_true", help="(default)")
+ e.add_argument("-o", "--output", required=True, metavar="DIR")
+ e.set_defaults(func=_export)
+ return parser
+
+
+def _run_command(argv: list[str]) -> int:
+ _prepare_streams()
+ try:
+ args = _build_parser().parse_args(argv)
+ except SystemExit as exc: # argparse usage errors and --help
+ return exc.code if isinstance(exc.code, int) else 2
+ try:
+ return args.func(args)
+ except CliError as exc:
+ print(f"ab1kit: {exc}", file=sys.stderr)
+ return 2
+ except OSError as exc:
+ print(f"ab1kit: {exc}", file=sys.stderr)
+ return 2
def main(argv: list[str] | None = None) -> int:
+ args = list(sys.argv[1:] if argv is None else argv)
+ if args and args[0] in COMMANDS:
+ return _run_command(args)
from ui.main_window import run
- return run(argv if argv is not None else sys.argv[1:])
+ return run(args)
if __name__ == "__main__":
diff --git a/chrom_core/align.py b/chrom_core/align.py
index 7417ca6..db70d2e 100644
--- a/chrom_core/align.py
+++ b/chrom_core/align.py
@@ -14,17 +14,24 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
+from typing import TYPE_CHECKING
from Bio import Align
from chrom_core.seqops import IUPAC, reverse_complement
+if TYPE_CHECKING: # reference.py imports this module; avoid a runtime cycle
+ from chrom_core.reference import Construct
+
MATCH_SCORE = 2.0
MISMATCH_SCORE = -3.0
GAP_OPEN = -10.0
GAP_EXTEND = -1.0
MAX_REFERENCE_LENGTH = 100_000
+CIRCULAR_MARGIN = 50 # extra unrolled bases past len(read) for circular constructs
+MAX_COUNTED_ALTERNATIVES = 1000 # co-optimal paths examined to dedupe the circular copy
+MAX_PLACEMENT_PATHS = 100 # co-optimal paths examined to count distinct placements
@dataclass(frozen=True)
@@ -40,8 +47,8 @@ class Difference:
class ReadAlignment:
strand: str # '+' or '-', relative to the read as passed in
score: float
- ref_start: int # covered reference span, 0-based [start, end)
- ref_end: int
+ ref_start: int # covered reference span, 0-based [start, end);
+ ref_end: int # ref_end < ref_start: wraps the origin of a circle
read_start: int
read_end: int
ref_to_read: dict[int, int] # covered ref position -> read index
@@ -50,11 +57,32 @@ class ReadAlignment:
alternatives: int # number of co-optimal alignments (1 = unique)
oriented_read: str # the read in its aligned orientation
blocks: list # biopython aligned blocks, for gapped reconstruction
+ # (unrolled coordinates for circular constructs)
+ ref_length: int = 0 # construct length (0 = unknown, from align_read)
+ placements: int = 1 # distinct (non-overlapping) co-optimal placements;
+ # 0 = unknown. Unlike ``alternatives``, alternative
+ # gap positions inside one placement count once.
+
+ @property
+ def wraps(self) -> bool:
+ return self.ref_end < self.ref_start
@property
def covered(self) -> int:
+ if self.wraps:
+ return self.ref_length - self.ref_start + self.ref_end
return self.ref_end - self.ref_start
+ def covers(self, pos: int) -> bool:
+ if self.wraps:
+ return self.ref_start <= pos < self.ref_length or 0 <= pos < self.ref_end
+ return self.ref_start <= pos < self.ref_end
+
+ def covered_positions(self) -> list[int]:
+ if self.wraps:
+ return list(range(self.ref_start, self.ref_length)) + list(range(self.ref_end))
+ return list(range(self.ref_start, self.ref_end))
+
def _make_aligner() -> Align.PairwiseAligner:
aligner = Align.PairwiseAligner()
@@ -85,20 +113,37 @@ def parse_sequence(text: str) -> str:
return seq
-def align_read(read: str, reference: str) -> ReadAlignment:
- """Align a (possibly reverse-strand) read into the reference."""
+def _clean(read: str, reference: str) -> tuple[str, str]:
read = "".join(read.split()).upper()
reference = "".join(reference.split()).upper()
if not read:
raise ValueError("empty read")
if not reference:
raise ValueError("empty reference")
- if len(reference) > MAX_REFERENCE_LENGTH:
+ return read, reference
+
+
+def _check_length(length: int) -> None:
+ if length > MAX_REFERENCE_LENGTH:
raise ValueError(
- f"reference of {len(reference)} bp is too long for exact alignment "
+ f"reference of {length} bp is too long for exact alignment "
f"(limit {MAX_REFERENCE_LENGTH})"
)
+
+def align_read(read: str, reference: str) -> ReadAlignment:
+ """Align a (possibly reverse-strand) read into the reference."""
+ read, reference = _clean(read, reference)
+ _check_length(len(reference))
+ return _align(read, reference)[0]
+
+
+def _align(read: str, reference: str, L: int | None = None) -> tuple[ReadAlignment, object]:
+ """align_read without the size limit (callers check it on the real length).
+
+ Also returns biopython's co-optimal alignments for the winning strand.
+ ``L``: the circle length when ``reference`` is an unrolled circle.
+ """
aligner = _make_aligner()
best: tuple[float, object, str, str] | None = None
for strand, oriented in (("+", read), ("-", reverse_complement(read))):
@@ -114,6 +159,102 @@ def align_read(read: str, reference: str) -> ReadAlignment:
except Exception: # counting can blow up on pathological repeats
alternatives = 0 # "unknown"
+ ref_to_read, differences, identity = _walk_blocks(blocks, reference, oriented)
+ differences.sort(key=lambda d: (d.ref_pos, d.kind))
+ if blocks:
+ ref_start, ref_end = int(blocks[0][0][0]), int(blocks[-1][0][1])
+ read_start, read_end = int(blocks[0][1][0]), int(blocks[-1][1][1])
+ else: # no similarity at all: empty alignment (everything became free end gaps)
+ ref_start = ref_end = read_start = read_end = 0
+
+ return ReadAlignment(
+ strand=strand,
+ score=score,
+ ref_start=ref_start,
+ ref_end=ref_end,
+ read_start=read_start,
+ read_end=read_end,
+ ref_to_read=ref_to_read,
+ differences=differences,
+ identity=identity,
+ alternatives=alternatives,
+ oriented_read=oriented,
+ blocks=blocks,
+ placements=_placements(alignments, L),
+ ), alignments
+
+
+def _placements(alignments, L: int | None = None) -> int:
+ """Clusters of co-optimal paths whose reference spans overlap.
+
+ Up to ``MAX_PLACEMENT_PATHS`` paths are examined. With ``L`` (circular,
+ unrolled reference) spans are compared modulo L, so the unrolled copy of
+ the start is the same placement. 0 when iterating the paths fails.
+ """
+ spans: list[tuple[int, int]] = []
+ try:
+ for k, alignment in enumerate(alignments):
+ if k >= MAX_PLACEMENT_PATHS:
+ break
+ ts = alignment.aligned[0]
+ if len(ts):
+ spans.append((int(ts[0][0]), int(ts[-1][1])))
+ except Exception:
+ return 0
+ if not spans:
+ return 1
+
+ if L: # start in the first copy; compare with the neighbouring copies too
+ spans = [(t0 % L, t0 % L + t1 - t0) for t0, t1 in spans]
+ shifts = (-L, 0, L) if L else (0,)
+
+ def overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
+ return any(a[0] < b[1] + s and b[0] + s < a[1] for s in shifts)
+
+ parent = list(range(len(spans)))
+
+ def root(i: int) -> int:
+ while parent[i] != i:
+ parent[i] = parent[parent[i]]
+ i = parent[i]
+ return i
+
+ for i in range(len(spans)):
+ for j in range(i):
+ if overlap(spans[i], spans[j]):
+ parent[root(i)] = root(j)
+ return len({root(i) for i in range(len(spans))})
+
+
+def _circular_alternatives(raw: ReadAlignment, alignments, L: int) -> int:
+ """Co-optimal alignments distinct modulo L.
+
+ A read in the first ``len(read) + CIRCULAR_MARGIN`` bases also matches the
+ unrolled copy, shifted by exactly L; such pairs count once. 0 stays
+ "unknown". Beyond ``MAX_COUNTED_ALTERNATIVES`` paths the raw count is
+ returned: an upper bound, and at least half of it is distinct (at most two
+ copies of any path fit in the unrolled sequence), so it stays > 1.
+ """
+ if raw.alternatives <= 1 or raw.alternatives > MAX_COUNTED_ALTERNATIVES:
+ return raw.alternatives
+ distinct = set()
+ try:
+ for alignment in alignments:
+ ts, qs = alignment.aligned
+ shift = (int(ts[0][0]) // L) * L if len(ts) else 0
+ distinct.add(tuple(
+ (int(t0) - shift, int(t1) - shift, int(q0), int(q1))
+ for (t0, t1), (q0, q1) in zip(ts, qs)
+ ))
+ except Exception: # same "unknown" convention as align_read
+ return 0
+ return len(distinct)
+
+
+def _walk_blocks(
+ blocks: list, reference: str, oriented: str
+) -> tuple[dict[int, int], list[Difference], float]:
+ """Per-position mapping, differences (unsorted) and identity of a block list."""
ref_to_read: dict[int, int] = {}
differences: list[Difference] = []
matches = 0
@@ -145,28 +286,79 @@ def align_read(read: str, reference: str) -> ReadAlignment:
Difference(int(t0 + k), "substitution", ref_base, read_base, int(q0 + k))
)
prev_t, prev_q = t1, q1
-
- differences.sort(key=lambda d: (d.ref_pos, d.kind))
identity = matches / columns if columns else 0.0
- if blocks:
- ref_start, ref_end = int(blocks[0][0][0]), int(blocks[-1][0][1])
- read_start, read_end = int(blocks[0][1][0]), int(blocks[-1][1][1])
- else: # no similarity at all: empty alignment (everything became free end gaps)
- ref_start = ref_end = read_start = read_end = 0
+ return ref_to_read, differences, identity
- return ReadAlignment(
- strand=strand,
- score=score,
+
+def align_to_construct(read: str, construct: Construct) -> ReadAlignment:
+ """Align a read to a construct; circular constructs allow reads across the origin.
+
+ Linear: ``align_read`` plus ``ref_length``. Circular: the read is aligned to
+ the unrolled sequence ``seq + seq[:len(read) + CIRCULAR_MARGIN]`` and every
+ ``ref_*`` coordinate, ``ref_to_read`` key and ``Difference.ref_pos`` is
+ reported modulo ``L = len(seq)`` (``ref_end`` as ``((ref_end - 1) % L) + 1``),
+ so ``ref_end < ref_start`` means the span wraps the origin. ``blocks`` stay
+ in unrolled coordinates: pass ``construct.seq * 2`` to ``gapped_sequences``.
+
+ A read longer than the circle can align over the same positions twice. The
+ first pass (in alignment order, from the alignment start) is kept: blocks
+ are clipped at ``start + L``, so at most L positions are reported, each
+ once, and identity/differences are computed over the clipped alignment.
+ ``score`` however stays that of the unclipped raw alignment. Full coverage
+ is normalised to ``(0, L)``.
+
+ For a wrapped alignment ``differences`` is in alignment order — positions
+ after the origin come last — not ascending by ``ref_pos``.
+
+ ``alternatives`` counts co-optimal alignments distinct modulo L, so the
+ unrolled copy of the construct's start is not mistaken for a repeat;
+ ``placements`` likewise compares reference spans modulo L.
+
+ The size limit applies to ``L``, not to the unrolled sequence.
+ """
+ read, seq = _clean(read, construct.seq)
+ L = len(seq)
+ _check_length(L)
+ if not construct.circular:
+ return replace(_align(read, seq)[0], ref_length=L)
+
+ unrolled = seq + seq[: len(read) + CIRCULAR_MARGIN]
+ raw, alignments = _align(read, unrolled, L)
+ alternatives = _circular_alternatives(raw, alignments, L)
+ if not raw.blocks:
+ return replace(raw, ref_length=L, alternatives=alternatives)
+
+ shift = (raw.blocks[0][0][0] // L) * L # make the alignment start in the first copy
+ limit = raw.blocks[0][0][0] - shift + L
+ clipped = []
+ for (t0, t1), (q0, q1) in raw.blocks:
+ t0, t1 = t0 - shift, t1 - shift
+ if t0 >= limit:
+ break
+ if t1 > limit:
+ q1 = q0 + (limit - t0)
+ t1 = limit
+ clipped.append(((int(t0), int(t1)), (int(q0), int(q1))))
+
+ ref_to_read, differences, identity = _walk_blocks(clipped, unrolled, raw.oriented_read)
+ differences.sort(key=lambda d: (d.ref_pos, d.kind)) # unrolled order = alignment order
+ ref_start, ref_end = clipped[0][0][0], clipped[-1][0][1]
+ if ref_end - ref_start >= L:
+ ref_start, ref_end = 0, L
+ else:
+ ref_start, ref_end = ref_start % L, ((ref_end - 1) % L) + 1
+ return replace(
+ raw,
ref_start=ref_start,
ref_end=ref_end,
- read_start=read_start,
- read_end=read_end,
- ref_to_read=ref_to_read,
- differences=differences,
+ read_start=clipped[0][1][0],
+ read_end=clipped[-1][1][1],
+ ref_to_read={p % L: q for p, q in ref_to_read.items()},
+ differences=[replace(d, ref_pos=d.ref_pos % L) for d in differences],
identity=identity,
alternatives=alternatives,
- oriented_read=oriented,
- blocks=blocks,
+ blocks=clipped,
+ ref_length=L,
)
@@ -175,8 +367,11 @@ def gapped_sequences(alignment: ReadAlignment, reference: str) -> tuple[str, str
reference = reference.upper()
ref_aligned: list[str] = []
read_aligned: list[str] = []
- prev_t = alignment.ref_start
- prev_q = alignment.read_start
+ if not alignment.blocks:
+ return "", ""
+ # start at the first block (== ref_start/read_start for align_read; for a
+ # circular alignment the blocks are in unrolled coordinates)
+ (prev_t, _), (prev_q, _) = alignment.blocks[0]
for (t0, t1), (q0, q1) in alignment.blocks:
ref_aligned.extend(reference[prev_t:t0])
read_aligned.extend("-" * (t0 - prev_t))
diff --git a/chrom_core/batch.py b/chrom_core/batch.py
index 90f867e..cc3efcf 100644
--- a/chrom_core/batch.py
+++ b/chrom_core/batch.py
@@ -6,7 +6,7 @@
from pathlib import Path
from chrom_core import read_ab1, trim_window
-from chrom_core import sessions
+from chrom_core.clones import load_read
from chrom_core.editing import EditableRead
from chrom_core.export import to_fasta, to_fastq, write_text
@@ -19,7 +19,7 @@
class FileReport:
path: Path
length: int = 0
- mean_q: float = 0.0
+ mean_q: float | None = 0.0 # None: the read carries no quality values (all Q = 0)
q20: int = 0
trim_start: int = 0
trim_end: int = 0
@@ -34,7 +34,10 @@ def status(self) -> str:
"""One of 'ok', 'warn', 'bad', 'error'."""
if self.error:
return "error"
- if self.trimmed_length >= MIN_OK_LENGTH and self.mean_q >= MIN_OK_MEAN_Q:
+ if (
+ self.trimmed_length >= MIN_OK_LENGTH
+ and (self.mean_q or 0.0) >= MIN_OK_MEAN_Q
+ ):
return "ok"
if self.trimmed_length >= MIN_WARN_LENGTH:
return "warn"
@@ -56,7 +59,11 @@ def scan_folder(folder: str | Path, window: int = 20, min_q: int = 20) -> list[F
FileReport(
path=path,
length=len(chrom.seq),
- mean_q=round(sum(chrom.quality) / max(1, len(chrom.quality)), 1),
+ mean_q=(
+ round(sum(chrom.quality) / len(chrom.quality), 1)
+ if any(chrom.quality)
+ else None
+ ),
q20=sum(1 for q in chrom.quality if q >= 20),
trim_start=start,
trim_end=end,
@@ -65,18 +72,6 @@ def scan_folder(folder: str | Path, window: int = 20, min_q: int = 20) -> list[F
return reports
-def _edited_read(path: Path) -> EditableRead:
- """Parse a file and apply its editing session sidecar when present."""
- model = EditableRead(read_ab1(path))
- data = sessions.load_session(sessions.session_path(path))
- if data and data.get("source") == path.name:
- try:
- model.restore_state(data)
- except (KeyError, TypeError, ValueError):
- pass
- return model
-
-
def batch_export(
reports: list[FileReport],
out_dir: str | Path,
@@ -95,7 +90,7 @@ def batch_export(
dest: Path | None = None
error = ""
try:
- model = _edited_read(report.path)
+ model = EditableRead(load_read(report.path))
start, end = trim_window(model.quality, window=window, min_q=min_q)
if end - start >= 2:
model.apply_trim(start, end)
@@ -113,13 +108,17 @@ def batch_export(
return results
-def _csv_field(value) -> str:
+def csv_field(value, sep: str = ";") -> str:
+ """One CSV field, quoted when it contains ``sep``, a quote or a line break."""
text = str(value)
- if any(ch in text for ch in ';"\n'):
+ if any(ch in text for ch in f'{sep}"\n\r'):
text = '"' + text.replace('"', '""') + '"'
return text
+_csv_field = csv_field
+
+
def summary_csv(reports: list[FileReport]) -> str:
"""Semicolon-separated summary (opens cleanly in RU-locale Excel)."""
lines = ["file;length;mean_q;q20;trimmed_length;status;error"]
@@ -130,7 +129,7 @@ def summary_csv(reports: list[FileReport]) -> str:
for value in (
report.path.name,
report.length,
- report.mean_q,
+ "" if report.mean_q is None else report.mean_q,
report.q20,
report.trimmed_length,
report.status,
diff --git a/chrom_core/clone_report.py b/chrom_core/clone_report.py
new file mode 100644
index 0000000..0a9c84a
--- /dev/null
+++ b/chrom_core/clone_report.py
@@ -0,0 +1,120 @@
+"""CSV and Excel reports for clone verification (shared by the UI and the CLI)."""
+
+from __future__ import annotations
+
+from chrom_core.batch import csv_field
+from chrom_core.clones import CloneDifference, CloneReport, ReadResult
+
+# verdict -> (light, dark) hex colours; the single source of the status palette.
+STATUS_HEX: dict[str, tuple[str, str]] = {
+ "ok": ("#c6efce", "#2e7d32"),
+ "errors": ("#ffc7ce", "#c62828"),
+ "incomplete": ("#ffeb9c", "#f9a825"),
+ "mixed": ("#e1d5f0", "#7b1fa2"),
+ "no_data": ("#e0e0e0", "#757575"),
+}
+
+CLONES_HEADER = ("clone", "verdict", "region_coverage_pct", "both_strands_pct",
+ "differences", "reads", "read_issues")
+DIFFERENCES_HEADER = ("clone", "position", "kind", "ref", "read", "effect",
+ "support", "against", "status", "note")
+
+
+def _plural(n: int, word: str) -> str:
+ return f"{n} {word}" if n == 1 else f"{n} {word}s"
+
+
+def _note(d: CloneDifference) -> str:
+ """Short remark: ``disputed: N read(s) against`` / ``low quality: N read(s)``,
+ plus ``outside region`` (joined by `` / ``); empty for a confirmed in-region one."""
+ parts = []
+ if d.status == "disputed":
+ parts.append(f"disputed: {_plural(d.against, 'read')} against")
+ elif d.status == "low_quality":
+ parts.append(f"low quality: {_plural(d.low_quality, 'read')}")
+ if not d.in_region:
+ parts.append("outside region")
+ return " / ".join(parts)
+
+
+_ERROR_TEXT = {"no_alignment": "did not align to the construct"}
+
+
+def read_issue(read: ReadResult) -> str:
+ """Why a read was left out of the verdict, in plain English; ``""`` if it was used."""
+ if read.error:
+ return _ERROR_TEXT.get(read.error, f"unreadable ({read.error})")
+ return "ambiguous placement" if read.ambiguous else ""
+
+
+def _clone_row(r: CloneReport) -> tuple:
+ # ``reads``: file names of every read of the clone (used or not), joined by "|";
+ # ``differences``: those the verdict counts; ``read_issues``: "name: reason; ...".
+ names = "|".join(read.ref.path.name for read in r.reads)
+ issues = "; ".join(f"{read.ref.path.name}: {read_issue(read)}"
+ for read in r.reads if read_issue(read))
+ return (r.clone, r.verdict, round(r.region_coverage_pct, 1), round(r.both_strands_pct, 1),
+ len(r.error_differences), names, issues)
+
+
+def _difference_rows(reports: list[CloneReport]):
+ for r in reports:
+ for d in r.differences:
+ # position is 1-based; an insertion is reported before this position
+ yield (r.clone, d.ref_pos + 1, d.kind, d.ref_base, d.read_base, d.effect,
+ d.support, d.against, d.status, _note(d))
+
+
+def _fmt(value) -> str:
+ return f"{value:.1f}" if isinstance(value, float) else str(value)
+
+
+def _csv(header, rows) -> str:
+ lines = [",".join(header)]
+ for row in rows:
+ lines.append(",".join(csv_field(_fmt(v), sep=",") for v in row))
+ return "\n".join(lines) + "\n"
+
+
+def clones_csv(reports: list[CloneReport]) -> str:
+ """One row per clone, comma-separated."""
+ return _csv(CLONES_HEADER, (_clone_row(r) for r in reports))
+
+
+def differences_csv(reports: list[CloneReport]) -> str:
+ """One row per difference of every clone (outside-region ones included)."""
+ return _csv(DIFFERENCES_HEADER, _difference_rows(reports))
+
+
+def clone_report_xlsx(path, reports: list[CloneReport], construct_name: str) -> None:
+ """Excel report: sheets ``Clones`` and ``Differences``; the verdict cell is filled
+ with its light ``STATUS_HEX`` colour. ``construct_name`` is the workbook title."""
+ try:
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, PatternFill
+ except ImportError as exc: # pragma: no cover - depends on environment
+ raise RuntimeError("openpyxl is required for Excel reports: pip install openpyxl") from exc
+
+ book = Workbook()
+ book.properties.title = construct_name
+ clones = book.active
+ clones.title = "Clones"
+ diffs = book.create_sheet("Differences")
+ for sheet, header in ((clones, CLONES_HEADER), (diffs, DIFFERENCES_HEADER)):
+ sheet.append(header)
+ for cell in sheet[1]:
+ cell.font = Font(bold=True)
+ for r in reports:
+ clones.append(_clone_row(r))
+ palette = STATUS_HEX.get(r.verdict)
+ if palette:
+ colour = palette[0].lstrip("#").upper()
+ clones.cell(row=clones.max_row, column=2).fill = PatternFill(
+ "solid", start_color=colour, end_color=colour)
+ for row in _difference_rows(reports):
+ diffs.append(row)
+ for sheet, widths in ((clones, (16, 12, 20, 18, 12, 50, 50)),
+ (diffs, (16, 10, 14, 8, 8, 14, 9, 9, 12, 30))):
+ for i, width in enumerate(widths):
+ sheet.column_dimensions[chr(ord("A") + i)].width = width
+ book.save(path)
diff --git a/chrom_core/clones.py b/chrom_core/clones.py
new file mode 100644
index 0000000..3f412d0
--- /dev/null
+++ b/chrom_core/clones.py
@@ -0,0 +1,521 @@
+"""Clone verification: grouping reads into clones, per-clone checks and verdicts."""
+
+from __future__ import annotations
+
+import re
+from concurrent.futures import CancelledError
+from dataclasses import dataclass, field, replace
+from pathlib import Path
+from typing import Iterable
+
+from chrom_core import sessions
+from chrom_core.abif import Chromatogram, read_ab1
+from chrom_core.align import Difference, ReadAlignment, align_to_construct
+from chrom_core.editing import EditableRead
+from chrom_core.effects import difference_effect
+from chrom_core.peaks import scan_secondary_peaks
+from chrom_core.reference import Construct
+from chrom_core.seqops import iupac_compatible, reverse_complement
+from chrom_core.trim import trim_window
+
+DEFAULT_PRIMER_TOKENS = (
+ "F", "R", "FW", "FWD", "REV", "RV", "T7", "T7TERM", "SP6", "T3",
+ "M13F", "M13R", "M13", "PUC-F", "PUC-R", "BGH", "CMV",
+)
+
+_SEP = r"[_\-. ]"
+_TOKEN_RE = re.compile(r"[^_\-. ]+")
+_WELL_RE = re.compile(r"[A-H]\d{1,2}")
+_MAX_PRIMER_PARTS = 3 # "pUC-F" spans two tokens
+
+
+def _norm(text: str) -> str:
+ return re.sub(f"{_SEP}+", "-", text).upper()
+
+
+@dataclass(frozen=True)
+class ReadRef:
+ path: Path
+ clone: str
+ primer: str
+
+
+def _natural_key(text: str) -> list:
+ return [
+ (0, int(part), "") if part.isdecimal() else (1, 0, part.casefold())
+ for part in re.split(r"(\d+)", text)
+ if part != ""
+ ]
+
+
+@dataclass
+class Grouping:
+ reads: list[ReadRef] = field(default_factory=list)
+ unassigned: list[Path] = field(default_factory=list)
+
+ def clones(self) -> dict[str, list[ReadRef]]:
+ out: dict[str, list[ReadRef]] = {}
+ for ref in self.reads:
+ out.setdefault(ref.clone, []).append(ref)
+ return {k: out[k] for k in sorted(out, key=_natural_key)}
+
+ def assign(self, path: Path, clone: str, primer: str) -> None:
+ """Put ``path`` into ``clone`` (moving it if already grouped or unassigned)."""
+ path = Path(path)
+ self.unassigned = [p for p in self.unassigned if p != path]
+ ref = ReadRef(path, clone, primer)
+ for i, old in enumerate(self.reads):
+ if old.path == path:
+ self.reads[i] = ref
+ return
+ self.reads.append(ref)
+
+ def to_dict(self) -> dict:
+ return {
+ "reads": [
+ {"path": str(r.path), "clone": r.clone, "primer": r.primer}
+ for r in self.reads
+ ],
+ "unassigned": [str(p) for p in self.unassigned],
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "Grouping":
+ return cls(
+ reads=[
+ ReadRef(Path(r["path"]), r["clone"], r["primer"])
+ for r in d.get("reads", [])
+ ],
+ unassigned=[Path(p) for p in d.get("unassigned", [])],
+ )
+
+
+def parse_name(
+ stem: str, primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS
+) -> tuple[str, str] | None:
+ """Split a file stem into ``(clone, primer)``; ``None`` if no clone remains.
+
+ The primer is the last dictionary token (else the last token). The clone is
+ the text before it, minus trailing separators, a lone digit (primer order)
+ and a leading plate well (``A05``) -- the well is stripped only when another
+ clone token follows it, so ``B2_F`` is clone ``B2``. Anything after the
+ primer (e.g. a repeat suffix ``_2``) is ignored.
+ """
+ tokens = list(_TOKEN_RE.finditer(stem))
+ if not tokens:
+ return None
+ known = {_norm(t) for t in primer_tokens}
+ best: tuple[int, int] | None = None # (start, end) in ``stem``
+ for i in range(len(tokens)):
+ for j in range(i, min(i + _MAX_PRIMER_PARTS, len(tokens))):
+ start, end = tokens[i].start(), tokens[j].end()
+ if _norm(stem[start:end]) not in known:
+ continue
+ # the last match wins; on equal end the longer (earlier start) one
+ if best is None or end > best[1] or (end == best[1] and start < best[0]):
+ best = (start, end)
+ if best is None:
+ best = (tokens[-1].start(), tokens[-1].end())
+ start, end = best
+ primer = stem[start:end]
+ clone = re.sub(f"{_SEP}+$", "", stem[:start])
+ clone = re.sub(rf"(?:^|{_SEP})\d$", "", clone) # primer order number
+ clone = re.sub(f"{_SEP}+$", "", clone)
+ # leading plate well, only if another clone token follows it
+ clone = re.sub(rf"^{_WELL_RE.pattern}{_SEP}+(?=[^_\-. ])", "", clone)
+ clone = re.sub(f"^{_SEP}+", "", clone)
+ return (clone, primer) if clone else None
+
+
+def group_reads(
+ paths: Iterable[Path],
+ pattern: str | None = None,
+ primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS,
+) -> Grouping:
+ """Group reads into clones by file name (default rules or a custom regex)."""
+ rx = None
+ if pattern:
+ try:
+ rx = re.compile(pattern)
+ except re.error as exc:
+ raise ValueError(f"invalid grouping pattern: {exc}") from exc
+ if not {"clone", "primer"} <= set(rx.groupindex):
+ raise ValueError("grouping pattern needs named groups 'clone' and 'primer'")
+ tokens = tuple(primer_tokens)
+ grouping = Grouping()
+ for p in map(Path, paths):
+ if rx is not None:
+ m = rx.fullmatch(p.stem)
+ parsed = (m.group("clone") or "", m.group("primer") or "") if m else None
+ if parsed is not None and not parsed[0]:
+ parsed = None
+ else:
+ parsed = parse_name(p.stem, tokens)
+ if parsed is None:
+ grouping.unassigned.append(p)
+ else:
+ grouping.reads.append(ReadRef(p, parsed[0], parsed[1]))
+ return grouping
+
+
+# ------------------------------------------------------------------ verification
+
+MIN_ALIGNED_BP = 50 # a read counts as aligned only past both thresholds
+MIN_IDENTITY = 0.80
+TRIM_WINDOW = 20 # quality trimming before alignment (sliding window)
+
+Region = tuple[int, int] # 0-based half-open; start > end wraps the origin (circular only)
+
+
+@dataclass(frozen=True)
+class VerifyParams:
+ min_q: int = 20
+ het_ratio: float = 0.40
+ mixed_min_positions: int = 3
+
+
+@dataclass(frozen=True)
+class ReadResult:
+ ref: ReadRef
+ alignment: ReadAlignment | None # None when ``error`` is set
+ error: str = "" # "no_alignment" or why the file could not be used
+ ambiguous: bool = False # placements > 1: excluded from the verdict
+
+
+@dataclass(frozen=True)
+class CloneDifference:
+ ref_pos: int
+ kind: str # 'substitution' | 'deletion' | 'insertion' (before ref_pos)
+ ref_base: str
+ read_base: str
+ support: int # reads showing it with Q >= min_q
+ against: int # reads showing the reference there with Q >= min_q
+ low_quality: int # reads showing it with Q < min_q
+ status: str # 'confirmed' | 'disputed' | 'low_quality'
+ in_region: bool
+ effect: str = ""
+
+
+@dataclass(frozen=True)
+class CloneReport:
+ clone: str
+ reads: tuple[ReadResult, ...]
+ coverage: dict[int, tuple[int, int]] # region position -> (reads on +, reads on -)
+ differences: tuple[CloneDifference, ...]
+ mixed_positions: tuple[int, ...]
+ verdict: str # 'no_data' | 'mixed' | 'errors' | 'incomplete' | 'ok'
+
+ def _pct(self, test) -> float:
+ if not self.coverage:
+ return 0.0
+ hits = sum(1 for v in self.coverage.values() if test(v))
+ return 100.0 * hits / len(self.coverage)
+
+ @property
+ def region_coverage_pct(self) -> float:
+ return self._pct(lambda v: v[0] + v[1] > 0)
+
+ @property
+ def both_strands_pct(self) -> float:
+ return self._pct(lambda v: v[0] > 0 and v[1] > 0)
+
+ @property
+ def region_differences(self) -> tuple[CloneDifference, ...]:
+ return tuple(d for d in self.differences if d.in_region)
+
+ @property
+ def error_differences(self) -> tuple[CloneDifference, ...]:
+ """In-region differences that are not low quality: what gives ``errors``."""
+ return tuple(d for d in self.region_differences if d.status != "low_quality")
+
+
+def check_region(region: Region | None, length: int, circular: bool) -> Region:
+ """``region`` normalised (``None`` -> whole construct); ``ValueError`` if invalid."""
+ if region is None:
+ return (0, length)
+ start, end = region
+ if not (0 <= start < length and 0 <= end <= length):
+ raise ValueError(f"region {start}..{end} lies outside the construct (0..{length})")
+ if start == end:
+ raise ValueError("empty region")
+ if start > end and not circular:
+ raise ValueError("a region across the origin needs a circular construct")
+ return (start, end)
+
+
+def region_positions(region: Region | None, length: int) -> list[int]:
+ """Construct positions of ``region`` in order (``None``: the whole construct)."""
+ start, end = check_region(region, length, circular=True)
+ if start < end:
+ return list(range(start, end))
+ return list(range(start, length)) + list(range(end))
+
+
+def _load(path: Path) -> tuple[Chromatogram, bool]:
+ """Current chromatogram (editing session applied) and its reversed flag."""
+ path = Path(path)
+ model = EditableRead(read_ab1(path))
+ data = sessions.load_session(sessions.session_path(path))
+ if data and data.get("source") == path.name:
+ try:
+ model.restore_state(data)
+ except (KeyError, TypeError, ValueError):
+ pass
+ return model.current(), model.reversed_complement
+
+
+def load_read(path: Path) -> Chromatogram:
+ """Parse a read and apply its editing session sidecar when present."""
+ return _load(path)[0]
+
+
+def _align_trimmed(chrom: Chromatogram, construct: Construct, min_q: int) -> ReadAlignment:
+ """Align the quality-trimmed read; indices refer to the full displayed read.
+
+ Trimming keeps low-quality overhangs from wrapping around a circle. A read
+ without a usable window (e.g. no quality values at all) is aligned whole.
+ """
+ n = len(chrom.seq)
+ start, end = trim_window(chrom.quality, window=TRIM_WINDOW, min_q=min_q)
+ if end - start < 2:
+ start, end = 0, n
+ al = align_to_construct(chrom.seq[start:end], construct)
+ if (start, end) == (0, n):
+ return al
+ off = start if al.strand == "+" else n - end
+ return replace(
+ al,
+ read_start=al.read_start + off,
+ read_end=al.read_end + off,
+ ref_to_read={p: i + off for p, i in al.ref_to_read.items()},
+ differences=[
+ d if d.read_pos is None else replace(d, read_pos=d.read_pos + off)
+ for d in al.differences
+ ],
+ oriented_read=chrom.seq.upper() if al.strand == "+"
+ else reverse_complement(chrom.seq.upper()),
+ blocks=[(t, (q0 + off, q1 + off)) for t, (q0, q1) in al.blocks],
+ )
+
+
+class _Read:
+ """An aligned read used for the verdict: quality lookups at reference sites."""
+
+ def __init__(self, chrom: Chromatogram, al: ReadAlignment, strand: str, circular: bool):
+ self.chrom, self.al, self.circular = chrom, al, circular
+ self.strand = strand # physical strand: a reverse-complemented display is undone
+ self.map = al.ref_to_read
+
+ def q(self, idx: int) -> int:
+ n = len(self.chrom.seq)
+ d = idx if self.al.strand == "+" else n - 1 - idx
+ return self.chrom.quality[d] if 0 <= d < n else 0
+
+ def _mapped_from(self, pos: int, step: int) -> int | None:
+ """Read index of the nearest mapped position beyond ``pos`` towards ``step``."""
+ p = pos
+ for _ in range(self.al.ref_length):
+ p += step
+ if self.circular:
+ p %= self.al.ref_length
+ if not self.al.covers(p):
+ return None
+ if p in self.map:
+ return self.map[p]
+ return None
+
+ def gap_q(self, pos: int) -> int:
+ """Q of the read bases flanking a deleted position or an insertion point."""
+ left = self._mapped_from(pos, -1)
+ right = self.map[pos] if pos in self.map else self._mapped_from(pos, +1)
+ if left is None or right is None:
+ return 0
+ return min(self.q(left), self.q(right))
+
+ def site_q(self, pos: int) -> int | None:
+ """Q at a reference position (flanks if deleted); None if not covered."""
+ if not self.al.covers(pos):
+ return None
+ return self.q(self.map[pos]) if pos in self.map else self.gap_q(pos)
+
+ def difference_q(self, d: Difference) -> int:
+ return self.q(d.read_pos) if d.kind == "substitution" else self.gap_q(d.ref_pos)
+
+ def shows_reference(self, d: Difference, ref: str, min_q: int) -> bool:
+ m, pos = self.map, d.ref_pos
+ if d.kind == "insertion":
+ prev = (pos - 1) % len(ref)
+ return (prev in m and pos in m and m[pos] == m[prev] + 1
+ and min(self.q(m[prev]), self.q(m[pos])) >= min_q)
+ return (pos in m and self.al.oriented_read[m[pos]] == ref[pos]
+ and self.q(m[pos]) >= min_q)
+
+
+def _informative(d: Difference) -> bool:
+ """N/IUPAC in the reference is never a difference; an ambiguous read call
+ compatible with the reference base (e.g. N) is a no-call, not a difference."""
+ if d.kind != "insertion" and d.ref_base not in "ACGT":
+ return False
+ if d.kind == "substitution" and d.read_base not in "ACGT":
+ return not iupac_compatible(d.read_base, d.ref_base)
+ return True
+
+
+def _status(support: int, against: int) -> str:
+ if support == 0:
+ return "low_quality"
+ return "disputed" if against else "confirmed"
+
+
+def _deletion_runs(found: dict, length: int, circular: bool):
+ """``(differences, showing reads, their Q)`` per merged difference, in position order.
+
+ Consecutive deleted positions shown by the same set of reads form one run
+ (across the origin on a circular construct); every other difference is a
+ run of one. A run's flanking Q is the same at each of its positions.
+ """
+ entries = [] # (sort key, run, showing, quals)
+ deletions = [] # (position, run, showing, quals, read ids)
+ for key in sorted(found):
+ d, showing, quals = found[key]
+ if d.kind != "deletion":
+ entries.append((key, [d], showing, quals))
+ continue
+ ids = frozenset(map(id, showing))
+ last = deletions[-1] if deletions else None
+ if last is not None and last[4] == ids and last[1][-1].ref_pos + 1 == d.ref_pos:
+ last[1].append(d)
+ else:
+ deletions.append((d.ref_pos, [d], showing, quals, ids))
+ if (circular and len(deletions) > 1 and deletions[0][0] == 0
+ and deletions[-1][1][-1].ref_pos == length - 1
+ and deletions[0][4] == deletions[-1][4]):
+ deletions[-1][1].extend(deletions.pop(0)[1])
+ for pos, run, showing, quals, _ids in deletions:
+ entries.append(((pos, "deletion", "-"), run, showing, quals))
+ entries.sort(key=lambda e: e[0])
+ for _key, run, showing, quals in entries:
+ yield run, showing, quals
+
+
+def _verdict(used, mixed, differences, coverage, params: VerifyParams) -> str:
+ if not used:
+ return "no_data"
+ if len(mixed) >= params.mixed_min_positions:
+ return "mixed"
+ if any(d.in_region and d.status != "low_quality" for d in differences):
+ return "errors"
+ if any(plus + minus == 0 for plus, minus in coverage.values()):
+ return "incomplete"
+ return "ok"
+
+
+def verify_clone(
+ construct: Construct,
+ region: Region | None,
+ reads: list[ReadRef],
+ params: VerifyParams = VerifyParams(),
+ annotate=difference_effect,
+) -> CloneReport:
+ """Check one colony's reads against ``construct`` within ``region``.
+
+ ``annotate(construct, difference) -> str`` fills ``CloneDifference.effect``
+ (default: ``difference_effect``; ``None`` leaves it empty).
+ """
+ L = len(construct.seq)
+ positions = region_positions(check_region(region, L, construct.circular), L)
+ in_region = set(positions)
+ ref = construct.seq.upper()
+
+ results: list[ReadResult] = []
+ used: list[_Read] = []
+ for read_ref in reads:
+ try:
+ chrom, flipped = _load(read_ref.path)
+ al = _align_trimmed(chrom, construct, params.min_q)
+ except Exception as exc: # unreadable / unalignable file: mark it, go on
+ results.append(ReadResult(read_ref, None, error=str(exc) or type(exc).__name__))
+ continue
+ if al.covered < MIN_ALIGNED_BP or al.identity < MIN_IDENTITY:
+ results.append(ReadResult(read_ref, None, error="no_alignment"))
+ continue
+ ambiguous = al.placements > 1 # distinct placements, not co-optimal paths
+ results.append(ReadResult(read_ref, al, ambiguous=ambiguous))
+ if not ambiguous:
+ strand = al.strand if not flipped else {"+": "-", "-": "+"}[al.strand]
+ used.append(_Read(chrom, al, strand, construct.circular))
+
+ coverage: dict[int, tuple[int, int]] = {}
+ for pos in positions:
+ good = [r.strand for r in used
+ if r.al.covers(pos) and r.site_q(pos) >= params.min_q]
+ coverage[pos] = (good.count("+"), good.count("-"))
+
+ # merge per (position, kind, read base): reads showing it, with their Q
+ found: dict[tuple, tuple[Difference, list[_Read], list[int]]] = {}
+ for r in used:
+ for d in r.al.differences:
+ if _informative(d):
+ entry = found.setdefault((d.ref_pos, d.kind, d.read_base), (d, [], []))
+ entry[1].append(r)
+ entry[2].append(r.difference_q(d))
+
+ differences = []
+ for run, showing, quals in _deletion_runs(found, L, construct.circular):
+ d = run[0]
+ support = sum(1 for q in quals if q >= params.min_q)
+ against = sum(1 for r in used if r not in showing
+ and all(r.shows_reference(x, ref, params.min_q) for x in run))
+ inside = any(x.ref_pos in in_region for x in run) or (
+ d.kind == "insertion" and (d.ref_pos - 1) % L in in_region)
+ ref_base = "".join(x.ref_base for x in run)
+ cd = CloneDifference(
+ d.ref_pos, d.kind, ref_base, d.read_base, support, against,
+ len(quals) - support, _status(support, against), inside,
+ )
+ if annotate is not None:
+ cd = replace(cd, effect=annotate(construct, cd) or "")
+ differences.append(cd)
+
+ mixed = tuple(sorted({
+ pos
+ for r in used
+ for pos, _ in scan_secondary_peaks(r.chrom, r.al, params.het_ratio, params.min_q)
+ if pos in in_region
+ }))
+
+ return CloneReport(
+ clone=reads[0].clone if reads else "",
+ reads=tuple(results),
+ coverage=coverage,
+ differences=tuple(differences),
+ mixed_positions=mixed,
+ verdict=_verdict(used, mixed, differences, coverage, params),
+ )
+
+
+def verify_all(
+ construct: Construct,
+ region: Region | None,
+ grouping: Grouping,
+ params: VerifyParams = VerifyParams(),
+ progress=None,
+ cancelled=None,
+ annotate=difference_effect,
+) -> list[CloneReport]:
+ """``verify_clone`` for every clone of ``grouping``, in natural name order.
+
+ ``progress(done, total)`` is called after each clone; ``cancelled()`` is
+ polled before each one and, when true, raises
+ ``concurrent.futures.CancelledError``.
+ """
+ check_region(region, len(construct.seq), construct.circular)
+ groups = grouping.clones()
+ reports = []
+ for done, reads in enumerate(groups.values(), start=1):
+ if cancelled is not None and cancelled():
+ raise CancelledError()
+ reports.append(verify_clone(construct, region, reads, params, annotate))
+ if progress is not None:
+ progress(done, len(groups))
+ return reports
diff --git a/chrom_core/config.py b/chrom_core/config.py
index aada4a6..e79d1bb 100644
--- a/chrom_core/config.py
+++ b/chrom_core/config.py
@@ -7,6 +7,8 @@
from dataclasses import asdict, dataclass, field
from pathlib import Path
+from .clones import DEFAULT_PRIMER_TOKENS
+
@dataclass
class AppConfig:
@@ -17,6 +19,8 @@ class AppConfig:
theme: str = "light" # "light" | "dark"
language: str = "ru" # "ru" | "en"
recent_files: list[str] = field(default_factory=list)
+ primer_tokens: list[str] = field(default_factory=lambda: list(DEFAULT_PRIMER_TOKENS))
+ group_pattern: str = "" # regex with named groups clone/primer; "" = by file name
def config_dir() -> Path:
diff --git a/chrom_core/editing.py b/chrom_core/editing.py
index fff1162..4fcd81e 100644
--- a/chrom_core/editing.py
+++ b/chrom_core/editing.py
@@ -120,9 +120,9 @@ def reverse_complement(self) -> None:
n = len(self.seq)
self.seq = list(reverse_complement("".join(self.seq)))
self.quality.reverse()
- if self.peaks:
- last = self.peaks[-1]
- self.peaks = [last - p for p in reversed(self.peaks)]
+ # current() reverses the traces over their full length: map peaks the same way
+ last = len(next(iter(self.original.traces.values()))) - 1
+ self.peaks = [last - p for p in reversed(self.peaks)]
self.edited = {n - 1 - k: v for k, v in self.edited.items()}
self.reversed_complement = not self.reversed_complement
diff --git a/chrom_core/effects.py b/chrom_core/effects.py
new file mode 100644
index 0000000..e4b8678
--- /dev/null
+++ b/chrom_core/effects.py
@@ -0,0 +1,87 @@
+"""Amino-acid consequences of clone differences inside CDS features."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from Bio.Seq import Seq
+
+
+if TYPE_CHECKING:
+ from chrom_core.clones import CloneDifference
+ from chrom_core.reference import Construct, Feature
+
+TRANSLATION_TABLE = 11
+_BASES = frozenset("ACGT")
+_COMPLEMENT = str.maketrans("ACGT", "TGCA")
+
+
+def _feature_positions(feature: Feature, length: int) -> list[int]:
+ """Construct positions of ``feature`` in transcript order (wrap and strand aware)."""
+ if feature.start <= feature.end:
+ pos = list(range(feature.start, feature.end))
+ else:
+ pos = list(range(feature.start, length)) + list(range(0, feature.end))
+ return pos[::-1] if feature.strand == -1 else pos
+
+
+def _translate(codon: str) -> str:
+ return str(Seq(codon).translate(table=TRANSLATION_TABLE))
+
+
+def difference_effect(construct: Construct, diff: CloneDifference) -> str:
+ """Effect of ``diff`` on the first CDS of ``construct`` that contains it.
+
+ Substitution: ``"synonymous"``, ``"D45N"`` (one-letter codes, amino-acid
+ number counted from the CDS start, frame from ``codon_start``) or
+ ``"W12*"`` for a stop. Insertion/deletion: ``"frameshift"`` when the length
+ is not a multiple of 3, else ``"inframe_ins"`` / ``"inframe_del"``; a
+ multi-base deletion arrives as one ``CloneDifference`` whose ``ref_base`` is
+ the whole deleted run. ``""`` outside any CDS, with no features, or when the
+ read base is not A/C/G/T or the codon is incomplete.
+ """
+ seq = construct.seq.upper()
+ length = len(seq)
+ if not length:
+ return ""
+ for feature in construct.features:
+ if feature.kind != "CDS":
+ continue
+ pos = _feature_positions(feature, length)
+ index = {p: i for i, p in enumerate(pos)}
+ if diff.ref_pos not in index:
+ continue
+ if diff.kind == "insertion":
+ if (diff.ref_pos - 1) % length not in index:
+ continue
+ return "frameshift" if len(diff.read_base) % 3 else "inframe_ins"
+ if diff.kind == "deletion":
+ return "frameshift" if len(diff.ref_base.replace("-", "")) % 3 else "inframe_del"
+ if diff.kind != "substitution":
+ return ""
+ alt = diff.read_base.upper()
+ if len(alt) != 1 or alt not in _BASES:
+ return ""
+ offset = index[diff.ref_pos] - (feature.codon_start - 1)
+ if offset < 0:
+ return ""
+ number, within = divmod(offset, 3)
+ first = number * 3 + feature.codon_start - 1
+ codon_pos = pos[first:first + 3]
+ if len(codon_pos) < 3:
+ return ""
+ if feature.strand == -1: # positions are in transcript order: complement only
+ alt = alt.translate(_COMPLEMENT)
+ codon = "".join(seq[p].translate(_COMPLEMENT) for p in codon_pos)
+ else:
+ codon = "".join(seq[p] for p in codon_pos)
+ mutated = codon[:within] + alt + codon[within + 1:]
+ return _label(codon, mutated, number + 1)
+ return ""
+
+
+def _label(codon: str, mutated: str, number: int) -> str:
+ ref_aa, alt_aa = _translate(codon), _translate(mutated)
+ if ref_aa == alt_aa:
+ return "synonymous"
+ return f"{ref_aa}{number}{alt_aa}"
diff --git a/chrom_core/peaks.py b/chrom_core/peaks.py
new file mode 100644
index 0000000..9f548e0
--- /dev/null
+++ b/chrom_core/peaks.py
@@ -0,0 +1,175 @@
+"""Normalised secondary-peak analysis: the single source of double-peak calls.
+
+Raw channel heights are not comparable between dyes: one channel can be
+systematically weaker than the others along the whole read, which inflates
+(or hides) a "second peak" ratio measured on raw intensities. Here every
+channel height at a position is divided by that channel's local baseline —
+the median height of nearby peaks where that channel was the called base —
+and the ratio is taken between normalised heights.
+
+The measurement window follows the local peak spacing (half the distance to
+the nearest neighbouring peak) so a neighbour's peak never leaks in.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from statistics import median
+
+from chrom_core.abif import Chromatogram
+from chrom_core.align import ReadAlignment
+from chrom_core.seqops import IUPAC
+
+BASELINE_SPAN = 20 # nearest called bases used for a channel's baseline
+MIN_WINDOW = 2 # smallest half-window, trace points
+_LONE_PEAK_WINDOW = 5 # half-window for a read with a single peak
+
+
+@dataclass(frozen=True)
+class SecondaryPeak:
+ primary_base: str # strongest (normalised) channel among the called alleles
+ base: str # strongest channel outside the called alleles; '' if none
+ ratio: float | None # normalised secondary / primary; None if unmeasurable
+
+
+def peak_window(chrom: Chromatogram, i: int) -> int:
+ """Half the distance to the nearest neighbouring peak, at least 2."""
+ peaks = chrom.peak_indices
+ gaps = []
+ if i > 0:
+ gaps.append(peaks[i] - peaks[i - 1])
+ if i + 1 < len(peaks):
+ gaps.append(peaks[i + 1] - peaks[i])
+ if not gaps:
+ return _LONE_PEAK_WINDOW
+ return max(MIN_WINDOW, min(gaps) // 2)
+
+
+def _heights(chrom: Chromatogram, i: int) -> dict[str, float]:
+ """Per-channel peak height within ``peak_window`` of called peak ``i``.
+
+ A channel's height is its tallest *local maximum* strictly inside the
+ window, or its value at the called peak position if that is higher. A
+ plain window maximum is not used: at half the peak spacing a neighbour's
+ peak is still ~half its height, so its rising/falling shoulder at the
+ window edge would masquerade as a second peak (on real reads this alone
+ produced dozens of false double peaks per read).
+ """
+ peak = chrom.peak_indices[i]
+ window = peak_window(chrom, i)
+ heights = {}
+ for base, trace in chrom.traces.items():
+ if not 0 <= peak < len(trace):
+ heights[base] = 0.0
+ continue
+ best = trace[peak]
+ lo = max(0, peak - window)
+ hi = min(len(trace) - 1, peak + window)
+ for k in range(lo + 1, hi):
+ if trace[k - 1] <= trace[k] >= trace[k + 1] and trace[k] > best:
+ best = trace[k]
+ heights[base] = float(best)
+ return heights
+
+
+def _called(chrom: Chromatogram, i: int) -> str:
+ return chrom.seq[i].upper()
+
+
+def _neighbours(n: int, i: int, span: int) -> list[int]:
+ """The ``span`` indices nearest to ``i`` (excluding ``i``) within [0, n)."""
+ out: list[int] = []
+ d = 1
+ while len(out) < span and (i - d >= 0 or i + d < n):
+ if i - d >= 0:
+ out.append(i - d)
+ if len(out) < span and i + d < n:
+ out.append(i + d)
+ d += 1
+ return out
+
+
+def _baseline(chrom: Chromatogram, i: int, span: int, heights_of) -> dict[str, float]:
+ per_channel: dict[str, list[float]] = {base: [] for base in chrom.traces}
+ primaries: list[float] = []
+ for j in _neighbours(len(chrom.seq), i, span):
+ heights = heights_of(j)
+ called = _called(chrom, j)
+ if called in per_channel:
+ per_channel[called].append(heights[called])
+ primaries.append(heights[called])
+ else: # mixed / N call: its tallest channel stands in for the peak height
+ primaries.append(max(heights.values(), default=0.0))
+ fallback = median(primaries) if primaries else 0.0
+ return {
+ base: median(values) if values else fallback
+ for base, values in per_channel.items()
+ }
+
+
+def channel_baseline(
+ chrom: Chromatogram, i: int, span: int = BASELINE_SPAN
+) -> dict[str, float]:
+ """Per-channel baseline height around read position ``i``.
+
+ For each channel: the median height of that channel over the ``span``
+ nearest called bases whose called base is that channel. A channel never
+ called nearby falls back to the median peak height of all ``span`` peaks.
+ """
+ return _baseline(chrom, i, span, lambda j: _heights(chrom, j))
+
+
+def _secondary(heights: dict[str, float], baseline: dict[str, float], called: str) -> SecondaryPeak:
+ norm = {
+ base: h / baseline[base] if baseline.get(base, 0) > 0 else h
+ for base, h in heights.items()
+ }
+ called_alleles = set(IUPAC.get(called, "ACGT")) & set(norm)
+ if not called_alleles:
+ called_alleles = set(norm)
+ primary_base = max(sorted(called_alleles), key=lambda b: norm[b])
+ primary = norm[primary_base]
+ outside = [b for b in sorted(norm) if b not in called_alleles]
+ if not outside:
+ return SecondaryPeak(primary_base, "", None)
+ base = max(outside, key=lambda b: norm[b])
+ ratio = norm[base] / primary if primary > 0 else None
+ return SecondaryPeak(primary_base, base, ratio)
+
+
+def secondary_peak(chrom: Chromatogram, i: int) -> SecondaryPeak:
+ """Strongest second channel under the called peak ``i``, normalised per dye."""
+ return _secondary(_heights(chrom, i), channel_baseline(chrom, i), _called(chrom, i))
+
+
+def scan_secondary_peaks(
+ chrom: Chromatogram,
+ alignment: ReadAlignment,
+ min_ratio: float,
+ min_q: int,
+) -> list[tuple[int, SecondaryPeak]]:
+ """Every covered reference position whose read peak has a second peak.
+
+ ``chrom`` is the read in its displayed orientation (as for ``call_snps``);
+ positions with quality below ``min_q`` are skipped. Sorted by ``ref_pos``.
+ """
+ n = len(chrom.seq)
+ cache: dict[int, dict[str, float]] = {}
+
+ def heights_of(j: int) -> dict[str, float]:
+ if j not in cache:
+ cache[j] = _heights(chrom, j)
+ return cache[j]
+
+ hits: list[tuple[int, SecondaryPeak]] = []
+ for ref_pos in sorted(alignment.ref_to_read):
+ idx = alignment.ref_to_read[ref_pos]
+ if alignment.strand == "-":
+ idx = n - 1 - idx
+ if not 0 <= idx < n or chrom.quality[idx] < min_q:
+ continue
+ baseline = _baseline(chrom, idx, BASELINE_SPAN, heights_of)
+ peak = _secondary(heights_of(idx), baseline, _called(chrom, idx))
+ if peak.ratio is not None and peak.ratio >= min_ratio:
+ hits.append((ref_pos, peak))
+ return hits
diff --git a/chrom_core/project.py b/chrom_core/project.py
new file mode 100644
index 0000000..87b5dc1
--- /dev/null
+++ b/chrom_core/project.py
@@ -0,0 +1,103 @@
+"""``.ab1proj`` project files: a clone-verification session on disk."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from chrom_core.clones import Grouping, Region, VerifyParams
+
+PROJECT_VERSION = 1
+
+
+@dataclass
+class Project:
+ construct_path: Path
+ circular: bool
+ region: Region | None
+ grouping: Grouping
+ params: VerifyParams
+
+
+def _to_stored(path: Path, base: Path) -> str:
+ """Relative POSIX path when ``path`` is inside ``base``, else absolute."""
+ absolute = Path(os.path.abspath(path))
+ try:
+ return absolute.relative_to(base).as_posix()
+ except ValueError:
+ return str(absolute)
+
+
+def _from_stored(text: str, base: Path) -> Path:
+ p = Path(text)
+ return p if p.is_absolute() else base / p
+
+
+def save_project(path: Path, project: Project) -> None:
+ path = Path(path)
+ base = Path(os.path.abspath(path.parent))
+ grouping = project.grouping.to_dict()
+ for r in grouping["reads"]:
+ r["path"] = _to_stored(Path(r["path"]), base)
+ grouping["unassigned"] = [
+ _to_stored(Path(p), base) for p in grouping["unassigned"]
+ ]
+ p = project.params
+ payload = {
+ "app": "ab1kit",
+ "version": PROJECT_VERSION,
+ "construct_path": _to_stored(project.construct_path, base),
+ "circular": project.circular,
+ "region": list(project.region) if project.region is not None else None,
+ "grouping": grouping,
+ "params": {
+ "min_q": p.min_q,
+ "het_ratio": p.het_ratio,
+ "mixed_min_positions": p.mixed_min_positions,
+ },
+ }
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def load_project(path: Path) -> Project:
+ path = Path(path)
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Project file is not valid JSON: {path} ({e})") from e
+ except (OSError, UnicodeDecodeError) as e:
+ raise ValueError(f"Cannot read project file: {path} ({e})") from e
+ if not isinstance(data, dict) or data.get("app") != "ab1kit":
+ raise ValueError(f"Not an ab1kit project file: {path}")
+ version = data.get("version")
+ if not isinstance(version, int) or version > PROJECT_VERSION:
+ raise ValueError(
+ f"Unsupported project version {version!r} (this ab1kit supports "
+ f"up to version {PROJECT_VERSION}): {path}"
+ )
+ base = Path(os.path.abspath(path.parent))
+ try:
+ grouping = Grouping.from_dict(
+ {
+ "reads": [
+ {**r, "path": str(_from_stored(r["path"], base))}
+ for r in data["grouping"].get("reads", [])
+ ],
+ "unassigned": [
+ str(_from_stored(p, base))
+ for p in data["grouping"].get("unassigned", [])
+ ],
+ }
+ )
+ region = data["region"]
+ return Project(
+ construct_path=_from_stored(data["construct_path"], base),
+ circular=bool(data["circular"]),
+ region=(int(region[0]), int(region[1])) if region is not None else None,
+ grouping=grouping,
+ params=VerifyParams(**data["params"]),
+ )
+ except (KeyError, TypeError, ValueError, IndexError, AttributeError) as e:
+ raise ValueError(f"Project file is incomplete or damaged: {path} ({e!r})") from e
diff --git a/chrom_core/reference.py b/chrom_core/reference.py
new file mode 100644
index 0000000..8a9cc21
--- /dev/null
+++ b/chrom_core/reference.py
@@ -0,0 +1,91 @@
+"""Reference constructs (plasmid / insert): loading from FASTA, GenBank, SnapGene."""
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from pathlib import Path
+
+from Bio import SeqIO
+
+from .align import parse_sequence
+
+_FORMAT_BY_EXT = {
+ ".fa": "fasta", ".fasta": "fasta", ".fna": "fasta", ".txt": "fasta",
+ ".gb": "genbank", ".gbk": "genbank", ".genbank": "genbank",
+ ".dna": "snapgene",
+}
+_NAME_QUALIFIERS = ("label", "gene", "product", "note")
+
+
+@dataclass(frozen=True)
+class Feature:
+ """Annotated region; 0-based, half-open. start > end: wraps the origin."""
+
+ name: str
+ kind: str
+ start: int
+ end: int
+ strand: int
+ codon_start: int = 1
+
+
+@dataclass(frozen=True)
+class Construct:
+ name: str
+ seq: str
+ circular: bool
+ features: tuple[Feature, ...] = ()
+
+ def with_circular(self, flag: bool) -> Construct:
+ return replace(self, circular=flag)
+
+
+def construct_from_text(text: str, name: str = "pasted") -> Construct:
+ return Construct(name, parse_sequence(text), True)
+
+
+def _detect_format(path: Path) -> str:
+ fmt = _FORMAT_BY_EXT.get(path.suffix.lower())
+ if fmt:
+ return fmt
+ with open(path, "rb") as fh:
+ head = fh.read(512).lstrip()
+ if head.startswith(b">"):
+ return "fasta"
+ if head.startswith(b"LOCUS"):
+ return "genbank"
+ raise ValueError(f"cannot determine reference format of {path.name}")
+
+
+def _feature(f) -> Feature:
+ loc = f.location
+ parts = list(loc.parts)
+ if loc.strand == -1:
+ parts.reverse() # Biopython lists complement(join()) parts in transcript order
+ start, end = int(parts[0].start), int(parts[-1].end)
+ q = f.qualifiers
+ name = next((q[k][0] for k in _NAME_QUALIFIERS if q.get(k)), f.type)
+ try:
+ codon_start = int(q["codon_start"][0]) if f.type == "CDS" else 1
+ except (KeyError, IndexError, ValueError):
+ codon_start = 1
+ return Feature(str(name), f.type, start, end, int(loc.strand or 0), codon_start)
+
+
+def load_construct(path: str | Path) -> Construct:
+ path = Path(path)
+ fmt = _detect_format(path)
+ try:
+ record = next(SeqIO.parse(path, fmt), None)
+ except Exception as exc: # Biopython raises assorted parser errors
+ raise ValueError(f"cannot parse {path.name} as {fmt}: {exc}") from exc
+ if record is None:
+ raise ValueError(f"no sequence records in {path.name}")
+ seq = parse_sequence(str(record.seq))
+ if not seq:
+ raise ValueError(f"empty sequence in {path.name}")
+ if fmt == "fasta":
+ circular, features = True, ()
+ else:
+ circular = record.annotations.get("topology") == "circular"
+ features = tuple(_feature(f) for f in record.features)
+ return Construct(record.id or path.stem, seq, circular, features)
diff --git a/chrom_core/snps.py b/chrom_core/snps.py
index 56be395..7c97c70 100644
--- a/chrom_core/snps.py
+++ b/chrom_core/snps.py
@@ -4,7 +4,8 @@
- ``homozygous`` — clean single peak of a non-reference base;
- ``heterozygous``— double peak: a second channel rises to at least
``het_ratio`` of the primary height at the same position (classic ~50/50
- Sanger het), or the basecaller itself emitted an IUPAC mixed base;
+ Sanger het; heights normalised per dye, see :mod:`chrom_core.peaks`), or
+ the basecaller itself emitted an IUPAC mixed base;
- ``low_quality`` — phred below ``min_q``; reported but flagged untrusted.
"""
@@ -14,6 +15,7 @@
from chrom_core.abif import Chromatogram
from chrom_core.align import ReadAlignment
+from chrom_core.peaks import secondary_peak
from chrom_core.seqops import IUPAC
DEFAULT_MIN_Q = 20
@@ -41,7 +43,7 @@ class SnpCall:
read_base: str # called base at the position (may itself be IUPAC)
kind: str # 'homozygous' | 'heterozygous' | 'low_quality'
quality: int
- ratio: float | None # secondary/primary peak height; None if unmeasurable
+ ratio: float | None # normalised secondary/primary peak height; None if unmeasurable
iupac: str # code summarizing the observed alleles
note: str = ""
@@ -49,7 +51,10 @@ class SnpCall:
def peak_heights(
chrom: Chromatogram, read_index: int, window: int = PEAK_WINDOW
) -> dict[str, float]:
- """Max trace intensity per channel around the called peak."""
+ """Raw max trace intensity per channel around the called peak (tooltips).
+
+ Not used for het calls: those go through :func:`chrom_core.peaks.secondary_peak`.
+ """
peak = chrom.peak_indices[read_index]
heights = {}
for base, trace in chrom.traces.items():
@@ -80,21 +85,9 @@ def call_snps(
continue
quality = chrom.quality[read_idx]
called = chrom.seq[read_idx].upper()
- heights = peak_heights(chrom, read_idx)
-
+ peak = secondary_peak(chrom, read_idx)
called_concrete = set(IUPAC.get(called, "ACGT"))
- primary = max(heights.get(b, 0.0) for b in called_concrete) or max(
- heights.values()
- )
- secondary_base, secondary = max(
- ((b, h) for b, h in heights.items()), key=lambda kv: kv[1]
- )
- # the strongest channel should be one of the called alleles; the
- # secondary candidate is the strongest channel outside them
- outside = [(b, h) for b, h in heights.items() if b not in called_concrete]
- if outside:
- secondary_base, secondary = max(outside, key=lambda kv: kv[1])
- ratio = secondary / primary if primary > 0 else None
+ secondary_base, ratio = peak.base, peak.ratio
alleles = set(called_concrete)
if ratio is not None and ratio >= het_ratio:
diff --git a/chrom_core/verify.py b/chrom_core/verify.py
index e35397c..42bf607 100644
--- a/chrom_core/verify.py
+++ b/chrom_core/verify.py
@@ -17,12 +17,13 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from chrom_core.abif import Chromatogram
from chrom_core.align import ReadAlignment
from chrom_core.seqops import IUPAC
-from chrom_core.snps import DEFAULT_HET_RATIO, DEFAULT_MIN_Q, alleles_to_iupac, peak_heights
+from chrom_core.peaks import secondary_peak
+from chrom_core.snps import DEFAULT_HET_RATIO, DEFAULT_MIN_Q, alleles_to_iupac
@dataclass(frozen=True)
@@ -41,7 +42,12 @@ class VariantVerdict:
quality: int | None = None
ratio: float | None = None
read_pos: int | None = None # 0-based index in the displayed read
- note: str = ""
+ note: str = "" # English text for CSV/XLSX reports
+ note_code: str = "" # key for the localised UI text: verify.note.
+ note_args: dict = field(default_factory=dict)
+
+
+_COMPLEMENT = {"A": "T", "C": "G", "G": "C", "T": "A"}
# ------------------------------------------------------------------- parsing
@@ -150,43 +156,50 @@ def _verify_one(
and (len(variant.ref) == 1 or variant.ref in (".", "", "-"))
)
if not is_snv:
- return VariantVerdict(variant, "unsupported", "", note="only SNV rows are supported")
+ return VariantVerdict(
+ variant, "unsupported", "", note="only SNV rows are supported", note_code="only_snv"
+ )
if not 0 <= pos < len(reference):
- return VariantVerdict(variant, "unsupported", "", note="position outside the reference")
+ return VariantVerdict(
+ variant, "unsupported", "", note="position outside the reference",
+ note_code="pos_outside_ref",
+ )
if variant.ref not in (".", "", "-") and variant.ref.upper() != reference[pos]:
return VariantVerdict(
- variant, "ref_mismatch", reference[pos], note="table ref base differs from the reference"
+ variant, "ref_mismatch", reference[pos],
+ note="table ref base differs from the reference", note_code="ref_differs",
)
if pos not in alignment.ref_to_read:
- note = (
- "deleted in the read"
- if alignment.ref_start <= pos < alignment.ref_end
- else "outside read coverage"
- )
- return VariantVerdict(variant, "no_data", "", note=note)
-
- read_idx = alignment.ref_to_read[pos]
+ if alignment.ref_start <= pos < alignment.ref_end:
+ note, code = "deleted in the read", "deleted_in_read"
+ else:
+ note, code = "outside read coverage", "outside_coverage"
+ return VariantVerdict(variant, "no_data", "", note=note, note_code=code)
+
+ # ref_to_read indexes the read in its aligned orientation; map it back to
+ # the displayed read (chrom) for '-' strand.
+ n = len(chrom.seq)
+ reverse = alignment.strand == "-"
+ read_idx = n - 1 - alignment.ref_to_read[pos] if reverse else alignment.ref_to_read[pos]
quality = chrom.quality[read_idx]
- heights = peak_heights(chrom, read_idx)
called = chrom.seq[read_idx].upper()
called_alleles = set(IUPAC.get(called, "ACGT"))
- primary = max((heights.get(b, 0) for b in called_alleles), default=0) or max(
- heights.values(), default=0
- )
- outside = [(b, h) for b, h in heights.items() if b not in called_alleles]
- secondary_base, secondary = max(outside, key=lambda kv: kv[1]) if outside else ("", 0)
- ratio = secondary / primary if primary > 0 else None
+ peak = secondary_peak(chrom, read_idx)
+ secondary_base, ratio = peak.base, peak.ratio
alleles = set(called_alleles)
if ratio is not None and ratio >= het_ratio and secondary_base:
alleles.add(secondary_base)
+ if reverse: # express the observation on the reference strand
+ alleles = {_COMPLEMENT[a] for a in alleles}
observed = alleles_to_iupac(alleles)
if quality < min_q:
return VariantVerdict(
- variant, "low_quality", observed, quality, ratio, read_idx, f"Q{quality} < {min_q}"
+ variant, "low_quality", observed, quality, ratio, read_idx, f"Q{quality} < {min_q}",
+ "low_q", {"q": quality, "min_q": min_q},
)
alt_alleles = set(IUPAC.get(variant.alt, variant.alt))
@@ -225,6 +238,7 @@ def verdicts_xlsx(path, verdicts: list[VariantVerdict], sample_id: str = "") ->
"""Excel report via openpyxl (imported lazily; optional dependency)."""
try:
from openpyxl import Workbook
+ from openpyxl.styles import Font
except ImportError as exc: # pragma: no cover - depends on environment
raise RuntimeError("openpyxl is required for Excel reports: pip install openpyxl") from exc
@@ -234,7 +248,7 @@ def verdicts_xlsx(path, verdicts: list[VariantVerdict], sample_id: str = "") ->
header = ("sample",) + _CSV_HEADER
sheet.append(header)
for cell in sheet[1]:
- cell.font = cell.font.copy(bold=True)
+ cell.font = Font(bold=True)
for v in verdicts:
ratio = round(v.ratio, 2) if v.ratio is not None else None
sheet.append(
diff --git a/docs/screenshots/batch.png b/docs/screenshots/batch.png
index 74163ac..c2c1cdf 100644
Binary files a/docs/screenshots/batch.png and b/docs/screenshots/batch.png differ
diff --git a/docs/screenshots/construct.png b/docs/screenshots/construct.png
new file mode 100644
index 0000000..74d448d
Binary files /dev/null and b/docs/screenshots/construct.png differ
diff --git a/docs/screenshots/viewer-dark.png b/docs/screenshots/viewer-dark.png
index 5d01858..b2d6955 100644
Binary files a/docs/screenshots/viewer-dark.png and b/docs/screenshots/viewer-dark.png differ
diff --git a/docs/screenshots/viewer.png b/docs/screenshots/viewer.png
index 28cb611..0a4af21 100644
Binary files a/docs/screenshots/viewer.png and b/docs/screenshots/viewer.png differ
diff --git a/docs/superpowers/plans/2026-10-25-clone-verification.md b/docs/superpowers/plans/2026-10-25-clone-verification.md
new file mode 100644
index 0000000..3419107
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-25-clone-verification.md
@@ -0,0 +1,611 @@
+# Проверка клонов, точность пиков и новый интерфейс — план реализации
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** ab1kit проверяет 4–12 клонов одного конструкта по заданному региону и показывает вердикт по каждому клону на экране-матрице, с хроматограммами всех ридов в месте каждого расхождения.
+
+**Architecture:** Вся логика — в UI-независимом `chrom_core/` (новые модули `peaks`, `reference`, `clones`, `effects`, `project`, `clone_report`; кольцевое выравнивание в `align`). Интерфейс на PySide6 Widgets: `main_window.py` разбивается на `ui/read_view/*`, новый экран — `ui/construct/*`, тяжёлые операции — через `ui/tasks.py` (`QThreadPool`). CLI — тонкая оболочка над ядром.
+
+**Tech Stack:** Python 3.11+, PySide6 ≥ 6.6, BioPython ≥ 1.83 (`Bio.Align`, `Bio.SeqIO`), openpyxl, pytest.
+
+**Spec:** `docs/superpowers/specs/2026-10-25-clone-verification-design.md`
+
+## Global Constraints
+
+- Python ≥ 3.11; новых runtime-зависимостей нет (только `biopython`, `PySide6`, `openpyxl`).
+- `chrom_core/` не импортирует Qt.
+- Все строки UI — через `tr("ключ")` из `i18n.py`; каждый новый ключ есть и в `RU` (`i18n.py`), и в `EN` (`translations_en.py`) — это проверяет `tests/test_i18n.py`. (В спецификации написано `_()` — в проекте функция называется `tr`.)
+- Пороги по умолчанию: `min_q = 20`, `het_ratio = 0.40`, `mixed_min_positions = 3`; окно базовой линии `span = 20`; запас кольцевого выравнивания `+50` п.н.; лимит конструкта `MAX_REFERENCE_LENGTH = 100_000`.
+- Тесты не ходят в сеть; данные из NCBI/GitHub скачиваются один раз скриптом и коммитятся.
+- Данные с несовместимой с MIT лицензией (gear-genomics, GPL-3.0) в репозиторий не кладутся.
+- Существующие тесты остаются зелёными после каждой задачи: `python -m pytest -q`.
+- GUI-тесты — offscreen (`QT_QPA_PLATFORM=offscreen`), по образцу `tests/test_smoke_gui.py`.
+- Палитра статусов: `ok` зелёный, `errors` красный, `incomplete` жёлтый, `mixed` фиолетовый, `no_data` серый — одни и те же цвета везде (матрица, сводка, отчёт XLSX).
+
+## Review Focus
+
+1. **Рид, развёрнутый реверс-комплементом в сессии правок** (`.ab1kit.json` с `reversed_complement: true`): покрытие и расхождения должны относиться к тем же позициям конструкта, что и у неразвёрнутого рида. Тест — Task 7.
+2. **Регион через начало координат кольцевого конструкта** (`start > end`, например 4300–200 на 4420 п.н.): покрытие, расхождения и вердикт считаются по обёрнутому региону. Тест — Task 7.
+3. **Рид без данных качества** (все Q = 0, как `examples/310.ab1`): расхождения не «подтверждаются», клон получает `incomplete`, а не `errors`; в батч-таблице «—» вместо 0. Тесты — Tasks 3 и 7.
+4. **Конструкт с несколькими записями, строчными буквами или `N`**: берётся первая запись, верхний регистр, `N` в референсе не считается расхождением. Тесты — Tasks 4 и 7.
+5. **Повторное секвенирование тем же праймером** (`cl3_T7.ab1` и `cl3_T7_2.ab1`) и **кириллица/пробелы в путях**: оба рида остаются в клоне `cl3`; проект и CLI работают с путями вида `D:\Данные\клоны 1\`. Тесты — Tasks 6, 10, 17.
+
+---
+
+## Фаза 1 — данные, анализ пиков, исправления
+
+### Task 1: Корпус реальных тестовых данных
+
+**Files:**
+- Create: `scripts/fetch_test_data.py`
+- Create: `tests/data/real/` (8 файлов `.ab1`), `tests/data/refs/`, `tests/data/README.md`
+- Create: `tests/clone_fixtures.py`
+- Test: `tests/test_data_corpus.py`
+
+**Interfaces:**
+- Produces (`tests/clone_fixtures.py`):
+ - `DATA: Path` = `tests/data`
+ - `REAL_READS: list[Path]` — 8 файлов, отсортированы
+ - `real_read(name: str) -> Chromatogram`
+ - `COI_GB: Path`, `PUC19_GB: Path`, `SNAPGENE_DNA: Path`
+ - `coi_reference() -> str` — последовательность COI, верхний регистр
+
+- [ ] **Step 1: Написать `scripts/fetch_test_data.py`.** Скачивает в `tests/data/`:
+ - из `https://raw.githubusercontent.com/roblanf/sangeranalyseR//inst/extdata/Allolobophora_chlorotica/ACHLO/` файлы `Achl_ACHLO006-09_{1_F,2_R}.ab1`, `Achl_ACHLO007-09_{1_F,2_R}.ab1`, `Achl_ACHLO040-09_{1_F,2_R}.ab1`, `Achl_ACHLO041-09_{1_F,2_R}.ab1` → `tests/data/real/`; `` — SHA HEAD на момент скачивания, зашит константой;
+ - pUC19 `L09137` в формате GenBank через `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nuccore&id=L09137&rettype=gbwithparts&retmode=text` → `tests/data/refs/pUC19_L09137.gb`;
+ - COI: accession передаётся аргументом `--coi ACC` → `tests/data/refs/coi_.gb` (тот же efetch);
+ - SnapGene-образец из `https://raw.githubusercontent.com/biopython/biopython//Tests/SnapGene/sample-d.dna` → `tests/data/refs/sample-d.dna`.
+ Только `urllib`; идемпотентен (существующие файлы не перекачивает без `--force`).
+- [ ] **Step 2: Подобрать accession COI.** Отправить `Achl_ACHLO006-09_1_F` в BLAST (`chrom_core.blast.build_request`/`submit`, результат открыть вручную) и выбрать запись GenBank *Allolobophora chlorotica* с аннотированной CDS, покрывающую рид целиком с идентичностью ≥ 98%. Запустить `python scripts/fetch_test_data.py --coi `.
+- [ ] **Step 3: Написать `tests/data/README.md`** — источник каждого файла, commit/accession, лицензия (sangeranalyseR — MIT; BioPython — Biopython License; NCBI — public domain), пометка «полусинтетические данные генерируются в `tests/clone_fixtures.py`, на диске не лежат».
+- [ ] **Step 4: Написать падающий тест**
+
+```python
+# tests/test_data_corpus.py
+from clone_fixtures import REAL_READS, COI_GB, PUC19_GB, SNAPGENE_DNA, real_read, coi_reference
+from chrom_core.align import align_read
+
+def test_corpus_files_present():
+ assert len(REAL_READS) == 8
+ assert COI_GB.exists() and PUC19_GB.exists() and SNAPGENE_DNA.exists()
+
+def test_every_real_read_aligns_to_coi_with_high_identity():
+ ref = coi_reference()
+ for path in REAL_READS:
+ aln = align_read(real_read(path.name).seq, ref)
+ assert aln.identity >= 0.97, path.name
+ assert aln.strand == ("+" if "_F" in path.name else "-")
+```
+
+- [ ] **Step 5: Запустить** `python -m pytest tests/test_data_corpus.py -v` → FAIL (`ModuleNotFoundError: clone_fixtures`).
+- [ ] **Step 6: Реализовать `tests/clone_fixtures.py`** (константы и функции из Interfaces; `coi_reference` читает `COI_GB` через `Bio.SeqIO`). Добавить `pythonpath = ["tests"]` в `[tool.pytest.ini_options]` в `pyproject.toml`.
+- [ ] **Step 7: Запустить** тест → PASS. Если идентичность ниже порога у отдельных ридов — сменить accession (Step 2), а не порог.
+- [ ] **Step 8: Commit** `git add scripts/fetch_test_data.py tests/data tests/clone_fixtures.py tests/test_data_corpus.py pyproject.toml && git commit -m "Test corpus: real sangeranalyseR reads, COI and pUC19 references"`
+
+### Task 2: `peaks.py` — нормированный анализ вторичных пиков
+
+**Files:**
+- Create: `chrom_core/peaks.py`
+- Modify: `chrom_core/snps.py` (использует `peaks`), `chrom_core/verify.py:166-181` (то же)
+- Test: `tests/test_peaks.py`; регрессия — `tests/test_snps.py`, `tests/test_verify.py`
+
+**Interfaces:**
+- Consumes: `Chromatogram` (`chrom_core/abif.py`), `ReadAlignment` (`chrom_core/align.py`)
+- Produces (`chrom_core/peaks.py`):
+ - `BASELINE_SPAN = 20`
+ - `peak_window(chrom: Chromatogram, i: int) -> int` — половина расстояния до ближайшего соседнего пика, не меньше 2
+ - `channel_baseline(chrom: Chromatogram, i: int, span: int = BASELINE_SPAN) -> dict[str, float]` — для каждого канала медиана высот пиков тех из `span` ближайших (по индексу) вызванных баз, у которых этот канал — вызванная база; если таких нет — медиана высот всех `span` пиков по всем каналам
+ - `@dataclass(frozen=True) SecondaryPeak(primary_base: str, base: str, ratio: float | None)`
+ - `secondary_peak(chrom: Chromatogram, i: int) -> SecondaryPeak` — высоты в окне `peak_window`, делённые на `channel_baseline`; `ratio = норм. второй вне вызванных / норм. максимальный среди вызванных`
+ - `scan_secondary_peaks(chrom: Chromatogram, alignment: ReadAlignment, min_ratio: float, min_q: int) -> list[tuple[int, SecondaryPeak]]` — по всем `ref_pos` из `alignment.ref_to_read` (в ориентации `chrom`, как в `call_snps`), где `quality ≥ min_q` и `ratio ≥ min_ratio`; список `(ref_pos, SecondaryPeak)` по возрастанию `ref_pos`
+ - `peak_heights` остаётся в `snps.py` с прежней сигнатурой (обратная совместимость, используется UI-тултипами)
+
+- [ ] **Step 1: Написать падающие тесты** (синтетика через `make_chrom` из `tests/test_snps.py` — перенести её в `tests/clone_fixtures.py` и импортировать в обоих местах):
+
+```python
+def test_weak_channel_normalisation_removes_false_het():
+ # G-канал систематически вдвое слабее: «второй пик» G на 45% сырой высоты
+ # при норм. высоте ≈ 0.9·baseline A → ratio по сырым 0.45 (ложный het),
+ # после нормировки — сам G-пик нормален, а под A-пиком G 45/500 → < 0.40
+ ...
+ assert secondary_peak(chrom, 10).ratio < 0.40
+
+def test_true_het_detected_after_normalisation():
+ ... # второй пик C на 50% → ratio ≈ 0.5
+ assert secondary_peak(chrom, 10).base == "C"
+ assert 0.45 <= secondary_peak(chrom, 10).ratio <= 0.55
+
+def test_peak_window_tracks_spacing():
+ assert peak_window(chrom_spacing_10, 5) == 5
+ assert peak_window(chrom_spacing_3, 5) == 2
+
+def test_scan_finds_het_where_reference_base_was_called():
+ # рид = референс, но под позицией 30 второй пик T на 50%
+ hits = scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20)
+ assert [pos for pos, _ in hits] == [30]
+```
+
+ Конкретные значения трасс — в теле тестов; утверждения — как выше.
+- [ ] **Step 2: Запустить** `python -m pytest tests/test_peaks.py -v` → FAIL (`ModuleNotFoundError: chrom_core.peaks`).
+- [ ] **Step 3: Реализовать `chrom_core/peaks.py`** по Interfaces.
+- [ ] **Step 4: Перевести `call_snps` и `_verify_one` на `secondary_peak`.** Логика вердиктов не меняется, меняется только источник `ratio`/`secondary_base`.
+- [ ] **Step 5: Добавить регрессию на реальных ридах** в `tests/test_peaks.py`:
+
+```python
+def test_real_reads_have_no_mass_false_hets():
+ ref = coi_reference()
+ for path in REAL_READS:
+ chrom = real_read(path.name)
+ aln = align_read(chrom.seq, ref)
+ hits = scan_secondary_peaks(chrom, aln, 0.40, 20)
+ assert len(hits) <= 2, (path.name, hits)
+```
+
+- [ ] **Step 6: Запустить всё** `python -m pytest -q` → PASS (включая `test_snps.py`, `test_verify.py`). Если старый тест SNP падает из-за нормировки, разобрать конкретный случай; менять ожидание теста можно только если новое значение ближе к истине — с комментарием в тесте.
+- [ ] **Step 7: Commit** `git commit -m "peaks: normalised secondary-peak analysis at any read position"`
+
+### Task 3: Исправить две ошибки интерфейса
+
+**Files:**
+- Modify: `chrom_core/verify.py` (`VariantVerdict`), `chrom_core/batch.py` (`FileReport`, `scan_folder`), `ui/main_window.py:722-757` (`_fill_verify_table`), `ui/batch_panel.py:76`, `i18n.py`, `translations_en.py`
+- Test: `tests/test_verify.py`, `tests/test_batch.py`, `tests/test_smoke_gui.py`
+
+**Interfaces:**
+- Produces:
+ - `VariantVerdict.note_code: str = ""` — коды: `only_snv`, `pos_outside_ref`, `ref_differs`, `deleted_in_read`, `outside_coverage`, `low_q` (с `note_args={"q": ..., "min_q": ...}`); поле `note_args: dict = field(default_factory=dict)`. Поле `note` (английский текст) остаётся — его пишут CSV/XLSX-отчёты.
+ - i18n-ключи `verify.note.` в RU и EN.
+ - `FileReport.mean_q: float | None` — `None`, если у рида все Q = 0.
+
+- [ ] **Step 1: Падающие тесты**
+
+```python
+# test_verify.py
+def test_ref_mismatch_carries_note_code(): ...
+ assert verdict.note_code == "ref_differs"
+
+# test_batch.py
+def test_read_without_quality_reports_none_mean_q(folder):
+ by_name = {r.path.name: r for r in batch.scan_folder(folder)}
+ assert by_name["310.ab1"].mean_q is None
+
+# test_smoke_gui.py
+def test_verify_table_note_is_localised(qapp, ...):
+ # русский UI, вариант с неверной ref-базой → ячейка «Примечание»
+ assert "table ref base" not in note_cell_text
+ assert note_cell_text == tr("verify.note.ref_differs")
+```
+
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать:** `_verify_one` заполняет `note_code`/`note_args`; `_fill_verify_table` показывает `tr(f"verify.note.{code}", **args)` (если код пуст — `note`); `scan_folder` ставит `mean_q=None` при `not any(chrom.quality)`; `BatchPanel` показывает `"—"` для `None`; `status` не меняется (рид без качества по-прежнему оценивается по длине обрезки). Проверить, что `summary_csv` пишет пустую ячейку для `None`.
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Commit** `git commit -m "Fix untranslated verify notes and Q=0 for reads without quality"`
+
+---
+
+## Фаза 2 — ядро проверки клонов
+
+### Task 4: `reference.py` — загрузка конструкта
+
+**Files:**
+- Create: `chrom_core/reference.py`
+- Test: `tests/test_reference.py`
+
+**Interfaces:**
+- Produces:
+ - `@dataclass(frozen=True) Feature(name: str, kind: str, start: int, end: int, strand: int)` — 0-based, полуоткрытый; `start > end` — фича через начало координат; `codon_start: int = 1` (из квалификатора `codon_start` для CDS)
+ - `@dataclass(frozen=True) Construct(name: str, seq: str, circular: bool, features: tuple[Feature, ...] = ())` с методом `with_circular(flag: bool) -> Construct`
+ - `load_construct(path: str | Path) -> Construct` — `.fa .fasta .fna .txt` → `fasta`, `.gb .gbk .genbank` → `genbank`, `.dna` → `snapgene`; неизвестное расширение — пробовать по первому символу (`>` → fasta, `LOCUS` → genbank), иначе `ValueError`. Первая запись; `seq.upper()`; недопустимые символы → `ValueError` (как `align.parse_sequence`). `circular` — из `record.annotations["topology"] == "circular"`; для FASTA — `True`. Имя фичи — первый из квалификаторов `label`, `gene`, `product`, `note`, иначе `kind`. Составные локации (`CompoundLocation`) через начало: `start` первой части, `end` последней.
+ - `construct_from_text(text: str, name: str = "pasted") -> Construct` — через `align.parse_sequence`, `circular=True`
+
+- [ ] **Step 1: Падающие тесты**
+
+```python
+def test_genbank_puc19():
+ c = load_construct(PUC19_GB)
+ assert len(c.seq) == 2686 and c.circular
+ assert any(f.kind == "CDS" for f in c.features)
+
+def test_snapgene_sample(): c = load_construct(SNAPGENE_DNA); assert c.seq and c.features
+
+def test_fasta_multi_record_lowercase(tmp_path):
+ p = tmp_path / "x.fasta"; p.write_text(">a\nacgtn\n>b\nTTTT\n")
+ c = load_construct(p)
+ assert c.seq == "ACGTN" and c.circular and c.features == ()
+
+def test_unknown_extension_sniffed(tmp_path): ... # "x.seq" с ">"-заголовком → FASTA
+
+def test_garbage_rejected(tmp_path): ... # pytest.raises(ValueError)
+
+def test_with_circular_toggle(): assert not construct_from_text("ACGT").with_circular(False).circular
+```
+
+- [ ] **Step 2: Запустить** `python -m pytest tests/test_reference.py -v` → FAIL.
+- [ ] **Step 3: Реализовать** по Interfaces.
+- [ ] **Step 4: Запустить** → PASS.
+- [ ] **Step 5: Commit** `git commit -m "reference: load constructs from FASTA, GenBank and SnapGene"`
+
+### Task 5: Кольцевое выравнивание
+
+**Files:**
+- Modify: `chrom_core/align.py` (новые функции; `align_read` не меняется)
+- Test: `tests/test_align_circular.py`
+
+**Interfaces:**
+- Consumes: `Construct` (Task 4)
+- Produces (`chrom_core/align.py`):
+ - `CIRCULAR_MARGIN = 50`
+ - `ReadAlignment` получает поле `ref_length: int = 0` и методы `covers(pos: int) -> bool`, `covered_positions() -> list[int]` (для обёрнутых — `ref_start..L-1, 0..ref_end-1`); `ref_end < ref_start` означает обёртку
+ - `align_to_construct(read: str, construct: Construct) -> ReadAlignment` — линейный: `align_read` + `ref_length`; кольцевой: `align_read(read, seq + seq[:len(read) + CIRCULAR_MARGIN])` (лимит проверяется по `len(construct.seq)`), затем все координаты `ref_*`, ключи `ref_to_read`, `Difference.ref_pos` — по модулю `L`; для `ref_end` используется `((ref_end - 1) % L) + 1`
+ - `gapped_sequences` для обёрнутого выравнивания работает по развёрнутому референсу (`seq + seq[:…]`) — передавать ему `construct.seq * 2`
+
+- [ ] **Step 1: Падающие тесты** (на реальном риде и COI):
+
+```python
+def test_linear_matches_align_read(): ... # те же differences/identity, ref_length == len(ref)
+
+def test_read_across_origin_of_rotated_reference():
+ ref = coi_reference(); aln0 = align_read(read.seq, ref)
+ mid = (aln0.ref_start + aln0.ref_end) // 2
+ rotated = ref[mid:] + ref[:mid]
+ aln = align_to_construct(read.seq, Construct("rot", rotated, True))
+ assert aln.ref_end < aln.ref_start # обёрнуто
+ assert len(aln.covered_positions()) == aln0.covered
+ assert [ (d.ref_pos - (len(ref) - mid)) % len(ref) for d in aln.differences ] \
+ == [ d.ref_pos for d in aln0.differences ]
+ assert aln.covers(0) and aln.covers(len(ref) - 1)
+
+def test_same_rotation_linear_breaks_read():
+ aln = align_to_construct(read.seq, Construct("rot", rotated, False))
+ assert aln.covered < aln0.covered # без кольца рид рвётся
+
+def test_limit_applies_to_original_length(): ... # 100_000 п.н. кольцо проходит; 100_001 — ValueError
+```
+
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать** по Interfaces.
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS (включая старые `test_align.py`).
+- [ ] **Step 5: Commit** `git commit -m "align: circular constructs with origin-spanning reads"`
+
+### Task 6: Группировка ридов в клоны
+
+**Files:**
+- Create: `chrom_core/clones.py` (раздел группировки)
+- Modify: `chrom_core/config.py` (`AppConfig.primer_tokens: list[str]`, `AppConfig.group_pattern: str = ""`)
+- Test: `tests/test_grouping.py`
+
+**Interfaces:**
+- Produces:
+ - `DEFAULT_PRIMER_TOKENS = ("F", "R", "FW", "FWD", "REV", "RV", "T7", "T7TERM", "SP6", "T3", "M13F", "M13R", "M13", "PUC-F", "PUC-R", "BGH", "CMV")`
+ - `@dataclass(frozen=True) ReadRef(path: Path, clone: str, primer: str)`
+ - `@dataclass Grouping(reads: list[ReadRef], unassigned: list[Path])` с методами `clones() -> dict[str, list[ReadRef]]` (порядок клонов — натуральная сортировка: `cl2` < `cl10`), `assign(path: Path, clone: str, primer: str) -> None`, `to_dict() -> dict`, `from_dict(d: dict) -> Grouping`
+ - `parse_name(stem: str, primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS) -> tuple[str, str] | None` — алгоритм из спецификации §2.4 (разделители `_ - .` и пробел; токен праймера — последний совпавший со словарём без учёта регистра, иначе последний токен; клон — исходная подстрока до токена праймера без хвостовых разделителей, без одиночной цифры прямо перед праймером и без ведущей лунки `^[A-H]\d{1,2}$`; хвостовой суффикс повтора `_2`/`-2` **после** токена праймера отбрасывается). Пустой клон → `None`.
+ - `group_reads(paths: Iterable[Path], pattern: str | None = None, primer_tokens=DEFAULT_PRIMER_TOKENS) -> Grouping` — при `pattern` используется `re.fullmatch` с группами `clone`, `primer`; несовпавшие → `unassigned`. Невалидный regex → `ValueError`.
+
+- [ ] **Step 1: Падающие тесты**
+
+```python
+@pytest.mark.parametrize("stem,expected", [
+ ("Achl_ACHLO006-09_1_F", ("Achl_ACHLO006-09", "F")),
+ ("Achl_ACHLO006-09_2_R", ("Achl_ACHLO006-09", "R")),
+ ("cl3_T7", ("cl3", "T7")),
+ ("cl3_T7_2", ("cl3", "T7")), # повторное секвенирование
+ ("A05_K3-F", ("K3", "F")),
+ ("clone12.rev", ("clone12", "rev")),
+ ("клон 14_SP6", ("клон 14", "SP6")),
+ ("T7", None),
+])
+def test_parse_name(stem, expected): assert parse_name(stem) == expected
+
+def test_group_real_reads_into_four_clones():
+ g = group_reads(REAL_READS)
+ assert len(g.clones()) == 4 and all(len(v) == 2 for v in g.clones().values())
+
+def test_repeat_reads_both_kept(): ... # cl3_T7.ab1 + cl3_T7_2.ab1 → 2 рида в cl3
+
+def test_custom_pattern_and_unassigned(): ... # r"(?PS\d+)-(?P\w+)" ; "junk" → unassigned
+
+def test_bad_regex_raises(): ...
+
+def test_natural_sort(): ... # cl2, cl10
+
+def test_grouping_roundtrip_and_manual_assign(): ...
+```
+
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать** по Interfaces; `AppConfig` — новые поля со значениями по умолчанию (`list(DEFAULT_PRIMER_TOKENS)`, `""`).
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Commit** `git commit -m "clones: group reads into clones by file name"`
+
+### Task 7: Проверка клона и вердикт
+
+**Files:**
+- Modify: `chrom_core/clones.py` (раздел проверки)
+- Modify: `tests/clone_fixtures.py` (полусинтетика)
+- Test: `tests/test_clone_verify.py`
+
+**Interfaces:**
+- Consumes: `Construct` (Task 4), `align_to_construct`, `ReadAlignment.covers/covered_positions` (Task 5), `secondary_peak`, `scan_secondary_peaks` (Task 2), `ReadRef` (Task 6), `sessions.load_session/session_path`, `EditableRead.restore_state/current`
+- Produces (`chrom_core/clones.py`):
+ - `@dataclass(frozen=True) VerifyParams(min_q: int = 20, het_ratio: float = 0.40, mixed_min_positions: int = 3)`
+ - `Region = tuple[int, int]` — 0-based полуоткрытый; `start > end` — через начало (только для кольцевого); `region_positions(region, length) -> list[int]`; `None` = весь конструкт
+ - `@dataclass(frozen=True) ReadResult(ref: ReadRef, alignment: ReadAlignment | None, error: str = "", ambiguous: bool = False)`
+ - `@dataclass(frozen=True) CloneDifference(ref_pos: int, kind: str, ref_base: str, read_base: str, support: int, against: int, low_quality: int, status: str, in_region: bool, effect: str = "")` — `status ∈ {"confirmed", "disputed", "low_quality"}`
+ - `@dataclass(frozen=True) CloneReport(clone: str, reads: tuple[ReadResult, ...], coverage: dict[int, tuple[int, int]], differences: tuple[CloneDifference, ...], mixed_positions: tuple[int, ...], verdict: str)` + свойства `region_coverage_pct: float`, `both_strands_pct: float`, `region_differences` (только `in_region`). `coverage[pos] = (ридов на «+», ридов на «−»)` с Q ≥ `min_q`, только для позиций региона
+ - `load_read(path: Path) -> Chromatogram` — с учётом сессии правок (как `batch._edited_read`, вынести её в общую функцию и переиспользовать)
+ - `verify_clone(construct: Construct, region: Region | None, reads: list[ReadRef], params: VerifyParams = VerifyParams(), annotate=None) -> CloneReport` — `annotate` — колбэк `(construct, CloneDifference) -> str` (эффект; подключается в Task 8)
+ - `verify_all(construct, region, grouping: Grouping, params, progress=None, cancelled=None) -> list[CloneReport]` — `progress(done: int, total: int)`, `cancelled() -> bool` (при `True` — `CancelledError` из `concurrent.futures`)
+- Правила (спецификация §2.4): `N`/IUPAC в **референсе** — не расхождение; расхождение «подтверждает» рид с Q ≥ `min_q` в этой позиции (для вставки — Q соседних баз), «против» — рид, покрывающий позицию с референсной базой и Q ≥ `min_q`; `alternatives > 1` → `ambiguous=True`, рид исключается из покрытия и расхождений. Вердикт — первое сработавшее: `no_data`, `mixed`, `errors` (есть `confirmed` или `disputed` в регионе), `incomplete`, `ok`.
+- Produces (`tests/clone_fixtures.py`):
+ - `mutate(seq: str, pos: int, kind: str, base: str = "") -> str` — `kind ∈ {"sub", "ins", "del"}`
+ - `with_secondary_peak(chrom: Chromatogram, index: int, base: str, ratio: float) -> Chromatogram` — копирует форму пика вызванной базы в канал `base`, масштабируя к `ratio` × высота основного пика
+ - `write_read(chrom: Chromatogram, folder: Path, name: str) -> Path` — сохраняет как сессию правок рядом с копией исходного `.ab1` (через `sessions.save_session`), чтобы `load_read` вернул изменённую хроматограмму
+
+- [ ] **Step 1: Падающие тесты** — один набор клонов, собранный фикстурой из реальных ридов `ACHLO006-09` (F+R), регион = покрытая обоими ридами часть COI:
+
+```python
+def test_ok_clone(): assert report.verdict == "ok" and report.region_differences == ()
+
+def test_substitution_found_with_support():
+ ref = mutate(coi, P, "sub", other_base) # P внутри региона
+ r = verify_clone(Construct("c", ref, False), region, reads)
+ d, = r.region_differences
+ assert (d.ref_pos, d.kind, d.status, d.support, d.against) == (P, "substitution", "confirmed", 2, 0)
+ assert r.verdict == "errors"
+
+def test_insertion_and_deletion_found(): ... # mutate "del" → read shows insertion; "ins" → deletion
+
+def test_disputed_when_reads_disagree(): ... # мутация в одном риде (через write_read) → support 1, against 1, "disputed", verdict "errors"
+
+def test_outside_region_does_not_affect_verdict(): ...
+
+def test_incomplete_when_read_trimmed(): ... # только F, обрезан → "incomplete", region_coverage_pct < 100
+
+def test_mixed_50_percent_flagged(): # 3 позиции с ratio 0.5
+ assert r.verdict == "mixed" and len(r.mixed_positions) == 3
+
+def test_mixed_30_percent_not_flagged(): ... # ratio 0.3 → "ok"
+
+def test_no_data_for_foreign_read(): ... # рид pUC19-несовместимый (COI-рид против pUC19) → "no_data"
+
+def test_reverse_complemented_session_same_result(): ... # Review Focus 1
+
+def test_region_across_origin(): ... # повёрнутый кольцевой COI, регион start > end → те же расхождения (Review Focus 2)
+
+def test_read_without_quality_is_incomplete_not_errors(): ... # все Q=0 → "incomplete" (Review Focus 3)
+
+def test_reference_N_is_not_a_difference(): ... # Review Focus 4
+
+def test_unreadable_file_marked_not_fatal(): ... # битый .ab1 → ReadResult.error, остальные обработаны
+
+def test_ambiguous_read_excluded(): ... # линейный конструкт = COI + COI → ReadResult.ambiguous, verdict "no_data"
+
+def test_invalid_region_rejected(): ... # пустой регион, вне конструкта, start > end на линейном → ValueError до выравнивания
+
+def test_verify_all_progress_and_cancel(): ...
+
+def test_performance_12_clones_x_4_reads():
+ # конструкт ~10 т.п.н. = COI + случайный фланг (seed 1); 12 клонов × 4 рида из реальных
+ t = time.perf_counter(); verify_all(...); elapsed = time.perf_counter() - t
+ assert elapsed < LIMIT # LIMIT = 2 × замер на машине разработчика, вписать числом
+```
+
+- [ ] **Step 2: Запустить** `python -m pytest tests/test_clone_verify.py -v` → FAIL.
+- [ ] **Step 3: Реализовать** фикстуры в `tests/clone_fixtures.py` и проверку в `chrom_core/clones.py` по Interfaces.
+- [ ] **Step 4: Запустить** → PASS; замерить `test_performance…`, вписать `LIMIT` числом.
+- [ ] **Step 5: Запустить всё** `python -m pytest -q` → PASS.
+- [ ] **Step 6: Commit** `git commit -m "clones: per-clone coverage, merged differences and verdicts"`
+
+### Task 8: Эффект расхождения в CDS
+
+**Files:**
+- Create: `chrom_core/effects.py`
+- Test: `tests/test_effects.py`
+
+**Interfaces:**
+- Consumes: `Construct`, `Feature` (Task 4), `CloneDifference` (Task 7)
+- Produces:
+ - `difference_effect(construct: Construct, diff: CloneDifference) -> str` — первая CDS, содержащая `diff.ref_pos` (с учётом обёртки и `strand`, рамка из `codon_start`). Замена: `"synonymous"` / `"[<номер>"` (однобуквенные коды, например `"D45N"`) / `"][<номер>*"` для стопа; вставка/делеция: `"frameshift"`, если длина не кратна 3, иначе `"inframe_ins"` / `"inframe_del"`. Вне CDS или без фич — `""`. Трансляция — `Bio.Seq.translate`, таблица 11.
+ - Строка-код эффекта переводится в UI ключами `effect.synonymous`, `effect.frameshift`, `effect.inframe_ins`, `effect.inframe_del` (миссенс/стоп показываются как есть).
+
+- [ ] **Step 1: Падающие тесты** — на COI из GenBank (с CDS) и на pUC19 (`bla` на минус-цепи):
+
+```python
+def test_synonymous_third_codon_position(): ... # замена 3-го нуклеотида кодона на синонимичный
+def test_missense_label(): assert difference_effect(coi_c, d) == f"{aa_ref}{n}{aa_alt}"
+def test_stop_gained(): assert difference_effect(...).endswith("*")
+def test_frameshift_and_inframe(): ...
+def test_minus_strand_cds_puc19(): ... # замена в bla даёт верный номер аминокислоты
+def test_no_features_empty(): assert difference_effect(Construct("x", seq, False), d) == ""
+```
+
+ Ожидаемые значения вычисляются в тесте из известного кодона, а не хардкодятся «на глаз».
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать**; подключить `difference_effect` как `annotate` по умолчанию в `verify_clone`.
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Commit** `git commit -m "effects: amino-acid consequences of clone differences in CDS"`
+
+### Task 9: Отчёты по клонам
+
+**Files:**
+- Create: `chrom_core/clone_report.py`
+- Test: `tests/test_clone_report.py`
+
+**Interfaces:**
+- Consumes: `CloneReport`, `CloneDifference` (Task 7)
+- Produces:
+ - `clones_csv(reports: list[CloneReport]) -> str` — колонки `clone, verdict, region_coverage_pct, both_strands_pct, differences, reads`
+ - `differences_csv(reports: list[CloneReport]) -> str` — колонки `clone, position, kind, ref, read, effect, support, against, status, note`; `position` 1-based
+ - `STATUS_HEX: dict[str, tuple[str, str]]` — `verdict -> (светлая, тёмная)` hex-цвета для `ok, errors, incomplete, mixed, no_data` (единый источник палитры; UI импортирует его в Task 13)
+ - `clone_report_xlsx(path, reports: list[CloneReport], construct_name: str) -> None` — листы `Clones` и `Differences` с теми же колонками; ячейка вердикта залита светлым цветом из `STATUS_HEX`
+ - CSV в том же стиле, что `verify.verdicts_csv` (разделитель `,`, экранирование как `batch._csv_field`)
+
+- [ ] **Step 1: Падающие тесты** — строки заголовков, число строк, 1-based позиция, цвет заливки ячейки `errors` в XLSX (`openpyxl.load_workbook`).
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Запустить** → PASS.
+- [ ] **Step 5: Commit** `git commit -m "clone_report: CSV and Excel reports for clone verification"`
+
+### Task 10: Проект `.ab1proj`
+
+**Files:**
+- Create: `chrom_core/project.py`
+- Test: `tests/test_project.py`
+
+**Interfaces:**
+- Consumes: `Construct`, `load_construct` (Task 4), `Grouping` (Task 6), `VerifyParams`, `Region` (Task 7)
+- Produces:
+ - `PROJECT_VERSION = 1`
+ - `@dataclass Project(construct_path: Path, circular: bool, region: Region | None, grouping: Grouping, params: VerifyParams)`
+ - `save_project(path: Path, project: Project) -> None` — JSON UTF-8 (`ensure_ascii=False`); пути к конструкту и ридам относительные к папке проекта, если лежат внутри неё, иначе абсолютные
+ - `load_project(path: Path) -> Project` — `ValueError` с понятным сообщением при битом JSON, чужом файле (`app != "ab1kit"`) или `version > PROJECT_VERSION`
+
+- [ ] **Step 1: Падающие тесты:** round-trip; относительные пути при переносе папки проекта; путь `tmp_path / "Данные" / "клоны 1"` (Review Focus 5); битый файл и будущая версия → `ValueError`.
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Commit** `git commit -m "project: .ab1proj files for clone verification sessions"`
+
+---
+
+## Фаза 3 — интерфейс
+
+### Task 11: Фоновые задачи
+
+**Files:**
+- Create: `ui/tasks.py`
+- Modify: `ui/main_window.py` (`_scan_folder`, `_blast_submit` — через задачи)
+- Test: `tests/test_ui_tasks.py`
+
+**Interfaces:**
+- Produces:
+ - `class Task(QObject)` с сигналами `progress(int, int)`, `finished(object)`, `failed(str)`, `cancelled()`; методы `cancel()`, `is_cancelled() -> bool`
+ - `run_task(fn: Callable[[Callable[[int, int], None], Callable[[], bool]], object], parent: QObject) -> Task` — `fn(progress, is_cancelled)` выполняется в `QThreadPool.globalInstance()`; сигналы приходят в GUI-поток; исключение → `failed(str(exc))`; `CancelledError` → `cancelled`
+ - В строке состояния `MainWindow` — `QProgressBar` и кнопка «Отмена» (ключи `task.cancel`, `task.running`), видимые во время задачи
+
+- [ ] **Step 1: Падающие тесты** (offscreen, `qtbot`-free: крутить `QApplication.processEvents()` до сигнала с таймаутом 5 с): результат приходит в `finished`; исключение — в `failed`; отмена — в `cancelled`; GUI-поток не блокируется (таймер в GUI-потоке тикает, пока задача спит 0,3 с).
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать** `ui/tasks.py`; перевести сканирование папки и BLAST на `run_task`.
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Commit** `git commit -m "ui: background tasks with progress and cancel"`
+
+### Task 12: Разбить `main_window.py`
+
+**Files:**
+- Create: `ui/read_view/__init__.py`, `ui/read_view/dialogs.py` (`SearchDialog`, `TrimDialog`), `ui/read_view/analysis_dock.py`, `ui/read_view/verify_dock.py`, `ui/read_view/batch_dock.py` (+ перенос `ui/batch_panel.py`), `ui/read_view/read_view.py` (`ReadView(QMainWindow)` — всё, что сейчас относится к одному риду: вью, миникарта, доки, правка, обрезка, экспорт, поиск)
+- Modify: `ui/main_window.py` — остаётся оболочка: меню «Файл/Вид/Справка», язык, тема, недавние файлы, `run()`
+- Test: существующий `tests/test_smoke_gui.py` (без изменения утверждений; правятся только пути monkeypatch)
+
+**Interfaces:**
+- Produces:
+ - `ReadView.open_path(path) -> bool`, `ReadView.refresh_read()`, `ReadView.current_model() -> EditableRead | None`, сигнал `ReadView.read_edited(Path)` (испускается после правки, undo/redo, обрезки и сохранения сессии)
+ - `MainWindow.read_view: ReadView`; публичные методы `MainWindow`, которые используют тесты (`open_path`, `apply_base_*`, `undo_read`, …), остаются как делегаты, чтобы `test_smoke_gui.py` не менялся по смыслу
+
+- [ ] **Step 1: Перенести код** без изменения поведения, по одному блоку (диалоги → доки → ReadView), после каждого — `python -m pytest tests/test_smoke_gui.py -q` → PASS.
+- [ ] **Step 2: Проверить размер:** ни один файл в `ui/` не длиннее ~450 строк (`wc -l ui/*.py ui/read_view/*.py`).
+- [ ] **Step 3: Запустить всё** `python -m pytest -q` → PASS.
+- [ ] **Step 4: Commit** `git commit -m "ui: split main window into read_view modules (no behaviour change)"`
+
+### Task 13: Тема, иконки, палитра статусов
+
+**Files:**
+- Create: `ui/style.qss` (светлая), `ui/style_dark.qss`, `ui/icons/*.svg` + `ui/icons/LICENSE` (набор Lucide, ISC/MIT — проверить лицензию при скачивании; иначе Tabler Icons, MIT), `ui/status.py`
+- Modify: `ui/theme.py` (загрузка QSS), `ui/read_view/*` (иконки на тулбаре, растяжение колонок, тултипы длинных ячеек), `ui/chrom_view.py` (цвет полос качества по Q), `ab1kit.spec` (включить `ui/style*.qss`, `ui/icons`), `pyproject.toml` (`package-data` для `ui`)
+- Test: `tests/test_smoke_gui.py`
+
+**Interfaces:**
+- Produces (`ui/status.py`):
+ - `status_color(verdict: str, theme: str) -> QColor` — из `chrom_core.clone_report.STATUS_HEX` (Task 9)
+ - `quality_color(q: int, theme: str) -> QColor` — `<20` красный, `20–29` жёлтый, `≥30` зелёный (те же пороги, что миникарта)
+ - `status_chip(verdict: str, text: str) -> QLabel` — скруглённая плашка
+ - `icon(name: str) -> QIcon`
+
+- [ ] **Step 1: Падающие тесты:** у каждого действия тулбара есть иконка; `quality_color(15) != quality_color(35)`; таблицы растягивают последнюю колонку и имеют тултип с полным текстом примечания; QSS применяется (`app.styleSheet()` не пуст) в обеих темах.
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.** Пакет иконок: только используемые (~20 файлов).
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS; сделать скриншоты обеих тем через `scripts/render_preview.py` и посмотреть глазами.
+- [ ] **Step 5: Commit** `git commit -m "ui: QSS theme, icons, unified status palette, coloured quality bars"`
+
+### Task 14: Строка-сводка и матрица клонов
+
+**Files:**
+- Create: `ui/construct/__init__.py`, `ui/construct/summary_bar.py`, `ui/construct/matrix.py`
+- Test: `tests/test_construct_ui.py`
+
+**Interfaces:**
+- Consumes: `Construct`, `Region`, `CloneReport` (Tasks 4, 7), `status_color`, `status_chip` (Task 13)
+- Produces:
+ - `SummaryBar(QWidget)`: `set_data(construct: Construct, region: Region | None, reports: list[CloneReport])`; сигнал `filter_changed(set[str])` — множество показываемых вердиктов (клик по счётчику переключает)
+ - `CloneMatrix(QWidget)` (собственная отрисовка `paintEvent`): `set_data(construct, region, reports)`, `set_filter(verdicts: set[str])`; дорожка референса (фичи прямоугольниками с подписью, рамка региона), строка на клон: покрытие (две цепи — насыщенный, одна — светлый, Q<20/нет — фон), метки расхождений цветом статуса, плашка вердикта справа. Колёсико — масштаб общей оси, перетаскивание — сдвиг. Протяжка по дорожке референса с зажатым Shift — задать регион.
+ - Сигналы: `difference_clicked(str, int)` (клон, `ref_pos`), `clone_clicked(str)`, `region_changed(object)` (`Region`)
+ - `CloneMatrix.hit_test(pos: QPoint) -> tuple[str, int] | None` — для тестов и кликов
+
+- [ ] **Step 1: Падающие тесты** (offscreen, данные — `verify_all` на наборе из Task 7): сводка показывает счётчики по вердиктам; фильтр скрывает строки; `hit_test` по координате метки возвращает `(clone, ref_pos)`; Shift-протяжка испускает `region_changed`; виджет рендерится в `QImage` без исключений в обеих темах; 12 строк помещаются в 600 px по высоте.
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Запустить** → PASS; отрендерить PNG матрицы и проверить глазами против макета B.
+- [ ] **Step 5: Commit** `git commit -m "ui: construct summary bar and clone matrix"`
+
+### Task 15: Панель деталей расхождения
+
+**Files:**
+- Create: `ui/construct/detail_pane.py`
+- Test: `tests/test_construct_ui.py`
+
+**Interfaces:**
+- Consumes: `CloneReport`, `ReadResult`, `load_read` (Task 7), `ChromatogramWidget` (`ui/chrom_view.py`: `set_chromatogram`, `set_reference`, `goto_base`, `set_highlights`)
+- Produces:
+ - `DetailPane(QWidget)`: `show_clone(report: CloneReport, construct: Construct)`, `show_position(ref_pos: int)`; слева — стопка `ChromatogramWidget` по одному на рид клона (компактная высота, общий масштаб), каждый центрирован на базе, соответствующей `ref_pos` (через `alignment.ref_to_read` и ориентацию), с подсветкой; над стопкой — строка референса ±10 п.н.; справа — таблица расхождений клона (позиция 1-based, `ref→read`, эффект через `tr`, за/против, статус); выбор строки → `show_position`
+ - Сигнал `open_read(Path)` — по двойному клику на хроматограмму
+
+- [ ] **Step 1: Падающие тесты:** число хроматограмм = числу ридов клона с выравниванием; после `show_position(P)` центральная база каждого вью — та, что выровнена на `P`; для рида на минус-цепи — тоже; выбор строки таблицы вызывает `show_position`; двойной клик испускает `open_read`.
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Запустить** → PASS.
+- [ ] **Step 5: Commit** `git commit -m "ui: difference detail pane with stacked chromatograms"`
+
+### Task 16: Экран «Проверка конструкта», группировка, связка с просмотрщиком
+
+**Files:**
+- Create: `ui/construct/grouping_dialog.py`, `ui/construct/workspace.py`
+- Modify: `ui/main_window.py` (вкладки «Проверка конструкта» / «Просмотр рида», меню «Проект»: новый/открыть/сохранить, экспорт отчёта), `i18n.py`, `translations_en.py`
+- Test: `tests/test_construct_ui.py`, `tests/test_smoke_gui.py`
+
+**Interfaces:**
+- Consumes: всё из Tasks 4–15; `run_task` (Task 11); `ReadView.read_edited` (Task 12)
+- Produces:
+ - `GroupingDialog(QDialog)`: `__init__(grouping: Grouping, pattern: str, parent)`, `result_grouping() -> Grouping`, `result_pattern() -> str`; таблица «файл / клон / праймер» с правкой; поле шаблона с предпросмотром (невалидный regex — красная рамка, кнопка OK неактивна); строки «без клона» выделены
+ - `ConstructWorkspace(QWidget)`: `set_construct(path)`, `add_reads(paths)`, `set_region(region)`, `load_project(path)`, `save_project(path)`, `export_report(path)`; пустое состояние с двумя зонами перетаскивания (конструкт; папка/файлы ридов); любое изменение (конструкт, риды, группировка, регион, параметры, `read_edited`) → перезапуск `verify_all` через `run_task` с отменой предыдущего; сигнал `open_read(Path)`
+ - Поля параметров `min_q`, `het_ratio`, `mixed_min_positions` и галочка «кольцевая» — в строке над матрицей; сохраняются в проекте
+ - `MainWindow`: вкладка «Проверка конструкта» первая и активна при старте без аргументов; `open_read` → переключение на «Просмотр рида» + `open_path`; `ab1kit file.ab1proj` открывает проект
+
+- [ ] **Step 1: Падающие тесты:** перетаскивание COI `.gb` и 8 реальных ридов → после завершения задачи 4 строки в матрице и вердикты как в Task 7 (для неизменённого референса — все `ok` или `incomplete`); правка рида в «Просмотре рида» (замена базы в позиции расхождения) → пересчёт и смена вердикта; сохранение/открытие проекта восстанавливает регион и группировку; битый `.ab1proj` → сообщение и пустое рабочее пространство без исключения; недопустимый регион → сообщение, проверка не запускается; отмена пересчёта оставляет прежние вердикты в матрице; `GroupingDialog` с невалидным regex блокирует OK; пустое состояние видно без данных; все новые ключи есть в RU и EN.
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS.
+- [ ] **Step 5: Ручная проверка:** запустить `ab1kit`, пройти сценарий из критерия успеха спецификации (перетащить конструкт и папку, задать регион, открыть расхождение, перейти в рид, поправить, вернуться) в светлой и тёмной теме, RU и EN. Обновить скриншоты `docs/screenshots/` (`scripts/render_preview.py`), добавить скриншот экрана проверки.
+- [ ] **Step 6: Commit** `git commit -m "ui: construct verification workspace with grouping and project files"`
+
+---
+
+## Фаза 4 — CLI и выпуск
+
+### Task 17: Подкоманды CLI, документация, версия
+
+**Files:**
+- Modify: `chrom_cli.py`, `README.md`, `CHANGELOG.md`, `pyproject.toml` (`version = "1.1.0"`)
+- Test: `tests/test_cli.py`
+
+**Interfaces:**
+- Consumes: `load_construct`, `group_reads`, `verify_all`, `VerifyParams`, `clones_csv`/`differences_csv`/`clone_report_xlsx`, `batch.scan_folder`/`summary_csv`/`batch_export`
+- Produces:
+ - `main(argv) -> int`: если `argv[0] in {"verify-clones", "qc", "export"}` — `argparse`-подкоманда, иначе GUI (как сейчас)
+ - `verify-clones --construct FILE [--region A-B] [--linear] [--pattern REGEX] [--min-q N] [--het-ratio X] READS_DIR -o REPORT` — `A-B` 1-based включительно → `Region(A-1, B)`; `.xlsx` → `clone_report_xlsx`, `.csv` → `clones_csv` в `REPORT` и `differences_csv` в `REPORT` с суффиксом `_differences.csv`; печать короткой таблицы клонов в stdout; код возврата 0 — есть хотя бы один `ok`, 1 — нет, 2 — ошибка входных данных (сообщение в stderr)
+ - `qc READS_DIR [-o CSV]` — `scan_folder` + `summary_csv` (stdout без `-o`)
+ - `export READS_DIR --trim [--fasta|--fastq] -o DIR` — `batch_export`
+
+- [ ] **Step 1: Падающие тесты:** `verify-clones` на `tmp_path / "Данные клоны"` с копиями реальных ридов и COI → код 0, XLSX с листами `Clones`/`Differences`; на изменённом референсе — строка с `errors`; без ридов/битый конструкт → 2; `qc` печатает CSV; `export` создаёт файлы; `main([])` и `main(["x.ab1"])` по-прежнему вызывают GUI (`monkeypatch` на `ui.main_window.run`).
+- [ ] **Step 2: Запустить** → FAIL.
+- [ ] **Step 3: Реализовать.**
+- [ ] **Step 4: Документация:** в README — раздел «Clone verification» (сценарий, формат имён, шаблон, вердикты, CLI-пример) и новый скриншот; CHANGELOG — `[1.1.0]` с Added/Changed/Fixed по задачам 2–17.
+- [ ] **Step 5: Запустить всё** `python -m pytest -q` → PASS; собрать exe `pyinstaller ab1kit.spec` и запустить — экран проверки открывается, иконки и тема на месте.
+- [ ] **Step 6: Commit** `git commit -m "CLI subcommands verify-clones/qc/export; docs and v1.1.0"`
diff --git a/docs/superpowers/specs/2026-10-25-clone-verification-design.md b/docs/superpowers/specs/2026-10-25-clone-verification-design.md
new file mode 100644
index 0000000..2443beb
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-25-clone-verification-design.md
@@ -0,0 +1,275 @@
+# Проверка клонов, точность анализа пиков и новый интерфейс — дизайн
+
+Дата: 2026-10-25 · Статус: согласован в обсуждении, ждёт ревью спецификации
+
+## 1. Цель и сценарий
+
+Главный сценарий ab1kit после этой работы — **проверка клонов одного
+конструкта**: в лаборатории отобрали 4–12 колоний, каждую отсеквенировали
+1–4 праймерами; нужно быстро понять, какой клон правильный, а если нет —
+где и какая ошибка. Остальные сценарии (генотипирование ожидаемых SNP, поиск
+мутаций, просмотр/экспорт одного рида, батч-QC) сохраняются как есть.
+
+Пользователи — биологи, не биоинформатики. Платформа — Windows (как сейчас),
+лицензия MIT.
+
+**Критерий успеха:** пользователь перетаскивает файл конструкта и папку с
+ридами, задаёт регион проверки и за минуту видит по каждому клону вердикт,
+а по каждому расхождению — хроматограммы всех ридов в этой позиции.
+
+### Принятые решения
+
+| Вопрос | Решение |
+| --- | --- |
+| Главный сценарий | Проверка клонов (B); остальные сохраняются |
+| Формат конструкта | Основной — FASTA (без аннотаций); также GenBank и SnapGene `.dna`; Benchling — нет |
+| Размер сессии | Один конструкт, 4–12 клонов × 1–4 рида |
+| Группировка ридов в клоны | Автоматически по имени файла + ручная правка; свой шаблон-regex в настройках |
+| Что проверяется | Регион, заданный пользователем; вне региона расхождения видны, но на вердикт не влияют |
+| Архитектура UI | PySide6 Widgets, обновлённый стиль; без переписывания на QML/веб |
+| Раскладка главного экрана | «Матрица»: все клоны строками на общей оси с референсом |
+
+## 2. Ядро (`chrom_core/`, без зависимостей от Qt, покрыто pytest)
+
+### 2.1 `reference.py` — загрузка конструкта
+
+- `load_construct(path) -> Construct`; формат по расширению (`.fa/.fasta/.fna/.txt`,
+ `.gb/.gbk/.genbank`, `.dna`) с проверкой содержимого; чтение через
+ `Bio.SeqIO` (`fasta`, `genbank`, `snapgene`). Берётся первая запись.
+- `Construct`: `name`, `seq` (верхний регистр, IUPAC), `circular: bool`,
+ `features: list[Feature]` (может быть пустым).
+- `Feature`: `name`, `kind` (`CDS`, `promoter`, `primer_bind`, …), `start`,
+ `end` (0-based, полуоткрытый), `strand` (+1/−1); фичи через начало
+ координат поддерживаются (`start > end` означает обёртку).
+- `circular`: из файла для GenBank/SnapGene (topology); для FASTA — `True` по
+ умолчанию, переключается пользователем.
+- Существующий `align.parse_sequence` остаётся для вставки текстом
+ (результат — `Construct` без фич, `circular=True`).
+
+### 2.2 `align.py` — кольцевой референс
+
+- Новая функция `align_to_construct(read, construct) -> ReadAlignment`.
+ Для линейного — вызывает существующий `align_read`. Для кольцевого —
+ выравнивает на `seq + seq[:len(read) + 50]`, затем приводит все
+ координаты (`ref_start/ref_end`, `ref_to_read`, `differences`) по модулю
+ длины. Для обёрнутого выравнивания `ref_end` может быть меньше
+ `ref_start`; добавляется метод `covers(pos)` и `covered_positions()`.
+- `align_read` и его тесты не меняются. `MAX_REFERENCE_LENGTH` (100 т.п.н.)
+ действует на исходную длину конструкта.
+
+### 2.3 `peaks.py` — анализ вторичных пиков (выносится из `snps.py`)
+
+- `channel_baseline(chrom, i, span=20) -> dict[base, float]` — медиана
+ высоты пиков каждого канала среди ~20 ближайших вызванных баз этого
+ канала; нормирует разную яркость красителей.
+- `peak_window(chrom, i)` — половина расстояния до соседних пиков (не
+ меньше 2 точек) вместо фиксированных ±5.
+- `secondary_peak(chrom, i) -> SecondaryPeak(base, ratio, primary_base)` —
+ на **любой** позиции: нормированная высота сильнейшего канала вне
+ вызванной базы, делённая на нормированную высоту вызванной.
+- `snps.py` и `verify.py` переходят на `peaks.py`; публичные сигнатуры
+ `call_snps`, `verify_expected`, `peak_heights` сохраняются (последняя
+ остаётся как обёртка). Добавляется `scan_secondary_peaks(chrom,
+ alignment, min_ratio)` — поиск гетерозигот/смесей по всему выровненному
+ риду, включая позиции, где вызвана референсная база.
+
+### 2.4 `clones.py` — проверка клонов
+
+**Группировка.** `group_reads(paths, pattern=None) -> Grouping`.
+- По умолчанию: имя файла без расширения делится по `_`, `-`, `.`, пробелу;
+ метка праймера — последний токен, совпавший со словарём (`F`, `R`, `FW`,
+ `REV`, `FWD`, `T7`, `T7term`, `SP6`, `T3`, `M13F`, `M13R`, `pUC-F`, … —
+ регистр не важен; словарь расширяется в настройках); если совпадений со
+ словарём нет — последний токен. Клон — исходная подстрока имени **до**
+ токена праймера (с сохранением исходных разделителей), из которой убраны
+ хвостовые разделители, номер порядка праймера (`^\d$` непосредственно
+ перед праймером) и ведущая позиция лунки планшета (`^[A-H]\d{1,2}$`).
+ Если после этого клон пустой — файл попадает в «без клона».
+ Пример: `Achl_ACHLO006-09_1_F` → клон `Achl_ACHLO006-09`, праймер `F`.
+- Свой шаблон — regex с именованными группами `clone` и `primer`.
+- Нераспознанные файлы → группа «без клона» (в проверку не идут, пока
+ пользователь их не назначит).
+- `Grouping` сериализуется вместе с ручными правками.
+
+**Проверка.** `verify_clone(construct, region, reads, params) -> CloneReport`.
+- `region`: `(start, end)` в координатах конструкта, обёртка через начало
+ допустима для кольцевого.
+- Для каждого рида: текущая версия с учётом сессии правок
+ (`<файл>.ab1kit.json`), выравнивание `align_to_construct`. Рид с
+ `alternatives > 1` помечается «неоднозначно» и не участвует в вердикте.
+- **Покрытие** по позиции региона: число ридов, покрывающих её с Q ≥ `min_q`
+ (по умолчанию 20), отдельно по цепям.
+- **Расхождения** (`CloneDifference`): замены, вставки, делеции из
+ выравниваний всех ридов, объединённые по позиции и типу; для каждого —
+ `support` (ридов с этим расхождением и Q ≥ `min_q`), `against` (ридов,
+ видящих референс с Q ≥ `min_q`), статус `confirmed` (support ≥ 1,
+ against = 0) / `disputed` (support ≥ 1, against ≥ 1) / `low_quality`
+ (support = 0, есть только низкокачественные).
+- **Эффект** (если в конструкте есть CDS, перекрывающая позицию): для
+ замены — синонимичная / миссенс (`D45N`) / стоп; для вставки/делеции —
+ «сдвиг рамки» или «вставка/делеция в рамке». Без аннотаций — пусто.
+- **Смешанность:** позиции региона, где `secondary_peak.ratio ≥ het_ratio`
+ (по умолчанию 0.40) хотя бы в одном риде с Q ≥ `min_q`.
+
+**Вердикт** (первое сработавшее правило):
+1. `no_data` — ни один рид не выровнялся (или все неоднозначны);
+2. `mixed` — ≥ `mixed_min_positions` (по умолчанию 3) позиций со вторым пиком;
+3. `errors` — есть `confirmed` или `disputed` расхождение в регионе;
+4. `incomplete` — есть позиции региона без покрытия с Q ≥ `min_q`;
+5. `ok`.
+
+Дополнительно в отчёте: доля региона, покрытая обеими цепями.
+
+Пороги (`min_q`, `het_ratio`, `mixed_min_positions`) — в `VerifyParams`,
+значения по умолчанию выше, настраиваются в интерфейсе.
+
+### 2.5 Проект и отчёты
+
+- `project.py`: файл `.ab1proj` (JSON) — путь к конструкту (относительный,
+ если рядом), `circular`, регион, список ридов с привязкой клон/праймер,
+ ручные правки группировки, `VerifyParams`. Версионируется полем `version`.
+- Отчёт `clone_report_csv` / `clone_report_xlsx` (openpyxl): лист «Клоны»
+ (клон, вердикт, покрытие региона %, обе цепи %, расхождений, риды) и лист
+ «Расхождения» (клон, позиция, тип, ref→read, эффект, за/против, статус,
+ примечание).
+
+### 2.6 Производительность
+
+Сценарий 12 клонов × 4 рида на конструкте 10 т.п.н. замеряется тестом;
+порог фиксируется по замеру с запасом ×2. В интерфейсе всё выполняется в
+фоне, поэтому длительность влияет на ожидание, но не на отзывчивость.
+
+## 3. Интерфейс (`ui/`)
+
+### 3.1 Структура окна
+
+- Две вкладки-режима: **«Проверка конструкта»** (новый главный экран) и
+ **«Просмотр рида»** (нынешний просмотрщик с панелями анализа, проверки
+ SNP и батч-QC).
+- Двойной клик по риду/расхождению → рид открывается в «Просмотре рида»;
+ после правки проверка пересчитывается.
+
+### 3.2 Экран «Проверка конструкта» (раскладка «матрица»)
+
+1. **Строка-сводка:** имя конструкта, длина, кольцевая/линейная, регион;
+ счётчики клонов по вердиктам — каждый работает как фильтр.
+2. **Матрица:** сверху дорожка референса (фичи, если есть; рамка региона —
+ задаётся протяжкой мышью или вводом координат). Ниже по строке на клон:
+ покрытие (цвет — одна/две цепи, бледный — Q < 20), метки расхождений
+ (цвет по статусу), плашка вердикта. Колёсико масштабирует общую ось.
+3. **Детали:** по клику на метку — хроматограммы всех ридов клона в этой
+ позиции друг под другом, референс сверху; рядом таблица расхождений
+ клона (позиция, ref→read, эффект, за/против, статус).
+4. **Пустое состояние:** подсказки «перетащите файл конструкта» и «…папку с
+ ридами» вместо пустых панелей.
+
+### 3.3 Окно группировки
+
+Таблица «файл → клон, праймер» с правкой ячеек; поле своего шаблона с
+мгновенным предпросмотром; группа «без клона» выделена.
+
+### 3.4 Внешний вид
+
+- Единая QSS-тема поверх Fusion (светлая/тёмная), общая палитра статусов:
+ зелёный — подходит, красный — ошибки, жёлтый — неполное покрытие,
+ фиолетовый — смесь, серый — нет данных; легенда в строке-сводке.
+- Иконки на панели инструментов: набор SVG под MIT/Apache-2.0 в
+ `ui/icons/` с файлом лицензии.
+- Полоски качества раскрашены по Q (с легендой).
+- Колонки таблиц подстраиваются под содержимое; полный текст длинных ячеек
+ во всплывающей подсказке.
+- Исправления найденных ошибок: английское примечание в русском UI
+ (`verify.py` отдаёт непереведённую строку); «Ср. Q = 0» в батч-режиме
+ для ридов без данных качества → «—».
+
+### 3.5 Внутреннее устройство UI
+
+- `main_window.py` (1163 строки) разбивается: оболочка окна и меню;
+ `ui/read_view/` — существующие панели (диалоги поиска/обрезки, анализ,
+ проверка SNP, батч), каждая в своём файле; `ui/construct/` — сводка,
+ матрица, панель деталей, диалог группировки.
+- Фоновые задачи: обёртка над `QThreadPool`/`QRunnable` с сигналами
+ прогресса, результата, ошибки и флагом отмены. Через неё — проверка
+ клонов, сканирование папки, отправка в BLAST.
+- Все новые строки — через `_()` с переводами в `translations_en.py`.
+
+## 4. CLI (`chrom_cli.py`)
+
+- Без аргументов или с путём — запуск GUI (как сейчас).
+- `ab1kit verify-clones --construct FILE [--region A-B] [--linear]
+ [--pattern REGEX] READS_DIR -o REPORT.(csv|xlsx)`; код возврата 0, если
+ есть хотя бы один клон `ok`, иначе 1; 2 — ошибка входных данных.
+- `ab1kit qc READS_DIR [-o CSV]` и `ab1kit export READS_DIR --trim
+ [--fasta|--fastq] -o DIR` — над существующим `batch.py`.
+- Реализация на `argparse`; подкоманды распознаются по первому аргументу,
+ путь к файлу/папке по-прежнему открывает GUI.
+
+## 5. Обработка ошибок
+
+- Нечитаемый `.ab1` или невыровнявшийся рид не прерывает проверку: рид
+ помечается в таблице с причиной, остальные обрабатываются.
+- Пустой регион, регион вне конструкта, конструкт больше лимита — понятное
+ сообщение до запуска.
+- Неоднозначное выравнивание (повторы) — рид помечен «неоднозначно», в
+ вердикт не идёт.
+- Отмена фоновой задачи сохраняет предыдущий результат.
+- Битый или устаревший `.ab1proj` — сообщение и открытие без проекта.
+
+## 6. Тестовые данные и тесты
+
+### 6.1 Данные
+
+- **Реальные риды:** sangeranalyseR (MIT), `inst/extdata/Allolobophora_chlorotica`
+ — 4 образца × F/R = 8 файлов (~1,7 МБ) в `tests/data/real/` с README
+ (источник, коммит, лицензия).
+- **Референсы с NCBI:** запись COI *A. chlorotica*, соответствующая
+ образцам (accession подбирается BLAST-ом ридов и фиксируется в README), и
+ pUC19 (L09137, GenBank, кольцевая, с аннотациями). Скачиваются один раз
+ `scripts/fetch_test_data.py` (E-utilities) и коммитятся; тесты сеть не
+ используют.
+- Наборы gear-genomics (GPL-3.0) не используются.
+
+### 6.2 Контролируемая истина
+
+- **Ошибки клона:** в копию референса вносится известная замена /
+ вставка / делеция; реальный рид даёт расхождение в известной позиции.
+- **Смесь:** полусинтетическая копия реального рида, в которой под
+ выбранной позицией в другой канал добавлен пик формы соседнего реального
+ пика заданной высоты (30% и 50%). Генерируется в фикстуре, явно помечена.
+- **Набор клонов:** «подходит» (реальные риды, неизменённый референс),
+ «ошибка» (изменённый референс), «неполный» (обрезанный рид),
+ «смесь» (полусинтетика), «нет данных» (рид другого гена).
+
+### 6.3 Проверки
+
+- Каждая внесённая ошибка найдена с верными позицией, типом, `support`.
+- Ноль ложных расхождений в регионе на неизменённом референсе при Q ≥ 20.
+- Второй пик 30% — не смесь, 50% — смесь (при `het_ratio` 0.40).
+- Кольцевое выравнивание: рид через начало координат повёрнутого pUC19
+ даёт непрерывное покрытие и верные координаты.
+- Группировка имён sangeranalyseR и синтетических имён (`cl3_T7`, `A05_K3-F`,
+ `clone12.rev`); свой regex.
+- Чтение FASTA/GenBank/SnapGene. BioPython не умеет писать `.dna`, поэтому
+ берётся образец из `Tests/SnapGene/` набора тестов BioPython (Biopython
+ License, как и нынешние `examples/`), с указанием источника в README.
+- Регрессия: существующие тесты `snps`/`verify` проходят после выноса в
+ `peaks.py`; плюс прогон на реальных ридах.
+- Производительность 12 × 4 на 10 т.п.н. (порог по замеру).
+- GUI smoke в offscreen: экран проверки открывается, проект загружается,
+ матрица рисуется, фоновая задача завершается.
+
+## 7. Порядок работ
+
+Каждый шаг оставляет программу рабочей:
+
+1. Тестовые данные; `peaks.py`; исправления двух ошибок UI.
+2. Ядро: `reference.py`, кольцевое выравнивание, `clones.py`, `.ab1proj`,
+ отчёты.
+3. Интерфейс: тема и иконки, разбиение `main_window.py`, фоновые задачи,
+ экран «Проверка конструкта», окно группировки.
+4. CLI.
+
+## 8. Вне рамок
+
+Планшеты на 96 ридов разных конструктов; автопоиск ORF в FASTA; `.scf`;
+экспорт из Benchling; mafft/MSA; сборка контигов.
diff --git a/i18n.py b/i18n.py
index 3eb5254..dd9acde 100644
--- a/i18n.py
+++ b/i18n.py
@@ -79,6 +79,12 @@
"verify.ref_mismatch": "ref не совпал",
"verify.low_quality": "низкое качество",
"verify.unsupported": "не поддерживается",
+ "verify.note.only_snv": "поддерживаются только замены одного нуклеотида",
+ "verify.note.pos_outside_ref": "позиция вне референса",
+ "verify.note.ref_differs": "ref-основание из таблицы не совпадает с референсом",
+ "verify.note.deleted_in_read": "делеция в риде",
+ "verify.note.outside_coverage": "вне покрытия рида",
+ "verify.note.low_q": "Q{q} < {min_q}",
"verify.summary": "{n} позиций: {confirmed} подтверждено, {het} гетерозигот, {notfound} не подтверждено, {nodata} без данных",
"verify.need_reference": "Сначала загрузите референс и выровняйте чтение",
"verify.save_csv": "Сохранить CSV…",
@@ -88,6 +94,8 @@
"tb.zoom_out": "Отдалить",
"tb.fit": "Весь рид",
"tb.reset": "Сброс вида",
+ "tb.quality_legend": "Качество Q:",
+ "tb.quality_legend.tip": "Цвет полос качества: красный Q<20, жёлтый 20–29, зелёный ≥30",
"goto.title": "Перейти к базе",
"goto.label": "Номер базы",
"search.title": "Поиск подпоследовательности",
@@ -148,10 +156,107 @@
"batch.col.q20": "Q≥20",
"batch.col.trim": "После обрезки",
"batch.col.status": "Статус",
+ "task.cancel": "Отмена",
+ "task.running": "Выполняется…",
+ "task.cancelled": "Операция отменена",
"batch.scan_done": "Просканировано файлов: {n}",
"batch.exported": "Экспортировано: {n}, ошибок: {err}",
"batch.export_dir": "Куда экспортировать",
"err.open_failed": "Не удалось открыть файл",
+ "construct.bp": "{n} п.н.",
+ "construct.circular": "кольцевая",
+ "construct.linear": "линейная",
+ "construct.region": "регион {start}–{end}",
+ "construct.region_all": "регион: весь конструкт",
+ "construct.count.ok": "Подходят: {n}",
+ "construct.count.errors": "Ошибки: {n}",
+ "construct.count.incomplete": "Неполные: {n}",
+ "construct.count.mixed": "Смесь?: {n}",
+ "construct.count.no_data": "Нет данных: {n}",
+ "construct.filter_tip": "Показать или скрыть клоны с этим вердиктом",
+ "matrix.reference": "референс",
+ "matrix.chip.ok": "подходит",
+ "matrix.chip.errors": "{n} расх.",
+ "matrix.chip.incomplete": "{pct}%",
+ "matrix.chip.mixed": "смесь?",
+ "matrix.chip.no_data": "нет данных",
+ "matrix.legend.cov_both": "две цепи",
+ "matrix.legend.cov_one": "одна цепь",
+ "matrix.legend.cov_none": "нет (Q<20)",
+ "matrix.legend.confirmed": "подтверждено",
+ "matrix.legend.disputed": "спорно",
+ "matrix.legend.low_quality": "низкое Q",
+ "matrix.legend.mixed": "двойной пик",
+ "matrix.legend.unchecked": "не проверяется",
+ "matrix.legend.outside": "вне региона",
+ "matrix.empty": "Нет клонов для показа",
+ "matrix.hint": "Колёсико — масштаб, перетаскивание — сдвиг, Shift+протяжка по референсу — задать регион",
+ "detail.col.pos": "Позиция",
+ "detail.col.change": "Реф.→чтение",
+ "detail.col.effect": "Эффект",
+ "detail.col.support": "За / против",
+ "detail.col.status": "Статус",
+ "effect.synonymous": "синонимичная",
+ "effect.frameshift": "сдвиг рамки",
+ "effect.inframe_ins": "вставка в рамке",
+ "effect.inframe_del": "делеция в рамке",
+ "detail.err.no_alignment": "нет выравнивания",
+ "detail.ambiguous": "неоднозначно — не учитывается в вердикте",
+ "detail.uncovered": "позиция не покрыта",
+ "tab.construct": "Проверка конструкта",
+ "tab.read": "Просмотр рида",
+ "menu.project": "Про&ект",
+ "menu.project.new": "&Новый",
+ "menu.project.open": "&Открыть проект…",
+ "menu.project.save": "&Сохранить проект",
+ "menu.project.save_as": "Сохранить проект &как…",
+ "menu.project.export": "&Экспорт отчёта…",
+ "grouping.title": "Группировка ридов по клонам",
+ "grouping.pattern": "Шаблон имени:",
+ "grouping.pattern_placeholder": "пусто — по имени файла автоматически",
+ "grouping.pattern_tip": "Регулярное выражение по имени файла без расширения с группами (?P…) и (?P…)",
+ "grouping.col.file": "Файл",
+ "grouping.col.clone": "Клон",
+ "grouping.col.primer": "Праймер",
+ "grouping.no_clone": "Без клона — рид не участвует в проверке",
+ "grouping.summary": "Клонов: {clones} · ридов: {reads} · без клона: {unassigned}",
+ "workspace.empty_title": "Проверка клонов",
+ "workspace.empty_hint": "Укажите конструкт и риды — вердикт по каждому клону появится сразу",
+ "workspace.drop_construct": "Перетащите файл конструкта (FASTA, GenBank, SnapGene)",
+ "workspace.drop_reads": "…и папку или файлы ридов .ab1",
+ "workspace.click_to_choose": "или нажмите, чтобы выбрать",
+ "workspace.choose_files": "Выбрать отдельные файлы…",
+ "workspace.reads_loaded": "ридов: {n}",
+ "workspace.region_from": "Регион:",
+ "workspace.region_to": "–",
+ "workspace.whole": "Весь конструкт",
+ "workspace.whole_tip": "Проверять всю последовательность конструкта",
+ "workspace.circular": "Кольцевая",
+ "workspace.circular_tip": "Конструкт кольцевой: риды и регион могут переходить через начало",
+ "workspace.min_q": "Мин. Q",
+ "workspace.min_q_tip": "Базы с качеством ниже порога не подтверждают и не опровергают расхождения",
+ "workspace.het_ratio": "Двойной пик",
+ "workspace.het_ratio_tip": "Доля второго пика от основного, начиная с которой позиция считается смешанной",
+ "workspace.mixed_min": "Смесь от",
+ "workspace.mixed_min_tip": "Сколько смешанных позиций нужно, чтобы клон получил вердикт «смесь?»",
+ "workspace.grouping": "Группировка…",
+ "workspace.add_reads": "Добавить риды…",
+ "workspace.construct_button": "Конструкт…",
+ "workspace.construct_failed": "Не удалось открыть конструкт",
+ "workspace.construct_too_long": "Конструкт слишком длинный: {n} п.н. (предел {limit})",
+ "workspace.no_new_reads": "Новых файлов .ab1 не найдено",
+ "workspace.region_invalid": "Недопустимый регион",
+ "workspace.region_invalid_text": "Регион {start}–{end} нельзя проверить: {reason}",
+ "workspace.verify_failed": "Ошибка проверки клонов",
+ "workspace.verified": "Проверено клонов: {n}",
+ "workspace.project_failed": "Не удалось открыть проект",
+ "workspace.project_loaded": "Проект открыт: {name}",
+ "workspace.project_save_failed": "Не удалось сохранить проект",
+ "workspace.need_construct": "Сначала укажите файл конструкта",
+ "workspace.project_saved": "Проект сохранён: {name}",
+ "workspace.export_failed": "Не удалось сохранить отчёт",
+ "workspace.no_results": "Пока нет результатов проверки",
+ "workspace.exported": "Отчёт сохранён: {name}",
}
_lang = "ru"
diff --git a/pyproject.toml b/pyproject.toml
index 3ad6ba9..a01357e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ab1kit"
-version = "1.0.0"
+version = "1.1.0"
description = "Open-source desktop tool for viewing, editing and analyzing Sanger sequencing chromatograms (.ab1)"
readme = "README.md"
license = { file = "LICENSE" }
@@ -22,8 +22,14 @@ dev = ["pytest>=8"]
ab1kit = "chrom_cli:main"
[tool.setuptools]
-packages = ["chrom_core", "ui"]
+packages = ["chrom_core", "ui", "ui.read_view", "ui.construct"]
py-modules = ["chrom_cli", "i18n", "translations_en"]
+[tool.setuptools.package-data]
+ui = ["style.qss", "style_dark.qss", "icons/*.svg", "icons/LICENSE", "icons/README.md", "icons/app/*"]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
+pythonpath = ["tests"]
+markers = ["perf: timing-sensitive tests, run with -m perf"]
+addopts = "-m 'not perf'"
diff --git a/scripts/fetch_test_data.py b/scripts/fetch_test_data.py
new file mode 100644
index 0000000..0c2d26b
--- /dev/null
+++ b/scripts/fetch_test_data.py
@@ -0,0 +1,87 @@
+"""One-time download of the real-data test corpus into ``tests/data``.
+
+Usage::
+
+ python scripts/fetch_test_data.py --coi ACC [--coi ACC ...] [--force]
+
+Uses only ``urllib``. Idempotent: files that already exist are skipped
+unless ``--force`` is given. Tests never touch the network; the downloaded
+files are committed to the repository.
+"""
+
+from __future__ import annotations
+
+import argparse
+import time
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+DATA = Path(__file__).resolve().parent.parent / "tests" / "data"
+
+SANGER_COMMIT = "4c5ebc9298a873c209819f12b7dfd85e126f1406"
+BIOPYTHON_COMMIT = "2fbe8755bb44731e60b7bd1e2cc55cf34d94e15b"
+
+SANGER_BASE = (
+ "https://raw.githubusercontent.com/roblanf/sangeranalyseR/"
+ f"{SANGER_COMMIT}/inst/extdata/Allolobophora_chlorotica/ACHLO/"
+)
+SNAPGENE_URL = (
+ "https://raw.githubusercontent.com/biopython/biopython/"
+ f"{BIOPYTHON_COMMIT}/Tests/SnapGene/sample-d.dna"
+)
+EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
+
+READS = [
+ f"Achl_{sample}_{suffix}.ab1"
+ for sample in ("ACHLO006-09", "ACHLO007-09", "ACHLO040-09", "ACHLO041-09")
+ for suffix in ("1_F", "2_R")
+]
+
+_last_ncbi_request = 0.0
+
+
+def _download(url: str) -> bytes:
+ request = urllib.request.Request(url, headers={"User-Agent": "ab1kit-fetch-test-data"})
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return response.read()
+
+
+def _genbank_url(accession: str) -> str:
+ query = urllib.parse.urlencode(
+ {"db": "nuccore", "id": accession, "rettype": "gbwithparts", "retmode": "text"}
+ )
+ return f"{EFETCH}?{query}"
+
+
+def _fetch(url: str, target: Path, force: bool, ncbi: bool = False) -> None:
+ global _last_ncbi_request
+ if target.exists() and not force:
+ print(f"skip {target.relative_to(DATA)}")
+ return
+ if ncbi: # stay polite: at most ~3 requests per second
+ wait = 0.4 - (time.monotonic() - _last_ncbi_request)
+ if wait > 0:
+ time.sleep(wait)
+ _last_ncbi_request = time.monotonic()
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(_download(url))
+ print(f"fetched {target.relative_to(DATA)}")
+
+
+def main(argv: list[str] | None = None) -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--coi", metavar="ACC", action="append", default=[], help="GenBank accession of a COI reference (repeatable)")
+ parser.add_argument("--force", action="store_true", help="re-download existing files")
+ args = parser.parse_args(argv)
+
+ for name in READS:
+ _fetch(SANGER_BASE + name, DATA / "real" / name, args.force)
+ _fetch(_genbank_url("L09137"), DATA / "refs" / "pUC19_L09137.gb", args.force, ncbi=True)
+ for acc in args.coi:
+ _fetch(_genbank_url(acc), DATA / "refs" / f"coi_{acc}.gb", args.force, ncbi=True)
+ _fetch(SNAPGENE_URL, DATA / "refs" / "sample-d.dna", args.force)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/make_icon.py b/scripts/make_icon.py
new file mode 100644
index 0000000..9b620d2
--- /dev/null
+++ b/scripts/make_icon.py
@@ -0,0 +1,81 @@
+"""Regenerate ui/icons/app/ab1kit.png and ab1kit.ico from ab1kit.svg.
+
+Usage: python scripts/make_icon.py [--out DIR]
+
+The SVG is the source of truth (letters are vector paths, so no fonts are
+needed). The ICO holds PNG-compressed entries, assembled here without extra
+dependencies.
+
+Note: the SVG letter outlines were extracted once from Consolas Bold 22px and are
+baked in as paths; this script does not touch them.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import struct
+import sys
+from pathlib import Path
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from PySide6.QtCore import QBuffer, QByteArray, QIODevice
+from PySide6.QtGui import QGuiApplication, QImage, QPainter
+from PySide6.QtSvg import QSvgRenderer
+
+APP_ICONS = Path(__file__).resolve().parent.parent / "ui" / "icons" / "app"
+ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
+
+
+def render(renderer: QSvgRenderer, size: int) -> QImage:
+ image = QImage(size, size, QImage.Format.Format_ARGB32)
+ image.fill(0)
+ painter = QPainter(image)
+ painter.setRenderHints(QPainter.RenderHint.Antialiasing
+ | QPainter.RenderHint.SmoothPixmapTransform)
+ renderer.render(painter)
+ painter.end()
+ return image
+
+
+def png_bytes(image: QImage) -> bytes:
+ data = QByteArray()
+ buf = QBuffer(data)
+ buf.open(QIODevice.OpenModeFlag.WriteOnly)
+ image.save(buf, "PNG")
+ buf.close()
+ return bytes(data)
+
+
+def build_ico(entries: list[tuple[int, bytes]]) -> bytes:
+ header = struct.pack("= 256 else size
+ directory += struct.pack(" int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--out", type=Path, default=APP_ICONS, help="output folder")
+ args = parser.parse_args(argv)
+
+ QGuiApplication.instance() or QGuiApplication([])
+ renderer = QSvgRenderer(str(APP_ICONS / "ab1kit.svg"))
+ if not renderer.isValid():
+ print("invalid SVG", file=sys.stderr)
+ return 1
+ args.out.mkdir(parents=True, exist_ok=True)
+ (args.out / "ab1kit.png").write_bytes(png_bytes(render(renderer, 256)))
+ entries = [(s, png_bytes(render(renderer, s))) for s in ICO_SIZES]
+ (args.out / "ab1kit.ico").write_bytes(build_ico(entries))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/render_preview.py b/scripts/render_preview.py
index 7a8da22..a4e2c07 100644
--- a/scripts/render_preview.py
+++ b/scripts/render_preview.py
@@ -1,6 +1,12 @@
"""Render the chromatogram widget offscreen to a PNG (dev smoke check).
-Usage: python scripts/render_preview.py [sample.ab1] [out.png]
+Usage: python scripts/render_preview.py [sample.ab1] [out.png] [--theme light|dark] [--window]
+ python scripts/render_preview.py --construct [out.png] [--theme light|dark] [--lang ru|en] [--empty]
+
+--window renders the whole main window (toolbar, docks, tables) instead of the bare widget.
+--construct renders the "construct check" tab: the COI reference of tests/data with one
+substitution introduced (so one clone shows an error) against the 8 real reads, with the
+detail pane open on that difference; --empty renders the empty state instead.
"""
from __future__ import annotations
@@ -14,16 +20,123 @@
from PySide6.QtWidgets import QApplication # noqa: E402
-from chrom_core import read_ab1 # noqa: E402
+from chrom_core import batch, read_ab1 # noqa: E402
from ui.chrom_view import ChromatogramWidget # noqa: E402
+def render_window(src: Path, out: Path, theme: str, uncheck: str = "") -> None:
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ app = QApplication.instance()
+ app.setStyle("Fusion")
+ window = MainWindow(AppConfig(recent_files=[], theme=theme))
+ window.resize(1300, 820)
+ window.show()
+ window.open_path(src)
+ seq = "".join(window.read_model.seq)
+ ref = list(seq[100:400])
+ ref[50] = "A" if ref[50] != "A" else "C"
+ ref[120] = "G" if ref[120] != "G" else "T"
+ window._set_reference_seq("".join(ref))
+ rows = ["name;pos;ref;alt", f"snp1;51;{seq[150]};T", f"snp2;121;{seq[220]};G"]
+ window._verify_variants("\n".join(rows) + "\n")
+ for base in uncheck:
+ window.read_view._channel_boxes[base].setChecked(False)
+ window.batch_dock.show()
+ window.batch_panel.show_reports(batch.scan_folder(src.parent))
+ for _ in range(20):
+ app.processEvents()
+ window.grab().save(str(out))
+ print(f"{src.name} [{theme}] -> {out}")
+ window.close()
+
+
+ROOT = Path(__file__).resolve().parent.parent
+MUTATION_AT = 250 # 0-based reference position of the introduced substitution
+
+
+def _mutated_reference(folder: Path) -> Path:
+ from Bio import SeqIO
+ from Bio.Seq import Seq
+
+ record = SeqIO.read(ROOT / "tests" / "data" / "refs" / "coi_HM417896.gb", "genbank")
+ seq = str(record.seq).upper()
+ base = "G" if seq[MUTATION_AT] != "G" else "T"
+ record.seq = Seq(seq[:MUTATION_AT] + base + seq[MUTATION_AT + 1:])
+ target = folder / "COI_construct.gb"
+ SeqIO.write(record, target, "genbank")
+ return target
+
+
+def render_construct(out: Path, theme: str, lang: str, empty: bool) -> None:
+ import time
+
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+ import ui.main_window as main_window
+ import ui.read_view.read_view as read_view
+
+ main_window.save_config = read_view.save_config = lambda *a, **k: None # keep the user's config
+ app = QApplication.instance()
+ app.setStyle("Fusion")
+ window = MainWindow(AppConfig(recent_files=[], theme=theme, language=lang))
+ window.resize(1300, 820)
+ window.show()
+ ws = window.workspace
+ if not empty:
+ folder = Path(tempfile.mkdtemp(prefix="ab1kit_construct_"))
+ ws.handle_drop([_mutated_reference(folder), ROOT / "tests" / "data" / "real"])
+ deadline = time.monotonic() + 60
+ while ws.is_verifying() and time.monotonic() < deadline:
+ app.processEvents()
+ time.sleep(0.01)
+ ws.set_region((6, 647))
+ while ws.is_verifying() and time.monotonic() < deadline:
+ app.processEvents()
+ time.sleep(0.01)
+ clone = next(r.clone for r in ws.reports() if r.verdict == "errors")
+ ws.matrix.difference_clicked.emit(clone, MUTATION_AT)
+ for _ in range(20):
+ app.processEvents()
+ window.grab().save(str(out))
+ print(f"construct [{theme}, {lang}{', empty' if empty else ''}] -> {out}")
+ window.close()
+
+
def main() -> None:
examples = sorted((Path(__file__).resolve().parent.parent / "examples").glob("*.ab1"))
- src = Path(sys.argv[1]) if len(sys.argv) > 1 else examples[0]
- out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(tempfile.gettempdir()) / "ab1kit_preview.png"
+ argv = sys.argv[1:]
+ theme = "light"
+ if "--theme" in argv:
+ i = argv.index("--theme")
+ theme = argv[i + 1]
+ del argv[i:i + 2]
+ uncheck = ""
+ if "--uncheck" in argv:
+ i = argv.index("--uncheck")
+ uncheck = argv[i + 1]
+ del argv[i:i + 2]
+ lang = "ru"
+ if "--lang" in argv:
+ i = argv.index("--lang")
+ lang = argv[i + 1]
+ del argv[i:i + 2]
+ if "--construct" in argv:
+ rest = [a for a in argv if a not in ("--construct", "--empty")]
+ out = Path(rest[0]) if rest else Path(tempfile.gettempdir()) / "ab1kit_construct.png"
+ app = QApplication([]) # noqa: F841 - must outlive the window
+ render_construct(out, theme, lang, "--empty" in argv)
+ return
+ window_mode = "--window" in argv
+ argv = [a for a in argv if a != "--window"]
+ src = Path(argv[0]) if argv else examples[0]
+ out = Path(argv[1]) if len(argv) > 1 else Path(tempfile.gettempdir()) / "ab1kit_preview.png"
app = QApplication([])
+ if window_mode:
+ render_window(src, out, theme, uncheck)
+ return
view = ChromatogramWidget()
view.resize(1200, 700)
view.set_chromatogram(read_ab1(src))
diff --git a/tests/clone_fixtures.py b/tests/clone_fixtures.py
new file mode 100644
index 0000000..c2842f7
--- /dev/null
+++ b/tests/clone_fixtures.py
@@ -0,0 +1,190 @@
+"""Shared fixtures for clone-verification tests: the real-data corpus in ``tests/data``."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import struct
+from dataclasses import replace
+from functools import cache
+from pathlib import Path
+
+from Bio import SeqIO
+
+from chrom_core import read_ab1, sessions
+from chrom_core.abif import DEFAULT_DYE_ORDER, Chromatogram
+from chrom_core.peaks import channel_baseline, peak_window
+from chrom_core.seqops import IUPAC
+
+DATA = Path(__file__).resolve().parent / "data"
+
+REAL_READS: list[Path] = sorted((DATA / "real").glob("*.ab1"))
+
+COI_GB = DATA / "refs" / "coi_HM417896.gb"
+
+# Sample prefix -> COI accession of the closest-lineage reference (A. chlorotica
+# is a cryptic aggregate; see tests/data/README.md). HM417896 is the primary one.
+SAMPLE_REFERENCE: dict[str, str] = {
+ "Achl_ACHLO006-09": "HM417896",
+ "Achl_ACHLO007-09": "HM417896",
+ "Achl_ACHLO040-09": "KC569719",
+ "Achl_ACHLO041-09": "HM417923",
+}
+PUC19_GB = DATA / "refs" / "pUC19_L09137.gb"
+SNAPGENE_DNA = DATA / "refs" / "sample-d.dna"
+
+
+def perf_factor() -> float:
+ """Multiplier for timing bounds (``AB1KIT_PERF_FACTOR``, default 1) on slow machines."""
+ return float(os.environ.get("AB1KIT_PERF_FACTOR", "1"))
+
+
+def real_read(name: str) -> Chromatogram:
+ return read_ab1(DATA / "real" / name)
+
+
+def coi_reference() -> str:
+ return str(SeqIO.read(COI_GB, "genbank").seq).upper()
+
+
+def reference_for(read_name: str) -> str:
+ """COI sequence (upper case) of the lineage-matched reference for a real read."""
+ for prefix, accession in SAMPLE_REFERENCE.items():
+ if read_name.startswith(prefix):
+ return str(SeqIO.read(DATA / "refs" / f"coi_{accession}.gb", "genbank").seq).upper()
+ raise KeyError(read_name)
+
+
+SPACING = 10 # trace points between called bases in synthetic chromatograms
+
+
+def make_chrom(
+ seq: str,
+ quality: int = 40,
+ heights: dict[tuple[int, str], float] | None = None,
+ spacing: int = SPACING,
+ primary: dict[str, float] | None = None,
+) -> Chromatogram:
+ """Synthetic chromatogram: clean primary peaks, optional extra heights.
+
+ ``heights`` maps (base index, channel) -> trace value at that peak, e.g.
+ {(10, "C"): 500} models a secondary C peak at read position 10.
+ ``primary`` overrides the height of a called peak per channel (default
+ 1000), e.g. {"G": 500} models a systematically weak G dye.
+ """
+ heights = heights or {}
+ primary = primary or {}
+ n = len(seq)
+ total = (n + 1) * spacing
+ traces = {base: [80] * total for base in "ACGT"}
+ peaks = []
+ for i, called in enumerate(seq.upper()):
+ peak = (i + 1) * spacing
+ peaks.append(peak)
+ # an IUPAC called base lights up all of its channels
+ active = set(IUPAC[called]) if called in IUPAC else set("ACGT")
+ for base in "ACGT":
+ traces[base][peak] = primary.get(base, 1000) if base in active else 60
+ for (index, base), value in heights.items():
+ traces[base][(index + 1) * spacing] = value
+ return Chromatogram(
+ sample_id="synthetic",
+ seq=seq,
+ quality=(quality,) * n,
+ traces={b: tuple(t) for b, t in traces.items()},
+ peak_indices=tuple(peaks),
+ )
+
+
+# ---------------------------------------------------------------- semi-synthetic
+
+
+def mutate(seq: str, pos: int, kind: str, base: str = "") -> str:
+ """``seq`` with a substitution / insertion (before ``pos``) / deletion at ``pos``."""
+ if kind == "sub":
+ return seq[:pos] + base + seq[pos + 1:]
+ if kind == "ins":
+ return seq[:pos] + base + seq[pos:]
+ if kind == "del":
+ return seq[:pos] + seq[pos + 1:]
+ raise ValueError(kind)
+
+
+def with_secondary_peak(chrom: Chromatogram, index: int, base: str, ratio: float) -> Chromatogram:
+ """Add a second peak in channel ``base`` under called base ``index``.
+
+ The called peak's shape is copied into ``base`` (so it is a real local
+ maximum, as ``peaks`` measures it), scaled so the *dye-normalised*
+ secondary/primary ratio is ``ratio``.
+ """
+ called = chrom.seq[index].upper()
+ baseline = channel_baseline(chrom, index)
+ scale = ratio * baseline[base] / baseline[called]
+ centre = chrom.peak_indices[index]
+ window = peak_window(chrom, index)
+ source = chrom.traces[called]
+ trace = list(chrom.traces[base])
+ for k in range(max(0, centre - window + 1), min(len(trace), centre + window)):
+ trace[k] = max(trace[k], round(source[k] * scale))
+ traces = dict(chrom.traces)
+ traces[base] = tuple(trace)
+ return replace(chrom, traces=traces)
+
+
+@cache
+def _source_by_sample_id() -> dict[str, Path]:
+ return {read_ab1(p).sample_id: p for p in REAL_READS}
+
+_DYE_OF = {base: tag for tag, base in DEFAULT_DYE_ORDER.items()}
+_RC_CHANNEL = {"A": "T", "T": "A", "C": "G", "G": "C"}
+
+
+def _flip(traces: dict[str, tuple[int, ...]]) -> dict[str, tuple[int, ...]]:
+ """Display traces of a reverse-complemented read <-> file traces."""
+ return {b: tuple(reversed(traces[_RC_CHANNEL[b]])) for b in traces}
+
+
+def _mismatches(a: dict, b: dict) -> int:
+ return sum(x != y for base in a for x, y in zip(a[base], b[base]))
+
+
+def _patch_traces(path: Path, traces: dict[str, tuple[int, ...]]) -> None:
+ """Overwrite the DATA9-12 dye channels of an ABIF file in place (same lengths)."""
+ data = bytearray(path.read_bytes())
+ _, _, _, _, count, _, offset = struct.unpack(">4siHHiii", data[6:30])
+ wanted = {_DYE_OF[b]: values for b, values in traces.items()}
+ for e in range(count):
+ entry = offset + 28 * e
+ name, number, _, _, n, _, where = struct.unpack(">4siHHiii", data[entry:entry + 24])
+ tag = f"{name.decode('latin-1')}{number}"
+ if tag in wanted:
+ assert n == len(wanted[tag]), "trace length must not change"
+ data[where:where + 2 * n] = struct.pack(f">{n}h", *wanted[tag])
+ path.write_bytes(bytes(data))
+
+
+def write_read(chrom: Chromatogram, folder: Path, name: str) -> Path:
+ """Save a modified real read as ``folder/name`` + editing session.
+
+ The source ``.ab1`` (found by sample id) is copied; the called bases,
+ quality and peak positions go to the session sidecar, so ``load_read``
+ returns ``chrom``. A reverse-complemented display is detected from the
+ traces; traces changed by ``with_secondary_peak`` are written into the copy.
+ """
+ source = _source_by_sample_id()[chrom.sample_id]
+ path = Path(folder) / (name if name.lower().endswith(".ab1") else name + ".ab1")
+ shutil.copyfile(source, path)
+ original = read_ab1(source).traces
+ flipped = _mismatches(_flip(chrom.traces), original) < _mismatches(chrom.traces, original)
+ file_traces = _flip(chrom.traces) if flipped else dict(chrom.traces)
+ if file_traces != original:
+ _patch_traces(path, file_traces)
+ sessions.save_session(sessions.session_path(path), {
+ "source": path.name,
+ "seq": chrom.seq,
+ "quality": list(chrom.quality),
+ "peaks": list(chrom.peak_indices),
+ "edited": [],
+ "reversed_complement": flipped,
+ })
+ return path
diff --git a/tests/data/README.md b/tests/data/README.md
new file mode 100644
index 0000000..f2e2e5f
--- /dev/null
+++ b/tests/data/README.md
@@ -0,0 +1,33 @@
+# Test data corpus
+
+Downloaded once by `scripts/fetch_test_data.py --coi HM417896 --coi KC569719 --coi HM417923` and committed.
+Tests never use the network. Semi-synthetic data is generated in
+`tests/clone_fixtures.py` and is not stored on disk.
+
+| File | Source | Pin | License |
+|---|---|---|---|
+| `real/Achl_ACHLO{006,007,040,041}-09_{1_F,2_R}.ab1` (8 files) | [sangeranalyseR](https://github.com/roblanf/sangeranalyseR) `inst/extdata/Allolobophora_chlorotica/ACHLO/` | commit `4c5ebc9298a873c209819f12b7dfd85e126f1406` | MIT |
+| `refs/pUC19_L09137.gb` | NCBI nuccore `L09137` (efetch, `gbwithparts`) | accession | public domain |
+| `refs/coi_{HM417896,KC569719,HM417923}.gb` | NCBI nuccore (efetch, `gbwithparts`) | accessions | public domain |
+| `refs/sample-d.dna` | [Biopython](https://github.com/biopython/biopython) `Tests/SnapGene/sample-d.dna` | commit `2fbe8755bb44731e60b7bd1e2cc55cf34d94e15b` | Biopython License |
+
+## COI references (per-lineage)
+
+*Allolobophora chlorotica* is a cryptic-species aggregate: its mitochondrial COI
+lineages differ by up to ~16%, so no single GenBank record is >= 97% identical to
+all eight reads. Candidates were found with NCBI E-utilities (esearch, ~930
+records) and checked locally with `chrom_core.align.align_read`. Each record
+below is an iBOL/BOLD mirror with an annotated CDS; `SAMPLE_REFERENCE` in
+`tests/clone_fixtures.py` maps each sample to its reference.
+`coi_reference()` is HM417896, the primary reference for ACHLO006/007.
+
+| Accession | Samples | Identity F / R |
+|---|---|---|
+| `HM417896.1` (voucher AC2, 658 bp) | ACHLO006-09 | 0.995 / 0.973 |
+| | ACHLO007-09 | 0.995 / 0.992 |
+| `KC569719.1` (voucher Acy224, 625 bp) | ACHLO040-09 | 0.986 / 0.977 |
+| `HM417923.1` (voucher B2, 658 bp) | ACHLO041-09 | 0.997 / 0.989 |
+
+Cross-lineage: ACHLO040-09 F against HM417896 is only 0.822 identical (041:
+0.836; 006/007 against KC569719/HM417923: 0.82-0.84), which makes it a real
+"different lineage" read for later tests.
diff --git a/tests/data/real/Achl_ACHLO006-09_1_F.ab1 b/tests/data/real/Achl_ACHLO006-09_1_F.ab1
new file mode 100644
index 0000000..bef8260
Binary files /dev/null and b/tests/data/real/Achl_ACHLO006-09_1_F.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO006-09_2_R.ab1 b/tests/data/real/Achl_ACHLO006-09_2_R.ab1
new file mode 100644
index 0000000..51b5fe6
Binary files /dev/null and b/tests/data/real/Achl_ACHLO006-09_2_R.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO007-09_1_F.ab1 b/tests/data/real/Achl_ACHLO007-09_1_F.ab1
new file mode 100644
index 0000000..8dfd463
Binary files /dev/null and b/tests/data/real/Achl_ACHLO007-09_1_F.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO007-09_2_R.ab1 b/tests/data/real/Achl_ACHLO007-09_2_R.ab1
new file mode 100644
index 0000000..b55e890
Binary files /dev/null and b/tests/data/real/Achl_ACHLO007-09_2_R.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO040-09_1_F.ab1 b/tests/data/real/Achl_ACHLO040-09_1_F.ab1
new file mode 100644
index 0000000..99e9161
Binary files /dev/null and b/tests/data/real/Achl_ACHLO040-09_1_F.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO040-09_2_R.ab1 b/tests/data/real/Achl_ACHLO040-09_2_R.ab1
new file mode 100644
index 0000000..86abb77
Binary files /dev/null and b/tests/data/real/Achl_ACHLO040-09_2_R.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO041-09_1_F.ab1 b/tests/data/real/Achl_ACHLO041-09_1_F.ab1
new file mode 100644
index 0000000..b66d36f
Binary files /dev/null and b/tests/data/real/Achl_ACHLO041-09_1_F.ab1 differ
diff --git a/tests/data/real/Achl_ACHLO041-09_2_R.ab1 b/tests/data/real/Achl_ACHLO041-09_2_R.ab1
new file mode 100644
index 0000000..453c866
Binary files /dev/null and b/tests/data/real/Achl_ACHLO041-09_2_R.ab1 differ
diff --git a/tests/data/refs/coi_HM417896.gb b/tests/data/refs/coi_HM417896.gb
new file mode 100644
index 0000000..dbe0fb0
--- /dev/null
+++ b/tests/data/refs/coi_HM417896.gb
@@ -0,0 +1,78 @@
+LOCUS HM417896 658 bp DNA linear INV 25-JUL-2016
+DEFINITION Allolobophora chlorotica voucher AC2 cytochrome oxidase subunit 1
+ (COI) gene, partial cds; mitochondrial.
+ACCESSION HM417896
+VERSION HM417896.1
+DBLINK BioProject: PRJNA37833
+KEYWORDS BARCODE.
+SOURCE mitochondrion Allolobophora chlorotica
+ ORGANISM Allolobophora chlorotica
+ Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata;
+ Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae;
+ Lumbricinae; Allolobophora.
+REFERENCE 1 (bases 1 to 658)
+ AUTHORS Dupont,L., Lazrek,F., Porco,D., King,R.A., Rougerie,R.,
+ Symondson,W.O.C., Livet,A., Richard,B., Decaens,T., Butt,K. and
+ Mathieu,J.
+ TITLE New insight into the genetic structure of the Allolobophora
+ chlorotica aggregate in Europe using microsatellite and
+ mitochondrial data
+ JOURNAL Pedobiologia (Jena) (2011) In press
+REFERENCE 2 (bases 1 to 658)
+ CONSRTM International Barcode of Life (iBOL)
+ TITLE iBOL Data Release
+ JOURNAL Unpublished
+REFERENCE 3 (bases 1 to 658)
+ CONSRTM International Barcode of Life (iBOL)
+ TITLE Direct Submission
+ JOURNAL Submitted (01-JUN-2010) Biodiversity Institute of Ontario,
+ University of Guelph, 50 Stone Rd West, Guelph, Ontario N1G2W1,
+ Canada
+COMMENT ##International Barcode of Life (iBOL)Data-START##
+ Barcode Index Number :: BOLD:AAA2170
+ Order Assignment :: Haplotaxida
+ iBOL Working Group :: iBOL:WG1.9
+ iBOL Release Status :: Phase 2
+ ##International Barcode of Life (iBOL)Data-END##
+FEATURES Location/Qualifiers
+ source 1..658
+ /organism="Allolobophora chlorotica"
+ /organelle="mitochondrion"
+ /mol_type="genomic DNA"
+ /specimen_voucher="AC2"
+ /db_xref="BOLD:ACHLO007-09.COI-5P"
+ /db_xref="taxon:302034"
+ /geo_loc_name="France: Seine-et-Marne, Ile de France,
+ Chaumes en brie (77390) -Maurevert"
+ /lat_lon="49.1458 N 4.43806 E"
+ /collection_date="29-Mar-2009"
+ /PCR_primers="fwd_seq:
+ tgtaaaacgacggccagtggtcaacaaatcataaagatattgg, rev_seq:
+ caggaaacagctatgactaaacttcagggtgaccaaaaaatca"
+ gene <1..>658
+ /gene="COI"
+ CDS <1..>658
+ /gene="COI"
+ /codon_start=2
+ /transl_table=5
+ /product="cytochrome oxidase subunit 1"
+ /protein_id="ADK40636.1"
+ /translation="TLYFILGVWAGMVGAGMSLLIRIELSQPGAFLGSDQLYNTIVTA
+ HAFVMIFFLVMPVFIGGFGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAV
+ EKGAGTGWTVYPPLASNLAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGL
+ RLERIPLFVWAVLITVVLLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHL
+ F"
+ORIGIN
+ 1 aactttatat tttattctgg gcgtctgagc aggaatggtt ggagccggta taagacttct
+ 61 aattcgaatc gagctaagac aaccaggagc gttcctgggc agagaccaac tatacaatac
+ 121 tatcgttact gcacacgcat ttgtaataat cttctttcta gtaatgcctg tattcatcgg
+ 181 gggattcgga aactggcttt tacctttaat acttggagcc cccgatatag cattccctcg
+ 241 actcaacaac atgagattct gactacttcc cccatcactg atccttttag tgtcctctgc
+ 301 ggcggtagaa aaaggcgctg gtacggggtg aactgtttat ccgcctctag caagaaatct
+ 361 tgcccacgca ggcccgtctg tagatttagc catcttttcc cttcatttag cgggtgcgtc
+ 421 ttctattcta ggggctatta attttatcac cacagttatt aatatgcgtt gaagaggatt
+ 481 acgtcttgaa cgaattcccc tgtttgtctg agctgtgcta attacagttg ttcttctact
+ 541 tctatcttta ccagtgctag caggtgccat taccatactt cttaccgacc gaaacctcaa
+ 601 tacttcattc tttgatcctg ccggtggtgg agaccccatc ctctaccaac acttattc
+//
+
diff --git a/tests/data/refs/coi_HM417923.gb b/tests/data/refs/coi_HM417923.gb
new file mode 100644
index 0000000..c9e8b53
--- /dev/null
+++ b/tests/data/refs/coi_HM417923.gb
@@ -0,0 +1,78 @@
+LOCUS HM417923 658 bp DNA linear INV 25-JUL-2016
+DEFINITION Allolobophora chlorotica voucher B2 cytochrome oxidase subunit 1
+ (COI) gene, partial cds; mitochondrial.
+ACCESSION HM417923
+VERSION HM417923.1
+DBLINK BioProject: PRJNA37833
+KEYWORDS BARCODE.
+SOURCE mitochondrion Allolobophora chlorotica
+ ORGANISM Allolobophora chlorotica
+ Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata;
+ Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae;
+ Lumbricinae; Allolobophora.
+REFERENCE 1 (bases 1 to 658)
+ AUTHORS Dupont,L., Lazrek,F., Porco,D., King,R.A., Rougerie,R.,
+ Symondson,W.O.C., Livet,A., Richard,B., Decaens,T., Butt,K. and
+ Mathieu,J.
+ TITLE New insight into the genetic structure of the Allolobophora
+ chlorotica aggregate in Europe using microsatellite and
+ mitochondrial data
+ JOURNAL Pedobiologia (Jena) (2011) In press
+REFERENCE 2 (bases 1 to 658)
+ CONSRTM International Barcode of Life (iBOL)
+ TITLE iBOL Data Release
+ JOURNAL Unpublished
+REFERENCE 3 (bases 1 to 658)
+ CONSRTM International Barcode of Life (iBOL)
+ TITLE Direct Submission
+ JOURNAL Submitted (01-JUN-2010) Biodiversity Institute of Ontario,
+ University of Guelph, 50 Stone Rd West, Guelph, Ontario N1G2W1,
+ Canada
+COMMENT ##International Barcode of Life (iBOL)Data-START##
+ Barcode Index Number :: BOLD:AAA2176
+ Order Assignment :: Haplotaxida
+ iBOL Working Group :: iBOL:WG1.9
+ iBOL Release Status :: Phase 2
+ ##International Barcode of Life (iBOL)Data-END##
+FEATURES Location/Qualifiers
+ source 1..658
+ /organism="Allolobophora chlorotica"
+ /organelle="mitochondrion"
+ /mol_type="genomic DNA"
+ /specimen_voucher="B2"
+ /db_xref="BOLD:ACHLO041-09.COI-5P"
+ /db_xref="taxon:302034"
+ /geo_loc_name="France: Seine-saint-Denis, Ile de France,
+ centre IRD Bondy"
+ /lat_lon="50.2769 N 2.52444 E"
+ /collection_date="07-Apr-2009"
+ /PCR_primers="fwd_seq:
+ tgtaaaacgacggccagtggtcaacaaatcataaagatattgg, rev_seq:
+ caggaaacagctatgactaaacttcagggtgaccaaaaaatca"
+ gene <1..>658
+ /gene="COI"
+ CDS <1..>658
+ /gene="COI"
+ /codon_start=2
+ /transl_table=5
+ /product="cytochrome oxidase subunit 1"
+ /protein_id="ADK40663.1"
+ /translation="TLYFILGVWAGMVGAGMSLLIRIELSQPGAFLGSDQLYNTIVTA
+ HAFVMIFFLVMPVFIGGFGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAV
+ EKGAGTGWTVYPPLASNIAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGL
+ RLERIPLFVWAVLITVVLLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHL
+ F"
+ORIGIN
+ 1 aactctatac tttattttag gcgtctgagc aggaatggta ggagctggta taagactcct
+ 61 aattcgaatt gagctaagac agccgggagc attcctagga agggatcagc tctataatac
+ 121 tattgtaact gctcacgcat ttgtaataat tttctttcta gtaatacctg tatttattgg
+ 181 ggggttcggt aattgacttc tacctttaat acttggagcc cctgacatgg cattcccacg
+ 241 acttaacaac ataagattct gactccttcc cccgtcacta attcttctag tgtcctctgc
+ 301 tgcagtagaa aaaggtgcag gaactggatg aactgtttat ccgcccctag caagaaatat
+ 361 tgctcatgcc ggcccatctg tagacttagc tattttttct cttcatttag cgggtgcttc
+ 421 atcaatctta ggggccatta attttattac tactgttatt aacatgcgat gaagaggctt
+ 481 acgacttgaa cgaatcccat tattcgtttg agccgtacta atcacagtgg tgcttctact
+ 541 cctatcccta ccagtgttag ccggtgcaat tactatatta cttaccgatc gaaatctaaa
+ 601 tacctccttc tttgatcctg ctgggggtgg agatccaatc ctatatcaac acttattc
+//
+
diff --git a/tests/data/refs/coi_KC569719.gb b/tests/data/refs/coi_KC569719.gb
new file mode 100644
index 0000000..5eb1867
--- /dev/null
+++ b/tests/data/refs/coi_KC569719.gb
@@ -0,0 +1,62 @@
+LOCUS KC569719 625 bp DNA linear INV 01-JAN-2015
+DEFINITION Allolobophora chlorotica voucher Acy224 cytochrome oxidase subunit
+ 1 (COI) gene, partial cds; mitochondrial.
+ACCESSION KC569719
+VERSION KC569719.1
+KEYWORDS .
+SOURCE mitochondrion Allolobophora chlorotica
+ ORGANISM Allolobophora chlorotica
+ Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata;
+ Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae;
+ Lumbricinae; Allolobophora.
+REFERENCE 1 (bases 1 to 625)
+ AUTHORS Dupont,L., Gresille,Y., Richard,B., Decaens,T. and Mathieu,J.
+ TITLE Fine-scale spatial genetic structure of two earthworm species,
+ Allolobophora chlorotica and Aporrectodea icterica (Annelida,
+ Clitellata) in temperate pastures related to dispersal constraints
+ JOURNAL Unpublished
+REFERENCE 2 (bases 1 to 625)
+ AUTHORS Dupont,L., Gresille,Y., Richard,B., Decaens,T. and Mathieu,J.
+ TITLE Direct Submission
+ JOURNAL Submitted (01-FEB-2013) UMR7618 BIOEMCO IBIOS, Faculte des Sciences
+ et Technologie UPEC, 61 Avn General de Gaulle, Creteil, IDF 94010,
+ France
+FEATURES Location/Qualifiers
+ source 1..625
+ /organism="Allolobophora chlorotica"
+ /organelle="mitochondrion"
+ /mol_type="genomic DNA"
+ /specimen_voucher="Acy224"
+ /db_xref="taxon:302034"
+ /haplogroup="L2"
+ /geo_loc_name="France"
+ /collection_date="01-Apr-2009"
+ /PCR_primers="fwd_name: lco1490, fwd_seq:
+ ggtcaacaaatcataaagatattgg, rev_name: hco2198, rev_seq:
+ taaacttcagggtgaccaaaaaatca"
+ gene <1..>625
+ /gene="COI"
+ CDS <1..>625
+ /gene="COI"
+ /codon_start=2
+ /transl_table=5
+ /product="cytochrome oxidase subunit 1"
+ /protein_id="AHB64231.1"
+ /translation="SLLIRIELSQPGAFLGSDQLYNTIVTAHAFVMIFFLVMPVFIGG
+ FGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAVEKGAGTGWTVYPPLASN
+ IAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGLRLERIPLFVWAVLITVV
+ LLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHLFWFFGHP"
+ORIGIN
+ 1 aagactccta attcgaattg agctaagaca gccgggagca tttctaggaa gggatcaact
+ 61 ctataacact attgtaactg ctcacgcatt tgtaataatt ttctttctag taatacctgt
+ 121 atttattggg gggttcggta attgacttct gcctttaata cttggagccc ctgacatggc
+ 181 attcccacgt cttaacaaca taagattttg actccttccc ccatcactaa tccttctagt
+ 241 atcctctgct gcagtagaaa agggtgcggg aactggatga actgtttatc cacccctagc
+ 301 aagaaacatt gctcatgccg gcccatctgt agacttagct attttttctc ttcatttagc
+ 361 aggtgcttca tcaatcttgg gtgccattaa ttttattact actgttatta atatacgatg
+ 421 aagaggctta cgacttgaac gaatcccatt attcgtttga gccgtactaa ttacagtggt
+ 481 ccttctactc ttatccttac cagtattagc cggtgcaatt actatactac ttaccgatcg
+ 541 aaatctaaat acctccttct ttgaccctgc tggaggtggg gatccaatcc tatatcaaca
+ 601 cttattctga ttttttggtc accct
+//
+
diff --git a/tests/data/refs/pUC19_L09137.gb b/tests/data/refs/pUC19_L09137.gb
new file mode 100644
index 0000000..40467ad
--- /dev/null
+++ b/tests/data/refs/pUC19_L09137.gb
@@ -0,0 +1,157 @@
+LOCUS SYNPUC19CV 2686 bp DNA circular SYN 22-MAY-2002
+DEFINITION Cloning vector pUC19c, complete sequence.
+ACCESSION L09137 X02514
+VERSION L09137.2
+KEYWORDS .
+SOURCE Cloning vector pUC19c
+ ORGANISM Cloning vector pUC19c
+ other sequences; artificial sequences; vectors.
+REFERENCE 1 (bases 1 to 2686)
+ AUTHORS Yanisch-Perron,C., Vieira,J. and Messing,J.
+ TITLE Improved M13 phage cloning vectors and host strains: nucleotide
+ sequences of the M13mp18 and pUC19 vectors
+ JOURNAL Gene 33 (1), 103-119 (1985)
+ PUBMED 2985470
+REFERENCE 2 (bases 1 to 2686)
+ AUTHORS Chambers,S.P., Prior,S.E., Barstow,D.A. and Minton,N.P.
+ TITLE The pMTL nic- cloning vectors. I. Improved pUC polylinker regions
+ to facilitate the use of sonicated DNA for nucleotide sequencing
+ JOURNAL Gene 68 (1), 139-149 (1988)
+ PUBMED 2851488
+REFERENCE 3 (bases 1 to 2686)
+ AUTHORS Gilbert,W.
+ TITLE Obtained from VecBase 3.0
+ JOURNAL Unpublished
+REFERENCE 4 (bases 1 to 2686)
+ AUTHORS Messing,J.
+ TITLE Direct Submission
+ JOURNAL Submitted (27-APR-1993) Department of Biochemistry, University of
+ Minnesota, St. Paul, MN 55108, USA
+REFERENCE 5 (bases 1 to 2686)
+ AUTHORS Messing,J.
+ TITLE Direct Submission
+ JOURNAL Submitted (11-APR-2002) Rutgers, The State University of New
+ Jersey, Waksman Institute of Microbiology, 190 Frelinghuysen Road,
+ Piscataway, NJ 08854-8020, USA
+ REMARK Sequence update by submitter
+COMMENT On Apr 11, 2002 this sequence version replaced L09137.1.
+ These data and their annotation were supplied to GenBank by Will
+ Gilbert under the auspices of the GenBank Currator Program. pUC19c
+ - Cloning vector (beta-galactosidase mRNA on complementary strand)
+ ENTRY PUC19C #TYPE DNA CIRCULAR TITLE pUC19c -
+ Cloning vector
+ (beta-galactosidase mRNA on complementary strand)
+ DATE 03-FEB-1986
+ #sequence 16-DEC-1986
+ ACCESSION VB0033
+ SOURCE artificial
+ COLLECTION ATCC 37254
+ REFERENCE
+ #number
+ #authors Norrander J., Kempe T., Messing J.
+ #journal Gene (1983) 26: 101-106
+ REFERENCE
+ #number 1
+ #authors Yanisch-Perron C., Vieira J., Messing J.
+ #journal Gene (1985) 33: 103-119
+ #comment shows the complete compiled sequence
+ REFERENCE
+ #number 2
+ #authors Chambers,S.P., et al.
+ #journal Gene (1988) 68: 139-149
+ #describes mutation at nt1308 and its effect on copy number
+ REFERENCE
+ #number
+ #authors Pouwels P.H., Enger-Valk B.E., Brammar W.J.
+ #book Cloning Vectors, Elsvier 1985 and supplements
+ #comment vector I-A-iv-20
+ COMMENT
+ This Sequence was obtained 3-MAR-1986 from J. Messing, Waksman
+ Institute, NJ on floppy disk.
+ Revised 16-DEC-1986 by F. Pfeiffer:
+ 1062/3 'AT' to 'TA' to match revised sequence of PBR322
+ The beta-galactosidase mRNA sequence including the multiple
+ cloning site of M13mp19 is on the strand complementary to that
+ shown.
+ KEYWORDS
+ CROSSREFERENCE
+ #complement
+ VecBase(3):pUC19
+ #prerevised
+ GenBank(50):M11662, EMBL(11):ARPuc19
+ #parent
+ VecBase(3):pUC13, VecBase(3):M13mp19, VecSource(3):bGal19
+ PARENT
+ Features of pUC19c (2686 bp)
+ residue source
+ 1- 137 2074-2210 pBR322
+ 138- 237 2252-2351 pBR322
+ 238- 395 1461-1304 (c) Lac-Operon
+ 396- 452 57- 1 (c) polylinker of M13mp19
+ 455- 682 1298-1071 (c) Lac-Operon
+ 683-2686 2352-4355 pBR322
+ Conflict (cfl) and Mutations (mut):
+ pUC19c source
+ mut 1308 A G 2977 pBR322 linked to increased copy number
+ mut 1942 A G 3611 pBR322
+ mut 2243 T C 3912 pBR322
+ FEATURE
+ 1629-2417 789-1 (c) Ap-R; b-lactamase
+ POLYLINKER HindIII-SphI-PstI-SalI-XbaI-BamHI-SmaI-KpnI-SacI-EcoRI
+ SELECTION
+ #resistance Ap
+ #indicator beta-galactosidase
+ SUMMARY pUC19c #length 2686 #checksum 4465.
+FEATURES Location/Qualifiers
+ source 1..2686
+ /organism="Cloning vector pUC19c"
+ /mol_type="genomic DNA"
+ /db_xref="taxon:174689"
+ORIGIN
+ 1 tcgcgcgttt cggtgatgac ggtgaaaacc tctgacacat gcagctcccg gagacggtca
+ 61 cagcttgtct gtaagcggat gccgggagca gacaagcccg tcagggcgcg tcagcgggtg
+ 121 ttggcgggtg tcggggctgg cttaactatg cggcatcaga gcagattgta ctgagagtgc
+ 181 accatatgcg gtgtgaaata ccgcacagat gcgtaaggag aaaataccgc atcaggcgcc
+ 241 attcgccatt caggctgcgc aactgttggg aagggcgatc ggtgcgggcc tcttcgctat
+ 301 tacgccagct ggcgaaaggg ggatgtgctg caaggcgatt aagttgggta acgccagggt
+ 361 tttcccagtc acgacgttgt aaaacgacgg ccagtgaatt cgagctcggt acccggggat
+ 421 cctctagagt cgacctgcag gcatgcaagc ttggcgtaat catggtcata gctgtttcct
+ 481 gtgtgaaatt gttatccgct cacaattcca cacaacatac gagccggaag cataaagtgt
+ 541 aaagcctggg gtgcctaatg agtgagctaa ctcacattaa ttgcgttgcg ctcactgccc
+ 601 gctttccagt cgggaaacct gtcgtgccag ctgcattaat gaatcggcca acgcgcgggg
+ 661 agaggcggtt tgcgtattgg gcgctcttcc gcttcctcgc tcactgactc gctgcgctcg
+ 721 gtcgttcggc tgcggcgagc ggtatcagct cactcaaagg cggtaatacg gttatccaca
+ 781 gaatcagggg ataacgcagg aaagaacatg tgagcaaaag gccagcaaaa ggccaggaac
+ 841 cgtaaaaagg ccgcgttgct ggcgtttttc cataggctcc gcccccctga cgagcatcac
+ 901 aaaaatcgac gctcaagtca gaggtggcga aacccgacag gactataaag ataccaggcg
+ 961 tttccccctg gaagctccct cgtgcgctct cctgttccga ccctgccgct taccggatac
+ 1021 ctgtccgcct ttctcccttc gggaagcgtg gcgctttctc atagctcacg ctgtaggtat
+ 1081 ctcagttcgg tgtaggtcgt tcgctccaag ctgggctgtg tgcacgaacc ccccgttcag
+ 1141 cccgaccgct gcgccttatc cggtaactat cgtcttgagt ccaacccggt aagacacgac
+ 1201 ttatcgccac tggcagcagc cactggtaac aggattagca gagcgaggta tgtaggcggt
+ 1261 gctacagagt tcttgaagtg gtggcctaac tacggctaca ctagaagaac agtatttggt
+ 1321 atctgcgctc tgctgaagcc agttaccttc ggaaaaagag ttggtagctc ttgatccggc
+ 1381 aaacaaacca ccgctggtag cggtggtttt tttgtttgca agcagcagat tacgcgcaga
+ 1441 aaaaaaggat ctcaagaaga tcctttgatc ttttctacgg ggtctgacgc tcagtggaac
+ 1501 gaaaactcac gttaagggat tttggtcatg agattatcaa aaaggatctt cacctagatc
+ 1561 cttttaaatt aaaaatgaag ttttaaatca atctaaagta tatatgagta aacttggtct
+ 1621 gacagttacc aatgcttaat cagtgaggca cctatctcag cgatctgtct atttcgttca
+ 1681 tccatagttg cctgactccc cgtcgtgtag ataactacga tacgggaggg cttaccatct
+ 1741 ggccccagtg ctgcaatgat accgcgagac ccacgctcac cggctccaga tttatcagca
+ 1801 ataaaccagc cagccggaag ggccgagcgc agaagtggtc ctgcaacttt atccgcctcc
+ 1861 atccagtcta ttaattgttg ccgggaagct agagtaagta gttcgccagt taatagtttg
+ 1921 cgcaacgttg ttgccattgc tacaggcatc gtggtgtcac gctcgtcgtt tggtatggct
+ 1981 tcattcagct ccggttccca acgatcaagg cgagttacat gatcccccat gttgtgcaaa
+ 2041 aaagcggtta gctccttcgg tcctccgatc gttgtcagaa gtaagttggc cgcagtgtta
+ 2101 tcactcatgg ttatggcagc actgcataat tctcttactg tcatgccatc cgtaagatgc
+ 2161 ttttctgtga ctggtgagta ctcaaccaag tcattctgag aatagtgtat gcggcgaccg
+ 2221 agttgctctt gcccggcgtc aatacgggat aataccgcgc cacatagcag aactttaaaa
+ 2281 gtgctcatca ttggaaaacg ttcttcgggg cgaaaactct caaggatctt accgctgttg
+ 2341 agatccagtt cgatgtaacc cactcgtgca cccaactgat cttcagcatc ttttactttc
+ 2401 accagcgttt ctgggtgagc aaaaacagga aggcaaaatg ccgcaaaaaa gggaataagg
+ 2461 gcgacacgga aatgttgaat actcatactc ttcctttttc aatattattg aagcatttat
+ 2521 cagggttatt gtctcatgag cggatacata tttgaatgta tttagaaaaa taaacaaata
+ 2581 ggggttccgc gcacatttcc ccgaaaagtg ccacctgacg tctaagaaac cattattatc
+ 2641 atgacattaa cctataaaaa taggcgtatc acgaggccct ttcgtc
+//
+
diff --git a/tests/data/refs/sample-d.dna b/tests/data/refs/sample-d.dna
new file mode 100644
index 0000000..30a1efa
Binary files /dev/null and b/tests/data/refs/sample-d.dna differ
diff --git a/tests/test_align.py b/tests/test_align.py
index cb0aa0e..c931509 100644
--- a/tests/test_align.py
+++ b/tests/test_align.py
@@ -104,6 +104,27 @@ def test_ambiguous_repetitive_reference_flagged() -> None:
assert a.identity == 1.0
+def test_homopolymer_indel_is_one_placement() -> None:
+ import random
+ rng = random.Random(5)
+ left = "".join(rng.choice("ACGT") for _ in range(80))
+ right = "".join(rng.choice("ACGT") for _ in range(80))
+ reference = left + "GTTTTC" + right
+ read = left + "GTTTTTC" + right # one extra T in the run
+ a = align_read(read, reference)
+ assert a.alternatives > 1 # co-optimal gap placements ...
+ assert a.placements == 1 # ... but a single placement
+
+
+def test_distinct_repeat_copies_are_two_placements() -> None:
+ import random
+ rng = random.Random(6)
+ unit = "".join(rng.choice("ACGT") for _ in range(60))
+ spacer = "".join(rng.choice("ACGT") for _ in range(200))
+ a = align_read(unit, spacer[:50] + unit + spacer + unit + spacer[50:])
+ assert a.placements == 2
+
+
def test_empty_inputs_raise(reference: str) -> None:
with pytest.raises(ValueError):
align_read("", reference)
diff --git a/tests/test_align_circular.py b/tests/test_align_circular.py
new file mode 100644
index 0000000..028abf8
--- /dev/null
+++ b/tests/test_align_circular.py
@@ -0,0 +1,203 @@
+import random
+
+import pytest
+
+from clone_fixtures import coi_reference, real_read
+from chrom_core.align import (
+ CIRCULAR_MARGIN,
+ MAX_REFERENCE_LENGTH,
+ align_read,
+ align_to_construct,
+ gapped_sequences,
+)
+from chrom_core.reference import Construct
+
+READ_NAME = "Achl_ACHLO006-09_1_F.ab1"
+
+
+@pytest.fixture(scope="module")
+def read_seq() -> str:
+ return real_read(READ_NAME).seq
+
+
+@pytest.fixture(scope="module")
+def ref() -> str:
+ return coi_reference()
+
+
+@pytest.fixture(scope="module")
+def insert_read(read_seq, ref) -> str:
+ """The part of the real read that lies inside the COI reference.
+
+ The raw read (703 bp) overhangs BOTH ends of the 658 bp reference. Biopython's
+ end gaps are free on one sequence per end only, so on an unrolled circle the
+ 44-base junk tail is cheaper aligned onto the start of the circle than gapped
+ — the read then legitimately "covers" the whole ring. A clone read sits
+ inside its construct, so the rotation tests use the inside part.
+ """
+ full = align_read(read_seq, ref)
+ return read_seq[full.read_start:full.read_end]
+
+
+@pytest.fixture(scope="module")
+def aln0(insert_read, ref):
+ return align_read(insert_read, ref)
+
+
+@pytest.fixture(scope="module")
+def mid(aln0) -> int:
+ m = (aln0.ref_start + aln0.ref_end) // 2
+ # the new origin must fall well inside the read's covered span
+ assert aln0.ref_start + 100 < m < aln0.ref_end - 100
+ return m
+
+
+@pytest.fixture(scope="module")
+def rotated(ref, mid) -> str:
+ return ref[mid:] + ref[:mid]
+
+
+def _random_seq(n: int, seed: int) -> str:
+ rng = random.Random(seed)
+ return "".join(rng.choice("ACGT") for _ in range(n))
+
+
+def test_margin_constant() -> None:
+ assert CIRCULAR_MARGIN == 50
+
+
+def test_linear_matches_align_read(read_seq, ref) -> None:
+ aln0 = align_read(read_seq, ref)
+ aln = align_to_construct(read_seq, Construct("coi", ref, False))
+ assert aln.differences == aln0.differences
+ assert aln.identity == aln0.identity
+ assert (aln.ref_start, aln.ref_end) == (aln0.ref_start, aln0.ref_end)
+ assert aln.ref_to_read == aln0.ref_to_read
+ assert aln.ref_length == len(ref)
+ assert aln.covered == aln0.covered
+ assert aln.covered_positions() == list(range(aln0.ref_start, aln0.ref_end))
+ assert aln0.ref_start > 0
+ assert aln.covers(aln0.ref_start) and not aln.covers(aln0.ref_start - 1)
+
+
+def test_read_across_origin_of_rotated_reference(insert_read, ref, aln0, mid, rotated) -> None:
+ aln = align_to_construct(insert_read, Construct("rot", rotated, True))
+ L = len(ref)
+ assert aln.ref_length == L
+ assert aln.ref_end < aln.ref_start # wrapped
+ assert len(aln.covered_positions()) == aln0.covered
+ assert aln.covered == aln0.covered
+ assert [(d.ref_pos - (L - mid)) % L for d in aln.differences] == [
+ d.ref_pos for d in aln0.differences
+ ]
+ assert aln.covers(0) and aln.covers(L - 1)
+ assert aln.identity == pytest.approx(aln0.identity)
+ # every key is a real construct position; the mapping is the rotation of aln0's
+ assert all(0 <= p < L for p in aln.ref_to_read)
+ assert {(p + mid) % L: q for p, q in aln.ref_to_read.items()} == aln0.ref_to_read
+ # gapped reconstruction over the unrolled reference gives the same pair
+ assert gapped_sequences(aln, rotated * 2) == gapped_sequences(aln0, ref)
+
+
+def test_same_rotation_linear_breaks_read(insert_read, aln0, rotated) -> None:
+ aln = align_to_construct(insert_read, Construct("rot", rotated, False))
+ assert aln.covered < aln0.covered # without the ring the read is cut at the origin
+ # only one side of the origin can be aligned: roughly half of the read's span
+ assert aln.covered <= aln0.covered * 0.6
+ assert aln.ref_end >= aln.ref_start
+
+
+def test_real_read_longer_than_circular_construct(read_seq, ref) -> None:
+ assert len(read_seq) > len(ref)
+ aln = align_to_construct(read_seq, Construct("coi", ref, True))
+ positions = aln.covered_positions()
+ assert len(positions) == len(set(positions)) <= len(ref)
+ assert all(0 <= p < len(ref) for p in positions)
+ assert set(aln.ref_to_read) <= set(positions)
+ assert all(0 <= d.ref_pos < len(ref) for d in aln.differences)
+ assert aln.covered == len(positions)
+
+
+def test_tandem_read_longer_than_circle_reports_each_position_once() -> None:
+ circle = _random_seq(300, seed=7)
+ read = (circle * 3)[50:50 + 400] # wraps the circle more than once
+ aln = align_to_construct(read, Construct("small", circle, True))
+ L = len(circle)
+ positions = aln.covered_positions()
+ assert len(positions) == L and sorted(positions) == list(range(L))
+ assert aln.covered == L
+ assert (aln.ref_start, aln.ref_end) == (0, L)
+ assert all(aln.covers(p) for p in range(L))
+ assert sorted(aln.ref_to_read) == list(range(L))
+ # the first pass over the circle wins: read index 0 sits at construct position 50
+ assert aln.ref_to_read[50] == 0
+ assert aln.ref_to_read[49] == L - 1
+ assert aln.differences == []
+ assert aln.identity == 1.0
+ ref_g, read_g = gapped_sequences(aln, circle * 2)
+ assert len(ref_g) == L and ref_g == read_g == read[:L]
+
+
+def test_wrapped_coordinates_minus_strand() -> None:
+ from chrom_core.seqops import reverse_complement
+
+ circle = _random_seq(500, seed=11)
+ insert = circle[420:] + circle[:90] # 170 bp across the origin
+ aln = align_to_construct(reverse_complement(insert), Construct("c", circle, True))
+ assert aln.strand == "-"
+ assert (aln.ref_start, aln.ref_end) == (420, 90)
+ assert aln.covered == 170
+ assert aln.covered_positions() == list(range(420, 500)) + list(range(90))
+ assert aln.ref_to_read[420] == 0 and aln.ref_to_read[0] == 80 and aln.ref_to_read[89] == 169
+ assert not aln.covers(90) and not aln.covers(419)
+
+
+def test_wrapped_differences_are_mod_length() -> None:
+ circle = _random_seq(400, seed=3)
+ insert = list(circle[350:] + circle[:60]) # covers 350..399, 0..59
+ insert[55] = "A" if insert[55] != "A" else "C" # construct position 5
+ aln = align_to_construct("".join(insert), Construct("c", circle, True))
+ assert [(d.ref_pos, d.kind) for d in aln.differences] == [(5, "substitution")]
+
+
+def test_limit_applies_to_original_length() -> None:
+ big = _random_seq(MAX_REFERENCE_LENGTH, seed=5)
+ read = big[1000:1030]
+ aln = align_to_construct(read, Construct("big", big, True))
+ assert aln.ref_length == MAX_REFERENCE_LENGTH
+ with pytest.raises(ValueError):
+ align_to_construct(read, Construct("big", big + "A", True))
+ with pytest.raises(ValueError):
+ align_to_construct(read, Construct("big", big + "A", False))
+
+
+def test_read_in_unrolled_prefix_is_not_ambiguous() -> None:
+ circle = _random_seq(1000, seed=21)
+ read = circle[10:210] # also present in the unrolled copy of the start
+ aln = align_to_construct(read, Construct("c", circle, True))
+ assert (aln.ref_start, aln.ref_end) == (10, 210)
+ assert aln.alternatives == 1
+ assert aln.placements == 1
+ linear = align_to_construct(read, Construct("c", circle, False))
+ assert (linear.alternatives, linear.placements) == (1, 1)
+
+
+def test_genuine_repeat_in_circle_stays_ambiguous() -> None:
+ circle = list(_random_seq(1000, seed=23))
+ repeat = _random_seq(100, seed=24)
+ circle[20:120] = repeat # inside the unrolled prefix as well
+ circle[600:700] = repeat
+ circle = "".join(circle)
+ aln = align_to_construct(repeat, Construct("c", circle, True))
+ assert aln.alternatives == 2
+ assert aln.placements == 2
+
+
+def test_homopolymer_indel_across_origin_is_one_placement() -> None:
+ circle = _random_seq(1000, seed=31)
+ circle = circle[:995] + "GTTTC" # a TTT run just before the origin
+ read = circle[900:996] + "TTTT" + circle[999:] + circle[:100] # one extra T
+ aln = align_to_construct(read, Construct("c", circle, True))
+ assert aln.wraps
+ assert aln.alternatives > 1
+ assert aln.placements == 1
diff --git a/tests/test_app_icon.py b/tests/test_app_icon.py
new file mode 100644
index 0000000..4dbc851
--- /dev/null
+++ b/tests/test_app_icon.py
@@ -0,0 +1,96 @@
+"""App icon: committed assets, regeneration script, window icon."""
+
+import os
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import struct
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from PySide6.QtGui import QIcon, QImage
+
+ROOT = Path(__file__).resolve().parent.parent
+APP_ICONS = ROOT / "ui" / "icons" / "app"
+
+
+def test_png_and_ico_exist():
+ assert (APP_ICONS / "ab1kit.png").is_file()
+ assert (APP_ICONS / "ab1kit.ico").is_file()
+
+
+def test_ico_has_required_sizes():
+ data = (APP_ICONS / "ab1kit.ico").read_bytes()
+ reserved, kind, count = struct.unpack_from(" list[tuple[int, bytes]]:
+ count = struct.unpack_from(" QImage:
+ image = QImage.fromData(blob, "PNG")
+ assert not image.isNull()
+ return image.convertToFormat(QImage.Format.Format_ARGB32)
+
+
+def _assert_pixels_close(a: QImage, b: QImage) -> None:
+ """Max per-channel diff <= 2, except at most 0.1% outlier pixels."""
+ assert (a.width(), a.height()) == (b.width(), b.height())
+ outliers = 0
+ for y in range(a.height()):
+ for x in range(a.width()):
+ pa, pb = a.pixel(x, y), b.pixel(x, y)
+ if any(abs(((pa >> s) & 255) - ((pb >> s) & 255)) > 2 for s in (0, 8, 16, 24)):
+ outliers += 1
+ assert outliers <= max(1, a.width() * a.height() // 1000)
+
+
+def test_make_icon_regenerates_same_pixels(tmp_path):
+ env = {k: v for k, v in os.environ.items() if k != "QT_QPA_FONTDIR"}
+ env["QT_QPA_PLATFORM"] = "offscreen"
+ subprocess.run([sys.executable, str(ROOT / "scripts" / "make_icon.py"),
+ "--out", str(tmp_path)], check=True, env=env, cwd=ROOT)
+ _assert_pixels_close(_decode((tmp_path / "ab1kit.png").read_bytes()),
+ _decode((APP_ICONS / "ab1kit.png").read_bytes()))
+ new = _ico_entries((tmp_path / "ab1kit.ico").read_bytes())
+ old = _ico_entries((APP_ICONS / "ab1kit.ico").read_bytes())
+ assert [size for size, _ in new] == [size for size, _ in old]
+ for (_, blob_new), (_, blob_old) in zip(new, old):
+ _assert_pixels_close(_decode(blob_new), _decode(blob_old))
+
+
+def test_main_window_sets_application_icon(monkeypatch):
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication.instance() or QApplication([])
+ monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None)
+ from ui.main_window import MainWindow
+
+ app.setWindowIcon(QIcon())
+ window = MainWindow()
+ icon = app.windowIcon()
+ assert not icon.isNull()
+ pix = icon.pixmap(32, 32)
+ assert (pix.width(), pix.height()) == (32, 32)
+ assert pix.toImage().convertToFormat(QImage.Format.Format_ARGB32).pixelColor(16, 16).alpha() > 0
+ window.close()
diff --git a/tests/test_batch.py b/tests/test_batch.py
index 01685c2..4b31ab5 100644
--- a/tests/test_batch.py
+++ b/tests/test_batch.py
@@ -101,3 +101,14 @@ def test_summary_csv(folder: Path) -> None:
def test_csv_field_quoting() -> None:
assert batch._csv_field("plain") == "plain"
assert batch._csv_field('a"b;c') == '"a""b;c"'
+
+
+def test_read_without_quality_reports_none_mean_q(folder: Path) -> None:
+ by_name = {r.path.name: r for r in batch.scan_folder(folder)}
+ assert by_name["310.ab1"].mean_q is None
+ assert by_name["3730.ab1"].mean_q is not None
+ row = next(
+ line for line in batch.summary_csv(list(by_name.values())).splitlines()
+ if line.startswith("310.ab1")
+ )
+ assert row.split(";")[2] == ""
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..eb2290c
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,279 @@
+"""Command-line subcommands: verify-clones, qc, export (thin shell over the core)."""
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from Bio import SeqIO
+from openpyxl import load_workbook
+
+import chrom_cli
+from clone_fixtures import COI_GB, DATA, coi_reference, mutate
+
+ROOT = Path(__file__).resolve().parent.parent
+READS = ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1",
+ "Achl_ACHLO007-09_1_F.ab1", "Achl_ACHLO007-09_2_R.ab1"]
+
+
+@pytest.fixture
+def reads_dir(tmp_path: Path) -> Path:
+ d = tmp_path / "Данные клоны"
+ d.mkdir()
+ for name in READS:
+ shutil.copy(DATA / "real" / name, d / name)
+ return d
+
+
+def test_verify_clones_xlsx(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ out = tmp_path / "отчёт.xlsx"
+ code = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir),
+ "-o", str(out)])
+ assert code == 0
+ book = load_workbook(out)
+ assert book.sheetnames == ["Clones", "Differences"]
+ shown = capsys.readouterr().out
+ assert "Achl_ACHLO006-09" in shown and "ok" in shown
+
+
+def test_verify_clones_csv_region_and_errors(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ ref = tmp_path / "mutated.fasta"
+ seq = coi_reference()
+ ref.write_text(">mut\n" + mutate(seq, 250, "sub", "A" if seq[250] != "A" else "C") + "\n")
+ out = tmp_path / "rep.csv"
+ code = chrom_cli.main(["verify-clones", "--construct", str(ref), "--region", "7-647",
+ "--linear", "--min-q", "20", "--het-ratio", "0.4",
+ str(reads_dir), "-o", str(out)])
+ assert code == 1 # every clone carries the planted difference
+ assert "errors" in out.read_text(encoding="utf-8")
+ diffs = tmp_path / "rep_differences.csv"
+ assert diffs.exists() and "251" in diffs.read_text(encoding="utf-8")
+ assert "errors" in capsys.readouterr().out
+
+
+def test_verify_clones_input_errors(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ empty = tmp_path / "empty"
+ empty.mkdir()
+ out = tmp_path / "x.xlsx"
+ base = ["verify-clones", "-o", str(out)]
+ assert chrom_cli.main(base + ["--construct", str(COI_GB), str(empty)]) == 2
+ bad = tmp_path / "bad.gb"
+ bad.write_text("not a sequence file")
+ assert chrom_cli.main(base + ["--construct", str(bad), str(reads_dir)]) == 2
+ assert chrom_cli.main(base + ["--construct", str(COI_GB), "--region", "9-3",
+ "--linear", str(reads_dir)]) == 2
+ assert chrom_cli.main(base + ["--construct", str(COI_GB), "--region", "abc",
+ str(reads_dir)]) == 2
+ assert chrom_cli.main(base + ["--construct", str(COI_GB), "--pattern", "(",
+ str(reads_dir)]) == 2
+ assert chrom_cli.main(base + ["--construct", str(COI_GB),
+ str(tmp_path / "missing")]) == 2
+ assert capsys.readouterr().err.count("ab1kit:") >= 6
+ assert not out.exists()
+
+
+def test_unassigned_files_reported_on_stderr(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ shutil.copy(DATA / "real" / READS[0], reads_dir / "!.ab1")
+ out = tmp_path / "r.csv"
+ chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), "-o", str(out)])
+ assert "!.ab1" in capsys.readouterr().err
+
+
+def test_qc_stdout_and_file(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ assert chrom_cli.main(["qc", str(reads_dir)]) == 0
+ printed = capsys.readouterr().out
+ assert printed.startswith("file;length;mean_q") and READS[0] in printed
+ out = tmp_path / "qc.csv"
+ assert chrom_cli.main(["qc", str(reads_dir), "-o", str(out)]) == 0
+ assert READS[1] in out.read_text(encoding="utf-8")
+
+
+def test_export(reads_dir: Path, tmp_path: Path) -> None:
+ out = tmp_path / "вывод"
+ assert chrom_cli.main(["export", str(reads_dir), "--trim", "--fasta", "-o", str(out)]) == 0
+ files = sorted(p.name for p in out.glob("*.trimmed.fasta"))
+ assert len(files) == len(READS)
+ out2 = tmp_path / "fq"
+ assert chrom_cli.main(["export", str(reads_dir), "--trim", "-o", str(out2)]) == 0
+ assert len(list(out2.glob("*.trimmed.fastq"))) == len(READS)
+
+
+@pytest.mark.parametrize("argv", [[], ["x.ab1"]])
+def test_gui_path_unchanged(monkeypatch, argv) -> None:
+ import ui.main_window as mw
+ seen = []
+ monkeypatch.setattr(mw, "run", lambda a: seen.append(a) or 0)
+ assert chrom_cli.main(argv) == 0
+ assert seen == [argv]
+
+
+def _run_cli(*args, **kw):
+ return subprocess.run([sys.executable, "-m", "chrom_cli", *map(str, args)],
+ capture_output=True, cwd=ROOT, **kw)
+
+
+def test_cli_does_not_import_qt_and_survives_legacy_console(reads_dir: Path, tmp_path: Path) -> None:
+ out = tmp_path / "r.csv"
+ code = (
+ "import sys, chrom_cli\n"
+ f"rc = chrom_cli.main(['verify-clones','--construct',r'{COI_GB}',r'{reads_dir}','-o',r'{out}'])\n"
+ "assert not any(m == 'PySide6' or m.startswith('PySide6.') for m in sys.modules), 'Qt imported'\n"
+ "sys.exit(rc)\n"
+ )
+ env = {**__import__("os").environ, "PYTHONIOENCODING": "cp1251"}
+ env.pop("PYTHONUTF8", None)
+ proc = subprocess.run([sys.executable, "-c", code], capture_output=True, cwd=ROOT, env=env)
+ assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace")
+
+
+def test_cyrillic_paths_on_legacy_console(tmp_path: Path) -> None:
+ d = tmp_path / "Данные клоны"
+ d.mkdir()
+ for name in READS[:2]:
+ shutil.copy(DATA / "real" / name, d / name)
+ shutil.copy(DATA / "real" / READS[0], d / "Клон ж_1_F.ab1")
+ shutil.copy(DATA / "real" / READS[0], d / "Ё!.ab1")
+ env = {**__import__("os").environ, "PYTHONIOENCODING": "cp437"} # cannot encode Cyrillic
+ env.pop("PYTHONUTF8", None)
+ proc = subprocess.run(
+ [sys.executable, "-m", "chrom_cli", "verify-clones", "--construct", str(COI_GB),
+ str(d), "-o", str(tmp_path / "o.csv")],
+ capture_output=True, cwd=ROOT, env=env)
+ assert proc.returncode in (0, 1), proc.stderr.decode("utf-8", "replace")
+ assert b"Traceback" not in proc.stderr
+ assert "Клон ж".encode() in proc.stdout # utf-8 forced regardless of the console
+ assert "Ё!.ab1".encode() in proc.stderr
+
+
+def test_attached_console_uses_console_code_page(monkeypatch) -> None:
+ import builtins
+ import ctypes
+ import types
+
+ kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1, GetConsoleOutputCP=lambda: 866,
+ SetConsoleOutputCP=lambda cp: pytest.fail("must not change"))
+ monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False)
+ opened = []
+ monkeypatch.setattr(builtins, "open", lambda *a, **k: opened.append((a, k)) or object())
+ monkeypatch.setattr(sys, "stdout", None)
+ monkeypatch.setattr(sys, "stderr", None)
+ chrom_cli._attach_console()
+ assert [a[0] for a, _ in opened] == ["CONOUT$", "CONOUT$"]
+ assert all(k["encoding"] == "cp866" and k["errors"] == "replace" for _, k in opened)
+
+
+def test_qc_without_stdout(monkeypatch, reads_dir: Path) -> None:
+ monkeypatch.setattr(sys, "stdout", None)
+ assert chrom_cli.main(["qc", str(reads_dir)]) == 0 # nowhere to print, must not crash
+
+
+@pytest.mark.parametrize("text, expected", [("1-10", (0, 10)), ("600-50", (599, 50))])
+def test_parse_region(text, expected) -> None:
+ assert chrom_cli._parse_region(text) == expected
+
+
+@pytest.mark.parametrize("text", ["abc", "5", "0-10", "-3-4", "1-2-3", ""])
+def test_parse_region_rejects(text) -> None:
+ with pytest.raises(chrom_cli.CliError):
+ chrom_cli._parse_region(text)
+
+
+def test_wrap_region_circular_ok_linear_rejected(reads_dir: Path, tmp_path: Path) -> None:
+ base = ["verify-clones", "--construct", str(COI_GB), "--region", "600-50", str(reads_dir)]
+ assert chrom_cli.main(base + ["--linear", "-o", str(tmp_path / "a.csv")]) == 2
+ ref = tmp_path / "circ.fasta"
+ ref.write_text(">c\n" + coi_reference() + "\n") # FASTA defaults to circular
+ base[2] = str(ref)
+ assert chrom_cli.main(base + ["-o", str(tmp_path / "b.csv")]) in (0, 1)
+
+
+def test_bad_output_extension_rejected_before_verifying(reads_dir: Path, tmp_path: Path, monkeypatch) -> None:
+ import chrom_core.clones as clones
+ monkeypatch.setattr(clones, "verify_all", lambda *a, **k: pytest.fail("ran verify_all"))
+ assert chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir),
+ "-o", str(tmp_path / "r.txt")]) == 2
+
+
+@pytest.mark.parametrize("code_page", [0, 99999])
+def test_attached_console_unknown_code_page_falls_back_to_utf8(monkeypatch, code_page) -> None:
+ import builtins
+ import ctypes
+ import types
+
+ kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1,
+ GetConsoleOutputCP=lambda: code_page)
+ monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False)
+ opened = []
+ monkeypatch.setattr(builtins, "open", lambda *a, **k: opened.append(k) or object())
+ monkeypatch.setattr(sys, "stdout", None)
+ monkeypatch.setattr(sys, "stderr", None)
+ chrom_cli._attach_console()
+ assert len(opened) == 2
+ assert all(k["encoding"] == "utf-8" and k["errors"] == "replace" for k in opened)
+
+
+def test_attached_console_is_all_or_nothing(monkeypatch) -> None:
+ import builtins
+ import ctypes
+ import types
+
+ kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1, GetConsoleOutputCP=lambda: 866)
+ monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False)
+ calls = []
+
+ def fake_open(*a, **k):
+ calls.append(a)
+ if len(calls) == 2:
+ raise OSError("no console")
+ return types.SimpleNamespace(close=lambda: None)
+
+ monkeypatch.setattr(builtins, "open", fake_open)
+ monkeypatch.setattr(sys, "stdout", None)
+ monkeypatch.setattr(sys, "stderr", None)
+ chrom_cli._attach_console()
+ assert sys.stdout is None and sys.stderr is None
+
+
+def test_unusable_reads_reported_on_stderr(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ good = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir),
+ "-o", str(tmp_path / "a.csv")])
+ capsys.readouterr()
+ (reads_dir / "Achl_ACHLO006-09_3_F.ab1").write_bytes(b"not an ab1 file at all")
+ source = (DATA / "real" / READS[0]).read_bytes()
+ (reads_dir / "Achl_ACHLO006-09_4_F.ab1").write_bytes(source[:len(source) // 3])
+ out = tmp_path / "b.csv"
+ code = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir),
+ "-o", str(out)])
+ assert code == good
+ err = capsys.readouterr().err.splitlines()
+ for name in ("Achl_ACHLO006-09_3_F.ab1", "Achl_ACHLO006-09_4_F.ab1"):
+ assert any(line.startswith(f"ab1kit: {name}: unreadable") for line in err), err
+ assert "Achl_ACHLO006-09_3_F.ab1: unreadable" in out.read_text(encoding="utf-8")
+
+
+def test_read_issue_wording() -> None:
+ from chrom_core.clone_report import read_issue
+ from chrom_core.clones import ReadRef, ReadResult
+
+ ref = ReadRef(Path("a.ab1"), "c", "F")
+ assert read_issue(ReadResult(ref, None, error="no_alignment")) == \
+ "did not align to the construct"
+ assert read_issue(ReadResult(ref, None, error="bad header")) == "unreadable (bad header)"
+
+
+def test_cli_difference_count_matches_report(reads_dir: Path, tmp_path: Path, capsys) -> None:
+ ref = tmp_path / "mutated.fasta"
+ seq = coi_reference()
+ alt = lambda b: "A" if b != "A" else "C"
+ seq = mutate(mutate(seq, 250, "sub", alt(seq[250])), 300, "sub", alt(seq[300]))
+ ref.write_text(">mut\n" + seq + "\n")
+ out = tmp_path / "rep.csv"
+ chrom_cli.main(["verify-clones", "--construct", str(ref), "--region", "7-280",
+ "--linear", str(reads_dir), "-o", str(out)])
+ shown = capsys.readouterr().out
+ rows = [line.split(",") for line in out.read_text(encoding="utf-8").splitlines()[1:]]
+ assert rows
+ for row in rows:
+ line = next(l for l in shown.splitlines() if l.startswith(row[0] + " "))
+ assert line.rstrip().endswith(f"differences {row[4]}")
diff --git a/tests/test_clone_report.py b/tests/test_clone_report.py
new file mode 100644
index 0000000..905768e
--- /dev/null
+++ b/tests/test_clone_report.py
@@ -0,0 +1,199 @@
+"""CSV / Excel reports for clone verification, built from real reads (HM417896 COI)."""
+
+from __future__ import annotations
+
+import csv
+import io
+from dataclasses import replace
+
+from pathlib import Path
+
+import pytest
+from openpyxl import load_workbook
+
+from chrom_core import batch
+from chrom_core.clone_report import (
+ STATUS_HEX,
+ clone_report_xlsx,
+ clones_csv,
+ differences_csv,
+)
+from chrom_core.clones import Grouping, ReadRef, verify_all, verify_clone
+from chrom_core.reference import Construct, load_construct
+from clone_fixtures import COI_GB, DATA, REAL_READS, coi_reference, mutate
+
+F = DATA / "real" / "Achl_ACHLO006-09_1_F.ab1"
+R = DATA / "real" / "Achl_ACHLO006-09_2_R.ab1"
+REGION = (6, 647)
+P = 250 # inside the region
+OUTSIDE = 652 # covered by F only, outside the region
+
+assert F in REAL_READS and R in REAL_READS
+
+
+@pytest.fixture(scope="module")
+def coi() -> str:
+ return coi_reference()
+
+
+def _reads(clone: str, *paths_primers) -> list[ReadRef]:
+ return [ReadRef(p, clone, primer) for p, primer in paths_primers]
+
+
+@pytest.fixture(scope="module")
+def reports(coi):
+ """c1: clean (ok) and c2: substitution at P and outside-region one at OUTSIDE."""
+ clean = verify_clone(Construct("c", coi, False), REGION, _reads("c1", (F, "F"), (R, "R")))
+ bad_ref = mutate(mutate(coi, P, "sub", "G" if coi[P] != "G" else "A"),
+ OUTSIDE, "sub", "G" if coi[OUTSIDE] != "G" else "A")
+ bad = verify_clone(Construct("c", bad_ref, False), REGION, _reads("c2", (F, "F"), (R, "R")))
+ return [clean, bad]
+
+
+def _rows(text: str) -> list[list[str]]:
+ return list(csv.reader(io.StringIO(text)))
+
+
+def test_clones_csv_header_and_rows(reports):
+ rows = _rows(clones_csv(reports))
+ assert rows[0] == ["clone", "verdict", "region_coverage_pct", "both_strands_pct",
+ "differences", "reads", "read_issues"]
+ assert len(rows) == 3
+ c1, c2 = rows[1], rows[2]
+ assert c1[:2] == ["c1", "ok"]
+ assert c1[2] == "100.0" and c1[3] == "97.0" # F and R overlap on most of the region
+ assert c1[4] == "0"
+ assert c1[5] == f"{F.name}|{R.name}" and c1[6] == ""
+ assert c2[:2] == ["c2", "errors"]
+ assert c2[4] == "1" # in-region, not low quality: the outside one is not counted
+
+
+def test_clones_csv_empty():
+ assert clones_csv([]).splitlines() == [
+ "clone,verdict,region_coverage_pct,both_strands_pct,differences,reads,read_issues"]
+
+
+def _diff(**kw):
+ from chrom_core.clones import CloneDifference
+
+ base = dict(ref_pos=1, kind="substitution", ref_base="A", read_base="G", support=1,
+ against=0, low_quality=0, status="confirmed", in_region=True)
+ return CloneDifference(**{**base, **kw})
+
+
+def test_differences_column_counts_what_the_verdict_uses():
+ from chrom_core.clones import CloneReport
+
+ diffs = (_diff(), _diff(ref_pos=2, status="disputed", against=1),
+ _diff(ref_pos=3, status="low_quality", support=0, low_quality=2),
+ _diff(ref_pos=4, in_region=False))
+ report = CloneReport("c", (), {}, diffs, (), "errors")
+ assert report.error_differences == diffs[:2]
+ assert _rows(clones_csv([report]))[1][4] == "2"
+
+
+def test_read_issues_column():
+ from chrom_core.clones import CloneReport, ReadResult
+
+ ok = verify_clone(Construct("c", coi_reference(), False), REGION,
+ _reads("c", (F, "F"))).reads[0]
+ results = (
+ ok,
+ ReadResult(ReadRef(Path("x.ab1"), "c", "F"), None, error="not an ABIF file"),
+ ReadResult(ReadRef(Path("n.ab1"), "c", "F"), None, error="no_alignment"),
+ replace(ok, ref=ReadRef(Path("y.ab1"), "c", "R"), ambiguous=True),
+ )
+ report = CloneReport("c", results, {}, (), (), "no_data")
+ assert _rows(clones_csv([report]))[1][6] == (
+ "x.ab1: unreadable (not an ABIF file); n.ab1: did not align to the construct; "
+ "y.ab1: ambiguous placement")
+
+
+def test_differences_csv_header_position_and_notes(reports):
+ rows = _rows(differences_csv(reports))
+ assert rows[0] == ["clone", "position", "kind", "ref", "read", "effect",
+ "support", "against", "status", "note"]
+ body = rows[1:]
+ assert len(body) == len(reports[1].differences) == 2 # clean clone contributes none
+ by_pos = {int(r[1]): r for r in body}
+ assert set(by_pos) == {P + 1, OUTSIDE + 1} # 1-based
+ inside, outside = by_pos[P + 1], by_pos[OUTSIDE + 1]
+ assert inside[0] == "c2" and inside[2] == "substitution"
+ assert inside[8] == "confirmed" and inside[9] == ""
+ assert inside[6] == "2" and inside[7] == "0"
+ assert outside[9] == "outside region"
+
+
+def test_differences_note_variants():
+ from chrom_core.clone_report import _note
+ from chrom_core.clones import CloneDifference
+
+ def diff(**kw):
+ base = dict(ref_pos=1, kind="substitution", ref_base="A", read_base="G", support=1,
+ against=0, low_quality=0, status="confirmed", in_region=True)
+ return CloneDifference(**{**base, **kw})
+
+ assert _note(diff()) == ""
+ assert _note(diff(status="disputed", against=1)) == "disputed: 1 read against"
+ assert _note(diff(status="disputed", against=3)) == "disputed: 3 reads against"
+ assert _note(diff(status="low_quality", support=0, low_quality=2)) == "low quality: 2 reads"
+ assert _note(diff(status="low_quality", support=0, low_quality=1, in_region=False)) == \
+ "low quality: 1 read / outside region"
+
+
+def test_csv_quotes_commas_and_quotes():
+ from chrom_core.clones import CloneReport
+ weird = CloneReport('a,"b"', (), {}, (), (), "no_data")
+ rows = _rows(clones_csv([weird]))
+ assert rows[1][0] == 'a,"b"'
+ assert len(rows[1]) == 7
+ assert batch.csv_field("x,y", sep=",") == '"x,y"'
+ assert batch.csv_field("x,y") == "x,y"
+
+
+def test_xlsx_sheets_and_fill(reports, tmp_path):
+ path = tmp_path / "r.xlsx"
+ clone_report_xlsx(path, reports, "COI")
+ book = load_workbook(path)
+ assert book.sheetnames == ["Clones", "Differences"]
+ clones, diffs = book["Clones"], book["Differences"]
+ assert [c.value for c in clones[1]] == ["clone", "verdict", "region_coverage_pct",
+ "both_strands_pct", "differences", "reads",
+ "read_issues"]
+ assert clones.max_row == 3
+ assert clones["B2"].value == "ok" and clones["B3"].value == "errors"
+ assert clones["B3"].fill.fgColor.rgb[-6:] == STATUS_HEX["errors"][0].lstrip("#").upper()
+ assert clones["B2"].fill.fgColor.rgb[-6:] == STATUS_HEX["ok"][0].lstrip("#").upper()
+ assert clones["B1"].font.bold
+ assert clones["C2"].value == 100.0
+ assert diffs.max_row == 3
+ assert diffs["B2"].value in (P + 1, OUTSIDE + 1)
+ assert book.properties.title == "COI"
+
+
+def test_status_hex_palette():
+ assert set(STATUS_HEX) == {"ok", "errors", "incomplete", "mixed", "no_data"}
+ for light, dark in STATUS_HEX.values():
+ for colour in (light, dark):
+ assert len(colour) == 7 and colour[0] == "#"
+ int(colour[1:], 16)
+ assert len({light for light, _ in STATUS_HEX.values()}) == 5
+
+
+def test_end_to_end_effect_from_cds_construct(coi, tmp_path):
+ construct = load_construct(COI_GB)
+ assert any(f.kind == "CDS" for f in construct.features)
+ alt = "G" if coi[P] != "G" else "A"
+ mutated = replace(construct, seq=mutate(construct.seq, P, "sub", alt))
+ grouping = Grouping([ReadRef(F, "c1", "F"), ReadRef(R, "c1", "R")])
+
+ [report] = verify_all(mutated, REGION, grouping)
+ [diff] = [d for d in report.differences if d.in_region]
+ assert diff.effect
+
+ [plain] = verify_all(mutated, REGION, grouping, annotate=None)
+ assert [d.effect for d in plain.differences] == [""] * len(plain.differences)
+
+ rows = _rows(differences_csv([report]))
+ row = next(r for r in rows[1:] if int(r[1]) == P + 1)
+ assert row[5] == diff.effect
diff --git a/tests/test_clone_verify.py b/tests/test_clone_verify.py
new file mode 100644
index 0000000..cc70771
--- /dev/null
+++ b/tests/test_clone_verify.py
@@ -0,0 +1,430 @@
+"""Per-clone verification: coverage, merged differences and verdicts.
+
+The base clone is the real pair ``Achl_ACHLO006-09`` F+R against its own COI
+reference (HM417896, 658 bp). Errors are introduced into the *reference*
+(``mutate``), so the unmodified reads report them, or into a read through an
+editing session (``write_read``), which is how a user's edits reach the check.
+"""
+
+from __future__ import annotations
+
+import random
+import time
+from concurrent.futures import CancelledError
+from dataclasses import replace
+
+import pytest
+
+from chrom_core import clones
+from chrom_core.clones import (
+ MIN_IDENTITY,
+ CloneReport,
+ VerifyParams,
+ load_read,
+ region_positions,
+ verify_all,
+ verify_clone,
+)
+from chrom_core.align import align_to_construct
+from chrom_core.clones import Grouping, ReadRef
+from chrom_core.editing import EditableRead
+from chrom_core.peaks import secondary_peak
+from chrom_core.reference import Construct, load_construct
+from clone_fixtures import (
+ DATA,
+ PUC19_GB,
+ coi_reference,
+ mutate,
+ perf_factor,
+ real_read,
+ with_secondary_peak,
+ write_read,
+)
+
+F_NAME = "Achl_ACHLO006-09_1_F.ab1"
+R_NAME = "Achl_ACHLO006-09_2_R.ab1"
+F_PATH = DATA / "real" / F_NAME
+R_PATH = DATA / "real" / R_NAME
+# The part of COI covered by both trimmed reads: F aligns 6..658, R 0..647.
+REGION = (6, 647)
+P = 250 # inside the region; context AAC[A]TGA, no homopolymer
+OUTSIDE = 652 # covered by F only, outside the region
+
+
+@pytest.fixture(scope="module")
+def coi() -> str:
+ return coi_reference()
+
+
+def _reads(f=F_PATH, r=R_PATH) -> list[ReadRef]:
+ out = []
+ if f is not None:
+ out.append(ReadRef(f, "c1", "F"))
+ if r is not None:
+ out.append(ReadRef(r, "c1", "R"))
+ return out
+
+
+def _construct(seq: str, circular: bool = False) -> Construct:
+ return Construct("c", seq, circular)
+
+
+def _f_index(coi: str, ref_pos: int) -> int:
+ """Displayed index in the F read of reference position ``ref_pos``."""
+ al = align_to_construct(real_read(F_NAME).seq, _construct(coi))
+ assert al.strand == "+"
+ return al.ref_to_read[ref_pos]
+
+
+def test_ok_clone(coi):
+ report = verify_clone(_construct(coi), REGION, _reads())
+ assert report.verdict == "ok" and report.region_differences == ()
+ assert report.region_coverage_pct == 100.0
+ assert 0 < report.both_strands_pct <= 100.0
+ assert set(report.coverage) == set(range(*REGION))
+ assert all(r.error == "" and not r.ambiguous for r in report.reads)
+
+
+def test_substitution_found_with_support(coi):
+ ref = mutate(coi, P, "sub", "G")
+ r = verify_clone(_construct(ref), REGION, _reads())
+ d, = r.region_differences
+ assert (d.ref_pos, d.kind, d.status, d.support, d.against) == (P, "substitution", "confirmed", 2, 0)
+ assert (d.ref_base, d.read_base, d.in_region) == ("G", "A", True)
+ assert r.verdict == "errors"
+
+
+def test_insertion_and_deletion_found(coi):
+ # base missing from the reference -> the reads show an insertion
+ r = verify_clone(_construct(mutate(coi, P, "del")), REGION, _reads())
+ d, = r.region_differences
+ assert (d.ref_pos, d.kind, d.read_base, d.status, d.support) == (P, "insertion", "A", "confirmed", 2)
+ assert r.verdict == "errors"
+ # extra base in the reference -> the reads show a deletion
+ r = verify_clone(_construct(mutate(coi, P, "ins", "G")), REGION, _reads())
+ d, = r.region_differences
+ assert (d.ref_pos, d.kind, d.ref_base, d.status, d.support) == (P, "deletion", "G", "confirmed", 2)
+ assert r.verdict == "errors"
+
+
+def _coi_with_extra(bases: str) -> Construct:
+ """Annotated COI with ``bases`` inserted before ``P`` (CDS stretched to match):
+ the unmodified reads then show a ``len(bases)``-bp deletion inside the CDS."""
+ c = load_construct(DATA / "refs" / "coi_HM417896.gb")
+ cds = next(f for f in c.features if f.kind == "CDS")
+ assert (P - cds.start - (cds.codon_start - 1)) % 3 == 0 # codon-aligned
+ features = tuple(replace(f, end=f.end + len(bases)) if f.end > P else f
+ for f in c.features)
+ return replace(c, seq=mutate(c.seq.upper(), P, "ins", bases), features=features)
+
+
+@pytest.mark.parametrize("extra, effect", [
+ ("GCT", "inframe_del"), ("G", "frameshift"), ("GC", "frameshift")])
+def test_multi_base_deletion_is_one_difference(extra, effect):
+ construct = _coi_with_extra(extra)
+ r = verify_clone(construct, (6, 647 + len(extra)), _reads())
+ d, = r.region_differences
+ assert (d.kind, d.read_base, d.status, d.support) == ("deletion", "-", "confirmed", 2)
+ assert len(d.ref_base) == len(extra)
+ assert construct.seq[d.ref_pos:d.ref_pos + len(extra)].upper() == d.ref_base
+ assert d.effect == effect
+ assert r.verdict == "errors"
+
+
+def test_disputed_when_reads_disagree(coi, tmp_path):
+ # A C peak 4x the called A under P, then the call edited to C: the trace
+ # agrees with the edit (no artificial double peak at P).
+ i = _f_index(coi, P)
+ model = EditableRead(with_secondary_peak(real_read(F_NAME), i, "C", 4.0))
+ model.set_base(i, "C")
+ assert secondary_peak(model.current(), i).ratio < VerifyParams().het_ratio
+ f = write_read(model.current(), tmp_path, F_NAME)
+ r = verify_clone(_construct(coi), REGION, _reads(f=f))
+ d, = r.region_differences
+ assert (d.ref_pos, d.kind, d.read_base, d.support, d.against, d.status) == (
+ P, "substitution", "C", 1, 1, "disputed")
+ assert r.verdict == "errors"
+
+
+def test_outside_region_does_not_affect_verdict(coi):
+ r = verify_clone(_construct(mutate(coi, OUTSIDE, "sub", "A")), REGION, _reads())
+ assert r.verdict == "ok" and r.region_differences == ()
+ d, = r.differences
+ assert (d.ref_pos, d.in_region, d.status) == (OUTSIDE, False, "confirmed")
+
+
+def test_incomplete_when_read_trimmed(coi, tmp_path):
+ model = EditableRead(real_read(F_NAME))
+ model.apply_trim(100, 400)
+ f = write_read(model.current(), tmp_path, F_NAME)
+ r = verify_clone(_construct(coi), REGION, _reads(f=f, r=None))
+ assert r.verdict == "incomplete"
+ assert r.region_coverage_pct < 100
+ assert r.both_strands_pct == 0.0
+
+
+def _mixed_read(coi, tmp_path, ratio: float, positions) -> object:
+ chrom = real_read(F_NAME)
+ for pos in positions:
+ i = _f_index(coi, pos)
+ other = next(b for b in "ACGT" if b != chrom.seq[i])
+ chrom = with_secondary_peak(chrom, i, other, ratio)
+ assert secondary_peak(chrom, i).ratio == pytest.approx(ratio, abs=0.05)
+ return write_read(chrom, tmp_path, F_NAME)
+
+
+MIXED_AT = (200, 300, 400)
+
+
+def test_mixed_50_percent_flagged(coi, tmp_path):
+ # The real R read itself has two second peaks in the region (595, 596):
+ # below the 3-position threshold, so the unmodified clone stays "ok".
+ baseline = verify_clone(_construct(coi), REGION, _reads()).mixed_positions
+ assert len(baseline) < VerifyParams().mixed_min_positions
+ f = _mixed_read(coi, tmp_path, 0.5, MIXED_AT)
+ r = verify_clone(_construct(coi), REGION, _reads(f=f))
+ assert r.verdict == "mixed"
+ added = tuple(p for p in r.mixed_positions if p not in baseline)
+ assert added == MIXED_AT and len(added) == 3
+
+
+def test_mixed_50_percent_single_read_counts_exactly(coi, tmp_path):
+ f = _mixed_read(coi, tmp_path, 0.5, MIXED_AT)
+ r = verify_clone(_construct(coi), REGION, _reads(f=f, r=None))
+ assert r.verdict == "mixed" and r.mixed_positions == MIXED_AT
+
+
+def test_mixed_30_percent_not_flagged(coi, tmp_path):
+ f = _mixed_read(coi, tmp_path, 0.3, MIXED_AT)
+ r = verify_clone(_construct(coi), REGION, _reads(f=f))
+ assert r.verdict == "ok"
+ assert not set(MIXED_AT) & set(r.mixed_positions)
+
+
+def test_no_data_for_foreign_read():
+ puc19 = load_construct(PUC19_GB)
+ raw = align_to_construct(real_read(F_NAME).seq, puc19)
+ assert raw.identity < MIN_IDENTITY or raw.covered < clones.MIN_ALIGNED_BP
+ r = verify_clone(puc19, None, [ReadRef(F_PATH, "c1", "F")])
+ assert r.verdict == "no_data"
+ res, = r.reads
+ assert (res.error, res.alignment) == ("no_alignment", None)
+
+
+def test_reverse_complemented_session_same_result(coi, tmp_path):
+ construct = _construct(mutate(coi, P, "sub", "G"))
+ plain = verify_clone(construct, REGION, _reads())
+ paths = []
+ for path, name in ((F_PATH, F_NAME), (R_PATH, R_NAME)):
+ model = EditableRead(real_read(name))
+ model.reverse_complement()
+ paths.append(write_read(model.current(), tmp_path, name))
+ flipped = verify_clone(construct, REGION, _reads(*paths))
+ assert flipped.verdict == plain.verdict == "errors"
+ assert flipped.differences == plain.differences
+ assert flipped.coverage == plain.coverage # strands are physical, not displayed
+ assert flipped.mixed_positions == plain.mixed_positions
+ assert [r.alignment.strand for r in flipped.reads] == ["-", "+"]
+
+
+def test_region_across_origin(coi, tmp_path):
+ # COI + a random spacer, so the circle is longer than a read; rotated so
+ # the origin falls inside COI and the region wraps it. The reads are cut
+ # to their COI part: their real flanks would align to the random spacer
+ # as noise.
+ paths = []
+ for name in (F_NAME, R_NAME):
+ model = EditableRead(real_read(name))
+ model.apply_trim(40, 640)
+ paths.append(write_read(model.current(), tmp_path, name))
+ rng = random.Random(2)
+ plasmid = mutate(coi, P, "sub", "G") + "".join(rng.choice("ACGT") for _ in range(400))
+ L, k = len(plasmid), 300
+ rotated = plasmid[k:] + plasmid[:k]
+ region = ((REGION[0] - k) % L, (REGION[1] - k) % L)
+ assert region[0] > region[1]
+ linear = verify_clone(_construct(plasmid), REGION, _reads(*paths))
+ circ = verify_clone(_construct(rotated, circular=True), region, _reads(*paths))
+ assert all(x.alignment.wraps and not x.ambiguous for x in circ.reads)
+ assert circ.verdict == linear.verdict == "errors"
+ assert [replace(d, ref_pos=(d.ref_pos + k) % L) for d in circ.differences] == list(
+ linear.differences)
+ assert circ.region_differences[0].ref_pos == (P - k) % L
+ assert circ.region_coverage_pct == linear.region_coverage_pct > 90.0
+ assert circ.both_strands_pct == linear.both_strands_pct > 50.0
+ assert {(p + k) % L: v for p, v in circ.coverage.items()} == linear.coverage
+
+
+def test_region_positions():
+ assert region_positions((2, 5), 10) == [2, 3, 4]
+ assert region_positions((8, 2), 10) == [8, 9, 0, 1]
+ assert region_positions(None, 4) == [0, 1, 2, 3]
+
+
+def test_read_without_quality_is_incomplete_not_errors(coi, tmp_path):
+ model = EditableRead(real_read(F_NAME))
+ model.apply_trim(20, 680) # drop the noisy ends: no quality, no trimming
+ chrom = model.current()
+ chrom = replace(chrom, quality=(0,) * len(chrom.seq))
+ f = write_read(chrom, tmp_path, F_NAME)
+ r = verify_clone(_construct(mutate(coi, P, "sub", "G")), REGION, _reads(f=f, r=None))
+ assert r.verdict == "incomplete"
+ assert r.region_coverage_pct == 0.0
+ d, = r.region_differences
+ assert (d.ref_pos, d.status, d.support, d.low_quality) == (P, "low_quality", 0, 1)
+
+
+def test_reference_N_is_not_a_difference(coi):
+ ref = mutate(coi, P, "sub", "N")
+ ref = mutate(ref, P + 10, "sub", "R") # IUPAC too (coi[260] is compatible or not, never counted)
+ r = verify_clone(_construct(ref), REGION, _reads())
+ assert r.verdict == "ok" and r.differences == ()
+
+
+def test_unreadable_file_marked_not_fatal(coi, tmp_path):
+ broken = tmp_path / "c1_X.ab1"
+ broken.write_bytes(b"definitely not an ABIF file")
+ reads = _reads() + [ReadRef(broken, "c1", "X")]
+ r = verify_clone(_construct(coi), REGION, reads)
+ assert r.verdict == "ok"
+ bad = r.reads[-1]
+ assert bad.error and bad.error != "no_alignment" and bad.alignment is None
+ assert all(x.alignment is not None for x in r.reads[:2])
+
+
+def test_ambiguous_read_excluded(coi, tmp_path):
+ # reads trimmed well inside COI: no overhang can favour one of the copies
+ paths = []
+ for name in (F_NAME, R_NAME):
+ model = EditableRead(real_read(name))
+ model.apply_trim(100, 500)
+ paths.append(write_read(model.current(), tmp_path, name))
+ r = verify_clone(_construct(coi + coi), None, _reads(*paths))
+ assert all(x.ambiguous and x.alignment.placements == 2 for x in r.reads)
+ assert r.verdict == "no_data"
+
+
+def test_genuine_repeat_single_read_is_no_data(coi, tmp_path):
+ model = EditableRead(real_read(F_NAME))
+ model.apply_trim(100, 500)
+ f = write_read(model.current(), tmp_path, F_NAME)
+ rng = random.Random(3)
+ spacer = "".join(rng.choice("ACGT") for _ in range(300))
+ r = verify_clone(_construct(coi + spacer + coi), None, _reads(f=f, r=None))
+ res, = r.reads
+ assert res.ambiguous and res.alignment.placements == 2
+ assert r.verdict == "no_data"
+ assert all(v == (0, 0) for v in r.coverage.values())
+
+
+def test_homopolymer_deletion_is_an_error_not_no_data(coi):
+ # co-optimal gap placements inside a run are one placement, not ambiguity (R15)
+ r = verify_clone(_construct(mutate(coi, 200, "ins", "T")), REGION, _reads()) # TTTT -> TTTTT
+ assert all(x.alignment.alternatives > 1 and not x.ambiguous for x in r.reads)
+ assert r.verdict == "errors"
+ assert [(d.kind, d.status) for d in r.region_differences] == [("deletion", "confirmed")]
+
+
+@pytest.mark.parametrize("region, circular", [
+ ((10, 10), True), # empty
+ ((0, 659), False), # past the end
+ ((-1, 10), False),
+ ((658, 5), True), # start outside the construct
+ ((500, 100), False), # wraps on a linear construct
+])
+def test_invalid_region_rejected(coi, monkeypatch, region, circular):
+ def boom(*a, **k):
+ raise AssertionError("aligned before validating the region")
+ monkeypatch.setattr(clones, "align_to_construct", boom)
+ monkeypatch.setattr(clones, "_load", boom)
+ with pytest.raises(ValueError):
+ verify_clone(_construct(coi, circular), region, _reads())
+ with pytest.raises(ValueError):
+ verify_all(_construct(coi, circular), region, Grouping(_reads()), VerifyParams())
+
+
+def test_verify_all_progress_and_cancel(coi):
+ grouping = Grouping([
+ ReadRef(F_PATH, "c2", "F"), ReadRef(R_PATH, "c2", "R"),
+ ReadRef(F_PATH, "c1", "F"),
+ ])
+ calls = []
+ reports = verify_all(_construct(coi), REGION, grouping, VerifyParams(),
+ progress=lambda d, t: calls.append((d, t)))
+ assert [r.clone for r in reports] == ["c1", "c2"]
+ assert all(isinstance(r, CloneReport) for r in reports)
+ assert [r.verdict for r in reports] == ["incomplete", "ok"]
+ assert calls == [(1, 2), (2, 2)]
+
+ calls.clear()
+ with pytest.raises(CancelledError):
+ verify_all(_construct(coi), REGION, grouping, VerifyParams(),
+ progress=lambda d, t: calls.append((d, t)),
+ cancelled=lambda: bool(calls))
+ assert calls == [(1, 2)]
+
+
+def test_annotate_sets_effect(coi):
+ seen = []
+
+ def annotate(construct, diff):
+ seen.append((construct.name, diff.ref_pos))
+ return "effect!"
+
+ r = verify_clone(_construct(mutate(coi, P, "sub", "G")), REGION, _reads(), annotate=annotate)
+ assert [d.effect for d in r.differences] == ["effect!"]
+ assert seen == [("c", P)]
+
+
+def test_load_read_applies_session(tmp_path):
+ model = EditableRead(real_read(F_NAME))
+ model.set_base(10, "A")
+ model.apply_trim(5, 300)
+ path = write_read(model.current(), tmp_path, F_NAME)
+ chrom = load_read(path)
+ assert chrom.seq == "".join(model.seq)
+ assert chrom.quality == tuple(model.quality)
+
+
+# Measured 2026-10-30 on the developer machine, after placements (R15) were
+# added: 15.1-15.5 s (3 runs; the exact DP alignment against 10 kb dominates).
+# LIMIT = 2 x 15.5, rounded up.
+LIMIT = 32.0
+
+
+@pytest.mark.perf
+def test_performance_12_clones_x_4_reads(coi):
+ rng = random.Random(1)
+ flank = "".join(rng.choice("ACGT") for _ in range(10_000 - len(coi)))
+ construct = _construct(coi + flank) # ~10 kb
+ names = ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1",
+ "Achl_ACHLO007-09_1_F.ab1", "Achl_ACHLO007-09_2_R.ab1"]
+ grouping = Grouping([
+ ReadRef(DATA / "real" / n, f"clone{c}", n[-5]) for c in range(12) for n in names
+ ])
+ t = time.perf_counter()
+ reports = verify_all(construct, None, grouping, VerifyParams())
+ elapsed = time.perf_counter() - t
+ print(f"verify_all 12x4 on {len(construct.seq)} bp: {elapsed:.2f} s")
+ assert len(reports) == 12
+ assert all(r.verdict != "no_data" for r in reports)
+ assert not any(x.ambiguous or x.error for r in reports for x in r.reads)
+ assert elapsed < LIMIT * perf_factor()
+
+
+def test_deletion_runs_split_by_reads_and_join_across_origin():
+ from chrom_core.align import Difference
+
+ a, b = object(), object()
+
+ def dels(*specs):
+ return {(p, "deletion", "-"): (Difference(p, "deletion", "ACGTACGTAC"[p], "-", None),
+ list(reads), [30] * len(reads))
+ for p, reads in specs}
+
+ found = dels((0, [a]), (1, [a]), (4, [a, b]), (5, [a]), (8, [a]), (9, [a]))
+ found[(3, "substitution", "T")] = (Difference(3, "substitution", "T", "A", 3), [a], [30])
+ runs = lambda circ: [("".join(d.ref_base for d in run), run[0].ref_pos)
+ for run, _s, _q in clones._deletion_runs(found, 10, circ)]
+ assert runs(False) == [("AC", 0), ("T", 3), ("A", 4), ("C", 5), ("AC", 8)]
+ assert runs(True) == [("T", 3), ("A", 4), ("C", 5), ("ACAC", 8)]
diff --git a/tests/test_construct_ui.py b/tests/test_construct_ui.py
new file mode 100644
index 0000000..9f3fd33
--- /dev/null
+++ b/tests/test_construct_ui.py
@@ -0,0 +1,1083 @@
+"""Construct screen widgets: summary bar and clone matrix (offscreen Qt).
+
+Reports come from ``verify_all`` on the real pair ``Achl_ACHLO006-09`` F+R
+against its COI reference, with errors / trimming / double peaks introduced
+through editing sessions (``write_read``), as in ``test_clone_verify``.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import pytest
+from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
+from PySide6.QtGui import QAction, QColor, QImage, QMouseEvent, QWheelEvent
+from PySide6.QtWidgets import QApplication
+
+from chrom_core.align import align_to_construct
+from chrom_core.clones import (
+ CloneDifference, CloneReport, Grouping, ReadRef, VerifyParams, verify_all,
+)
+from chrom_core.editing import EditableRead
+from chrom_core.reference import load_construct
+from clone_fixtures import COI_GB, DATA, perf_factor, real_read, with_secondary_peak, write_read
+
+F_NAME = "Achl_ACHLO006-09_1_F.ab1"
+R_NAME = "Achl_ACHLO006-09_2_R.ab1"
+F = DATA / "real" / F_NAME
+R = DATA / "real" / R_NAME
+REGION = (6, 647)
+P = 250 # substitution site inside the region
+OUTSIDE = 652 # covered by F only, outside the region
+MIXED_AT = (200, 300, 400)
+
+
+@pytest.fixture(scope="module")
+def qapp():
+ return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture(scope="module")
+def construct():
+ return load_construct(COI_GB)
+
+
+def _f_index(construct, ref_pos: int) -> int:
+ al = align_to_construct(real_read(F_NAME).seq, construct)
+ assert al.strand == "+"
+ return al.ref_to_read[ref_pos]
+
+
+@pytest.fixture(scope="module")
+def reports(construct, tmp_path_factory):
+ """ok (c1), errors confirmed (c2, F only), errors disputed (c3), incomplete (c4), mixed (c5)."""
+ folder = tmp_path_factory.mktemp("clones")
+ i = _f_index(construct, P)
+ # a C peak 4x the called base, then the call edited to C: the trace agrees
+ # with the edit (no artificial double peak); same at OUTSIDE
+ chrom = real_read(F_NAME)
+ o = _f_index(construct, OUTSIDE)
+ edits = {i: "C" if chrom.seq[i] != "C" else "G", o: "C" if chrom.seq[o] != "C" else "G"}
+ for index, base in edits.items():
+ chrom = with_secondary_peak(chrom, index, base, 4.0)
+ edited = EditableRead(chrom)
+ for index, base in edits.items():
+ edited.set_base(index, base)
+ sub_dir = folder / "sub"
+ sub_dir.mkdir()
+ f_sub = write_read(edited.current(), sub_dir, F_NAME)
+
+ trimmed = EditableRead(real_read(F_NAME))
+ trimmed.apply_trim(100, 400)
+ trim_dir = folder / "trim"
+ trim_dir.mkdir()
+ f_trim = write_read(trimmed.current(), trim_dir, F_NAME)
+
+ chrom = real_read(F_NAME)
+ for pos in MIXED_AT:
+ j = _f_index(construct, pos)
+ chrom = with_secondary_peak(chrom, j, next(b for b in "ACGT" if b != chrom.seq[j]), 0.5)
+ mix_dir = folder / "mix"
+ mix_dir.mkdir()
+ f_mix = write_read(chrom, mix_dir, F_NAME)
+
+ grouping = Grouping([
+ ReadRef(F, "c1", "F"), ReadRef(R, "c1", "R"),
+ ReadRef(f_sub, "c2", "F"),
+ ReadRef(f_sub, "c3", "F"), ReadRef(R, "c3", "R"),
+ ReadRef(f_trim, "c4", "F"),
+ ReadRef(f_mix, "c5", "F"), ReadRef(R, "c5", "R"),
+ ])
+ out = verify_all(construct, REGION, grouping)
+ assert [r.verdict for r in out] == ["ok", "errors", "errors", "incomplete", "mixed"]
+ assert [d.status for d in out[1].region_differences] == ["confirmed"]
+ assert [d.status for d in out[2].region_differences] == ["disputed"]
+ assert [d.ref_pos for d in out[1].differences if not d.in_region] == [OUTSIDE]
+ return out
+
+
+def _matrix(qapp, construct, reports, width=900, theme="light"):
+ from ui.construct.matrix import CloneMatrix
+
+ m = CloneMatrix()
+ m.set_theme(theme)
+ m.set_data(construct, REGION, reports)
+ m.resize(width, m.sizeHint().height())
+ return m
+
+
+def _send_mouse(widget, kind, pos: QPoint, button, buttons, modifiers=Qt.KeyboardModifier.NoModifier):
+ local = QPointF(pos)
+ event = QMouseEvent(kind, local, widget.mapToGlobal(local), button, buttons, modifiers)
+ QApplication.sendEvent(widget, event)
+
+
+# ------------------------------------------------------------------ summary bar
+
+
+def test_summary_shows_construct_and_counts(qapp, construct, reports):
+ from i18n import tr
+ from ui.construct.summary_bar import SummaryBar
+
+ bar = SummaryBar()
+ bar.set_data(construct, REGION, reports)
+ assert construct.name in bar.title_text()
+ info = bar.info_text()
+ assert "658" in info and "7–647" in info
+ assert tr("construct.linear") in info
+ counts = {v: bar.counter(v) for v in ("ok", "errors", "incomplete", "mixed")}
+ assert "1" in counts["ok"].text() and "2" in counts["errors"].text()
+ assert "1" in counts["incomplete"].text() and "1" in counts["mixed"].text()
+ assert bar.counter("no_data").isHidden() # zero counters are not shown
+
+
+def test_summary_counter_toggles_filter(qapp, construct, reports):
+ from ui.construct.summary_bar import SummaryBar
+
+ bar = SummaryBar()
+ bar.set_data(construct, REGION, reports)
+ seen: list[set] = []
+ bar.filter_changed.connect(seen.append)
+ bar.counter("ok").click()
+ assert seen[-1] == {"errors", "incomplete", "mixed", "no_data"}
+ bar.counter("errors").click()
+ assert seen[-1] == {"incomplete", "mixed", "no_data"}
+ bar.counter("ok").click()
+ assert seen[-1] == {"ok", "incomplete", "mixed", "no_data"}
+
+
+def test_summary_whole_construct_and_wrapping_region(qapp, construct, reports):
+ from dataclasses import replace
+
+ from i18n import tr
+ from ui.construct.summary_bar import SummaryBar
+
+ bar = SummaryBar()
+ bar.set_data(construct, None, reports)
+ assert tr("construct.region_all") in bar.info_text()
+ circ = replace(construct, circular=True)
+ bar.set_data(circ, (600, 50), reports)
+ assert "601–50" in bar.info_text() and tr("construct.circular") in bar.info_text()
+
+
+# ------------------------------------------------------------------ matrix
+
+
+def test_filter_hides_rows(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ assert m.visible_clones() == ["c1", "c2", "c3", "c4", "c5"]
+ m.set_filter({"errors"})
+ assert m.visible_clones() == ["c2", "c3"]
+ assert m.row_rect("c2").top() < m.row_rect("c3").top()
+ assert m.row_rect("c1").isNull()
+ m.set_filter({"ok", "mixed"})
+ assert m.visible_clones() == ["c1", "c5"]
+
+
+def test_hit_test_on_difference_mark(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ point = m.mark_point("c2", P)
+ assert m.hit_test(point) == ("c2", P)
+ assert m.hit_test(m.mark_point("c3", P)) == ("c3", P)
+ # the same x on the clean clone, and far from any mark: nothing
+ assert m.hit_test(QPoint(point.x(), m.row_rect("c1").center().y())) is None
+ away = QPoint(round(m.x_for(500)), m.row_rect("c2").center().y())
+ assert m.hit_test(away) is None
+ # mixed positions are marks too
+ assert m.hit_test(m.mark_point("c5", 300)) == ("c5", 300)
+
+
+def test_click_on_mark_and_on_row(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ diffs, clones = [], []
+ m.difference_clicked.connect(lambda c, p: diffs.append((c, p)))
+ m.clone_clicked.connect(clones.append)
+ left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton
+ point = m.mark_point("c2", P)
+ _send_mouse(m, QEvent.Type.MouseButtonPress, point, left, left)
+ _send_mouse(m, QEvent.Type.MouseButtonRelease, point, left, none)
+ assert diffs == [("c2", P)]
+ row = QPoint(round(m.x_for(500)), m.row_rect("c4").center().y())
+ _send_mouse(m, QEvent.Type.MouseButtonPress, row, left, left)
+ _send_mouse(m, QEvent.Type.MouseButtonRelease, row, left, none)
+ assert clones == ["c4"]
+
+
+def test_shift_drag_on_reference_sets_region(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ regions = []
+ m.region_changed.connect(regions.append)
+ y = m.reference_rect().center().y()
+ a, b = QPoint(round(m.x_for(300)), y), QPoint(round(m.x_for(100)), y)
+ left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton
+ shift = Qt.KeyboardModifier.ShiftModifier
+ _send_mouse(m, QEvent.Type.MouseButtonPress, a, left, left, shift)
+ _send_mouse(m, QEvent.Type.MouseMove, QPoint((a.x() + b.x()) // 2, y), none, left, shift)
+ _send_mouse(m, QEvent.Type.MouseMove, b, none, left, shift)
+ _send_mouse(m, QEvent.Type.MouseButtonRelease, b, left, none, shift)
+ assert len(regions) == 1
+ start, end = regions[0]
+ assert abs(start - 100) <= 2 and abs(end - 300) <= 2
+ assert m.view_range() == (0.0, float(len(construct.seq))) # no pan happened
+
+
+def test_wheel_zooms_and_drag_pans(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ L = float(len(construct.seq))
+ assert m.view_range() == (0.0, L)
+ centre = QPointF(m.x_for(329), m.row_rect("c1").center().y())
+ wheel = QWheelEvent(centre, m.mapToGlobal(centre), QPoint(), QPoint(0, 240),
+ Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
+ Qt.ScrollPhase.NoScrollPhase, False)
+ QApplication.sendEvent(m, wheel)
+ start, span = m.view_range()
+ assert span < L and start > 0
+ assert abs(m.x_for(329) - centre.x()) < 1.5 # zoom keeps the point under the cursor
+ left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton
+ p = centre.toPoint()
+ _send_mouse(m, QEvent.Type.MouseButtonPress, p, left, left)
+ _send_mouse(m, QEvent.Type.MouseMove, p + QPoint(60, 0), none, left)
+ _send_mouse(m, QEvent.Type.MouseButtonRelease, p + QPoint(60, 0), left, none)
+ assert m.view_range()[0] < start and m.view_range()[1] == span
+ # zooming out again never shows more than the construct
+ out = QWheelEvent(centre, m.mapToGlobal(centre), QPoint(), QPoint(0, -2400),
+ Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
+ Qt.ScrollPhase.NoScrollPhase, False)
+ QApplication.sendEvent(m, out)
+ assert m.view_range() == (0.0, L)
+
+
+@pytest.mark.parametrize("theme", ["light", "dark"])
+def test_renders_in_both_themes(qapp, construct, reports, theme):
+ from ui.construct.matrix import mark_colors
+ from ui.status import status_color
+
+ m = _matrix(qapp, construct, reports, theme=theme)
+ image = _render(m, QColor("#123456"))
+ assert image.pixelColor(2, 2) != QColor("#123456") # background painted
+ point = m.mark_point("c2", P)
+ assert image.pixelColor(point) == mark_colors()["confirmed"]
+ chip = m.chip_rect("c1") # sample the pill's padding, left of the text
+ assert image.pixelColor(chip.left() + 8, chip.center().y()) == status_color("ok", theme)
+
+
+def _render(m, fill=QColor("#123456")) -> QImage:
+ image = QImage(m.size(), QImage.Format.Format_ARGB32)
+ image.fill(fill)
+ m.render(image)
+ return image
+
+
+def test_disputed_mark_is_hollow(qapp, construct, reports):
+ from ui.construct.matrix import mark_colors
+
+ m = _matrix(qapp, construct, reports)
+ image = _render(m)
+ red = mark_colors()["disputed"]
+ centre = m.mark_point("c3", P)
+ assert image.pixelColor(centre) != red # hollow inside
+ edges = [image.pixelColor(centre + QPoint(dx, 0)) for dx in range(-4, 5)]
+ assert edges.count(red) == 2 # left and right outline
+ assert sum(1 for dx in range(-4, 5) if image.pixelColor(centre + QPoint(dx, 0)) != red
+ and abs(dx) <= 1) == 3 # >= 5 px wide outline
+ assert image.pixelColor(m.mark_point("c2", P)) == mark_colors()["confirmed"] # solid
+
+
+def test_out_of_region_difference_drawn_and_clickable(qapp, construct, reports):
+ from ui.theme import theme as palette
+
+ m = _matrix(qapp, construct, reports)
+ image = _render(m)
+ point = m.mark_point("c2", OUTSIDE)
+ colour = image.pixelColor(point)
+ assert colour.red() > colour.green() + 40 and colour.red() > colour.blue() + 40
+ assert m.hit_test(point) == ("c2", OUTSIDE)
+ clicked = []
+ m.difference_clicked.connect(lambda c, p: clicked.append((c, p)))
+ left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton
+ _send_mouse(m, QEvent.Type.MouseButtonPress, point, left, left)
+ _send_mouse(m, QEvent.Type.MouseButtonRelease, point, left, none)
+ assert clicked == [("c2", OUTSIDE)]
+ # outside the region the track is hatched ("not checked"); a gap in the
+ # region's coverage stays the plain track colour ("no coverage")
+ track = QColor(palette("light")["track"])
+ y = m.row_rect("c1").center().y()
+ hatched = [image.pixelColor(x, y + dy) for dx in range(10)
+ for x in [round(m.x_for(647)) + 2 + dx] for dy in range(-4, 5)
+ if x < round(m.x_for(len(construct.seq)))]
+ assert hatched and any(c != track for c in hatched)
+ y4 = m.row_rect("c4").center().y()
+ assert all(image.pixelColor(x, y4) == track
+ for x in range(round(m.x_for(450)), round(m.x_for(470))))
+
+
+def test_hit_test_while_zoomed(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ anchor = QPointF(m.x_for(P + 0.5), m.row_rect("c2").center().y())
+ zoom = QWheelEvent(anchor, m.mapToGlobal(anchor), QPoint(), QPoint(0, 120 * 12),
+ Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
+ Qt.ScrollPhase.NoScrollPhase, False)
+ QApplication.sendEvent(m, zoom)
+ assert m.view_range()[1] < 100
+ point = m.mark_point("c2", P)
+ assert m.hit_test(point) == ("c2", P)
+ neighbour = QPoint(round(m.x_for(P + 3.5)), point.y()) # three bases away
+ assert m.hit_test(neighbour) is None
+ assert m.hit_test(QPoint(point.x(), m.row_rect("c1").center().y())) is None
+
+
+def test_ruler_labels_are_one_based(qapp, construct, reports):
+ m = _matrix(qapp, construct, reports)
+ ticks = m.ruler_ticks()
+ assert ticks and all(v % ticks[0][0] == 0 for v, _ in ticks)
+ for v, x in ticks:
+ assert int(m.pos_at(x)) + 1 == v # the tick sits on base number v (1-based)
+
+
+def test_mark_at_last_position(qapp, construct):
+ from ui.construct.matrix import CloneMatrix, mark_colors
+
+ last = len(construct.seq) - 1
+ rep = _synthetic("end", "errors", len(construct.seq))
+ rep = CloneReport(rep.clone, (), rep.coverage, (
+ CloneDifference(last, "substitution", "A", "G", 2, 0, 0, "confirmed", True),), (), "errors")
+ m = CloneMatrix()
+ m.set_data(construct, None, [rep])
+ m.resize(900, m.sizeHint().height())
+ point = m.mark_point("end", last)
+ assert point.x() < m.chip_rect("end").left()
+ assert m.hit_test(point) == ("end", last)
+ assert _render(m).pixelColor(point) == mark_colors()["confirmed"]
+
+
+# measured ~12 ms (best of 5) on the dev machine for 12 rows x 200 runs on 10 kb;
+# the bound is about 2x that (it was ~440 ms before name widths were cached)
+PAINT_BUDGET_S = 0.025
+
+
+@pytest.mark.perf
+def test_paint_cost_10kb_12_rows(qapp):
+ import time
+
+ from chrom_core.reference import Construct
+ from ui.construct.matrix import CloneMatrix
+
+ length = 10_000
+ big = Construct("big", "ACGT" * (length // 4), False, ())
+ coverage = {p: ((1, 1) if (p // 50) % 2 else (1, 0)) for p in range(length)} # 200 runs
+ reps = [CloneReport(f"clone_{i}", (), coverage, (), (), "ok") for i in range(12)]
+ m = CloneMatrix()
+ m.set_data(big, None, reps)
+ m.resize(1200, m.sizeHint().height())
+ image = QImage(m.size(), QImage.Format.Format_ARGB32)
+ m.render(image) # warm-up
+ best = min(_timed(m, image, time) for _ in range(3))
+ assert best < PAINT_BUDGET_S * perf_factor(), f"paint took {best * 1000:.0f} ms"
+
+
+def _timed(m, image, time) -> float:
+ start = time.perf_counter()
+ m.render(image)
+ return time.perf_counter() - start
+
+
+def test_wrapping_region_draws_two_segments():
+ from ui.construct.matrix import segments
+
+ assert segments(10, 40, 100) == [(10, 40)]
+ assert segments(90, 5, 100) == [(90, 100), (0, 5)]
+ assert segments(0, 100, 100) == [(0, 100)]
+
+
+def _synthetic(clone: str, verdict: str, length: int) -> CloneReport:
+ coverage = {p: ((1, 1) if p % 50 else (1, 0)) for p in range(length)}
+ diffs = (CloneDifference(100, "substitution", "A", "G", 2, 0, 0, "confirmed", True),)
+ return CloneReport(clone, (), coverage, diffs if verdict == "errors" else (), (), verdict)
+
+
+def test_twelve_rows_fit_in_600_px(qapp, construct):
+ from ui.construct.matrix import CloneMatrix
+
+ verdicts = ["ok", "errors", "incomplete", "mixed", "no_data", "ok"] * 2
+ reps = [_synthetic(f"clone_{i + 1}", v, len(construct.seq)) for i, v in enumerate(verdicts)]
+ m = CloneMatrix()
+ m.set_data(construct, None, reps)
+ assert m.sizeHint().height() <= 600
+ m.resize(900, 600)
+ assert m.row_rect("clone_12").bottom() < 600
+ assert m.hit_test(m.mark_point("clone_2", 100)) == ("clone_2", 100)
+
+
+# ------------------------------------------------------------------ detail pane
+
+
+@pytest.fixture(scope="module")
+def pane_report(construct, tmp_path_factory):
+ """ACHLO006-09: F (+) and R (-) with a substitution at P in F's session."""
+ folder = tmp_path_factory.mktemp("pane")
+ i = _f_index(construct, P)
+ chrom = real_read(F_NAME)
+ base = "C" if chrom.seq[i] != "C" else "G"
+ chrom = with_secondary_peak(chrom, i, base, 4.0)
+ edited = EditableRead(chrom)
+ edited.set_base(i, base)
+ f_sub = write_read(edited.current(), folder, F_NAME)
+ out = verify_all(construct, REGION, Grouping([ReadRef(f_sub, "c1", "F"), ReadRef(R, "c1", "R")]))
+ report = out[0]
+ assert [r.alignment.strand for r in report.reads] == ["+", "-"]
+ assert [d.ref_pos for d in report.region_differences] == [P]
+ return report
+
+
+def _pane(qapp, construct, report):
+ from ui.construct.detail_pane import DetailPane
+
+ pane = DetailPane()
+ pane.resize(1000, 500)
+ pane.show_clone(report, construct)
+ return pane
+
+
+def test_displayed_index_converts_once_for_both_strands():
+ from chrom_core.align import ReadAlignment
+ from ui.construct.detail_pane import displayed_index
+
+ def al(strand, mapping):
+ return ReadAlignment(strand=strand, score=0, ref_start=0, ref_end=10, read_start=0, read_end=10,
+ ref_to_read=mapping, differences=[], identity=1.0, alternatives=1,
+ oriented_read="", blocks=[], ref_length=100)
+
+ plus, minus = al("+", {5: 3, 6: 4}), al("-", {5: 3, 6: 4})
+ assert displayed_index(plus, 5, 10) == 3
+ assert displayed_index(minus, 5, 10) == 10 - 1 - 3
+ assert displayed_index(minus, 50, 10) is None # not covered
+ # a deleted position falls back to the nearest mapped neighbour
+ gap = al("+", {5: 3, 7: 4})
+ assert displayed_index(gap, 6, 10) == 4
+
+
+def test_displayed_index_out_of_range_is_not_covered():
+ from chrom_core.align import ReadAlignment
+ from ui.construct.detail_pane import displayed_index
+
+ def al(strand):
+ return ReadAlignment(strand=strand, score=0, ref_start=0, ref_end=10, read_start=0,
+ read_end=10, ref_to_read={5: 8}, differences=[], identity=1.0,
+ alternatives=1, oriented_read="", blocks=[], ref_length=100)
+
+ # the chromatogram was shortened by an edit after the alignment was stored
+ assert displayed_index(al("+"), 5, 6) is None # index 8 >= 6
+ assert displayed_index(al("-"), 5, 6) is None # 6 - 1 - 8 < 0
+
+
+def test_pane_survives_alignment_past_a_shortened_read(qapp, construct, pane_report):
+ from dataclasses import replace
+
+ first = pane_report.reads[0]
+ far = {p: i + 100_000 for p, i in first.alignment.ref_to_read.items()}
+ stale = replace(pane_report, reads=(
+ replace(first, alignment=replace(first.alignment, ref_to_read=far)),
+ *pane_report.reads[1:]))
+ pane = _pane(qapp, construct, stale)
+ pane.show_position(P)
+ assert not pane._notes[0].isHidden() and pane._notes[1].isHidden()
+
+
+def test_pane_one_chromatogram_per_aligned_read(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ assert len(pane.views()) == 2
+ assert pane.table.rowCount() == len(pane_report.differences)
+
+
+def test_pane_centres_every_read_on_the_position(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ pane.show_position(P)
+ for result, view in zip(pane_report.reads, pane.views()):
+ n = len(view.chrom.seq)
+ idx = result.alignment.ref_to_read[P]
+ want = idx if result.alignment.strand == "+" else n - 1 - idx
+ assert view.highlights == [(want, want + 1)]
+ centre = view.offset + view.width() / view.zoom / 2
+ assert abs(centre - view.chrom.peak_indices[want]) <= 1.0
+ letters, mapping = view.reference
+ assert mapping[P] == want
+ assert len({v.zoom for v in pane.views()}) == 1 # shared scale
+
+
+def test_pane_reference_strip_shows_neighbourhood(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ pane.show_position(P)
+ text = pane.reference_text()
+ assert construct.seq[P - 10:P] in text and construct.seq[P + 1:P + 11] in text
+ assert f"[{construct.seq[P]}]" in text
+
+
+def test_pane_table_row_selection_shows_position(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ seen = []
+ orig = pane.show_position
+ pane.show_position = lambda p: (seen.append(p), orig(p))
+ pane.table.selectRow(0)
+ assert seen[-1] == P == pane_report.differences[0].ref_pos
+ assert pane.table.item(0, 0).text() == str(P + 1)
+ assert "→" in pane.table.item(0, 1).text()
+
+
+def test_pane_double_click_opens_read(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ opened = []
+ pane.open_read.connect(opened.append)
+ view = pane.views()[1]
+ _send_mouse(view, QEvent.Type.MouseButtonDblClick, QPoint(20, 20),
+ Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton)
+ assert opened == [pane_report.reads[1].ref.path]
+
+
+def test_pane_effect_labels_translated(qapp, construct, pane_report):
+ from dataclasses import replace
+ from i18n import tr
+
+ d = pane_report.differences[0]
+ report = replace(pane_report, differences=(replace(d, effect="frameshift"), replace(d, effect="D45N")))
+ pane = _pane(qapp, construct, report)
+ assert pane.table.item(0, 2).text() == tr("effect.frameshift")
+ assert pane.table.item(1, 2).text() == "D45N"
+
+
+def test_pane_status_cells_keep_one_colour_per_meaning(qapp, construct, pane_report):
+ from dataclasses import replace
+ from ui import status
+
+ d = pane_report.differences[0]
+ diffs = tuple(replace(d, status=s) for s in ("confirmed", "disputed", "low_quality"))
+ pane = _pane(qapp, construct, replace(pane_report, differences=diffs))
+ bg = [pane.table.item(r, 4).background().color() for r in range(3)]
+ red = status.status_color("errors", "light")
+ assert bg[0] == red
+ assert bg[1] != red and bg[1].red() > bg[1].green() and bg[1].red() > bg[1].blue() # weaker red tint
+ assert bg[1] != status.status_color("incomplete", "light")
+ assert bg[2] == status.status_color("no_data", "light")
+ pane.set_theme("dark")
+ assert pane.table.item(0, 4).background().color() == status.status_color("errors", "dark")
+
+
+def test_pane_dims_rows_outside_region(qapp, construct, pane_report):
+ from dataclasses import replace
+ from i18n import tr
+
+ d = pane_report.differences[0]
+ pane = _pane(qapp, construct, replace(pane_report, differences=(d, replace(d, in_region=False))))
+ inside, outside = pane.table.item(0, 0), pane.table.item(1, 0)
+ assert not inside.font().italic() and inside.toolTip() == ""
+ assert outside.font().italic() and outside.toolTip() == tr("matrix.legend.outside")
+ assert outside.foreground().color() != inside.foreground().color()
+
+
+def test_pane_zoom_is_shared_and_kept(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ views = pane.views()
+ assert len({v.zoom for v in views}) == 1
+ views[0].zoom_in() # before any position is shown
+ assert views[1].zoom == views[0].zoom
+ pane.show_position(P)
+ chosen = views[0].zoom
+ assert views[1].zoom == chosen # show_position keeps the user's zoom
+ views[0].zoom_in()
+ assert views[1].zoom == views[0].zoom > chosen
+ for result, view in zip(pane_report.reads, views):
+ n = len(view.chrom.seq)
+ idx = result.alignment.ref_to_read[P]
+ want = idx if result.alignment.strand == "+" else n - 1 - idx
+ centre = view.offset + view.width() / view.zoom / 2
+ assert abs(centre - view.chrom.peak_indices[want]) <= 1.0
+ pane.resize(1100, 500)
+ assert views[0].zoom == views[1].zoom and views[0].zoom != chosen
+
+
+def test_pane_failed_and_ambiguous_reads_are_shown(qapp, construct, pane_report):
+ from dataclasses import replace
+ from PySide6.QtWidgets import QLabel
+ from chrom_core.clones import ReadResult
+ from i18n import tr
+
+ f, r = pane_report.reads
+ bad = ReadResult(ReadRef(Path("bad_read.ab1"), "c1", "F"), None, error="no_alignment")
+ report = replace(pane_report, reads=(f, bad, replace(r, ambiguous=True)))
+ pane = _pane(qapp, construct, report)
+ assert len(pane.views()) == 2
+ texts = [lab.text() for lab in pane.findChildren(QLabel)]
+ assert any("bad_read.ab1" in s and tr("detail.err.no_alignment") in s for s in texts)
+ assert tr("detail.ambiguous") in pane.views()[1].toolTip()
+ assert tr("detail.ambiguous") not in pane.views()[0].toolTip()
+ gone = ReadResult(ReadRef(Path("missing.ab1"), "c1", "F"), r.alignment)
+ pane = _pane(qapp, construct, replace(pane_report, reads=(f, gone)))
+ assert len(pane.views()) == 1
+ assert any("missing.ab1" in lab.text() for lab in pane.findChildren(QLabel))
+
+
+def test_pane_dims_read_that_does_not_cover_position(qapp, construct, pane_report):
+ pane = _pane(qapp, construct, pane_report)
+ covers = pane_report.reads[1].alignment
+ outside = next(p for p in range(len(construct.seq)) if not covers.covers(p)
+ and pane_report.reads[0].alignment.covers(p))
+ pane.show_position(outside)
+ assert pane.views()[1].graphicsEffect() is not None
+ assert pane.views()[1].highlights == []
+ assert pane.views()[0].graphicsEffect() is None
+ pane.show_position(P)
+ assert pane.views()[1].graphicsEffect() is None
+
+
+def test_pane_renders_png(qapp, construct, pane_report):
+ target = os.environ.get("AB1KIT_DETAIL_PNG")
+ if not target:
+ pytest.skip("set AB1KIT_DETAIL_PNG to render the pane")
+ pane = _pane(qapp, construct, pane_report)
+ pane.show_position(P)
+ assert pane.grab().save(target)
+
+
+def test_pane_survives_corrupt_ab1(qapp, construct, pane_report, tmp_path):
+ from dataclasses import replace
+ from PySide6.QtWidgets import QLabel
+ from chrom_core.abif import read_ab1
+ from chrom_core.clones import ReadResult
+
+ raw = bytearray((Path(__file__).resolve().parents[1] / "examples" / "310.ab1").read_bytes())
+ directory = int.from_bytes(raw[26:30], "big")
+ raw[directory + 12] ^= 0xFF # deterministic: BioPython's ABIF parser asserts
+ corrupt = tmp_path / "corrupt.ab1"
+ corrupt.write_bytes(raw)
+ with pytest.raises(AssertionError):
+ read_ab1(corrupt)
+
+ f, r = pane_report.reads
+ broken = ReadResult(ReadRef(corrupt, "c1", "F"), f.alignment)
+ pane = _pane(qapp, construct, replace(pane_report, reads=(broken, r)))
+ assert len(pane.views()) == 1
+ assert any("corrupt.ab1" in lab.text() for lab in pane.findChildren(QLabel))
+ pane.show_position(P)
+ assert pane.views()[0].highlights
+
+
+# ------------------------------------------------------------------ workspace (Task 16)
+
+REAL_DIR = DATA / "real"
+
+
+@pytest.fixture
+def window(qapp, monkeypatch):
+ """Main window with config writes disabled and message boxes recorded."""
+ from PySide6.QtWidgets import QMessageBox
+
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ for target in ("ui.main_window.save_config", "ui.read_view.read_view.save_config",
+ "ui.construct.workspace.save_config"):
+ monkeypatch.setattr(target, lambda *a, **k: None)
+ messages: list[str] = []
+
+ def record(_parent, title, text, *a, **k):
+ messages.append(f"{title}: {text}")
+ return QMessageBox.StandardButton.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", record)
+ monkeypatch.setattr(QMessageBox, "critical", record)
+ w = MainWindow(AppConfig(recent_files=[]))
+ w.messages = messages
+ yield w
+ w._cancel_tasks()
+ w.close()
+ _delete(qapp, w)
+
+
+def _delete(qapp, widget):
+ """Really destroy a test window (closed windows otherwise live on and make
+ every later theme switch restyle them)."""
+ from PySide6.QtCore import QCoreApplication, QEvent
+
+ widget.deleteLater()
+ QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
+ qapp.processEvents()
+
+
+def _wait_idle(qapp, ws, timeout=30.0):
+ import time
+
+ deadline = time.monotonic() + timeout
+ while ws.is_verifying() and time.monotonic() < deadline:
+ qapp.processEvents()
+ time.sleep(0.005)
+ qapp.processEvents()
+ assert not ws.is_verifying()
+
+
+def _drop(widget, paths):
+ from PySide6.QtCore import QMimeData, QUrl
+ from PySide6.QtGui import QDropEvent
+
+ mime = QMimeData()
+ mime.setUrls([QUrl.fromLocalFile(str(p)) for p in paths])
+ event = QDropEvent(QPointF(10, 10), Qt.DropAction.CopyAction, mime,
+ Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier)
+ widget.dropEvent(event)
+
+
+def _count_starts(ws):
+ calls = []
+ original = ws._start_task
+
+ def counting(*a, **k):
+ calls.append(k.get("key"))
+ return original(*a, **k)
+
+ ws._start_task = counting
+ return calls
+
+
+def _loaded(qapp, window, construct_path=COI_GB, reads=(REAL_DIR,)):
+ ws = window.workspace
+ _drop(ws, [construct_path, *reads])
+ _wait_idle(qapp, ws)
+ return ws
+
+
+def test_workspace_is_first_tab_and_shows_empty_state(window):
+ ws = window.workspace
+ assert window.tabs.indexOf(ws) == 0
+ assert window.tabs.currentWidget() is ws
+ assert window.tabs.indexOf(window.read_view) == 1
+ window.show()
+ assert ws.empty_state.isVisible()
+ assert not ws.matrix.isVisible()
+ texts = " ".join(z.text() for z in ws.empty_state.zones())
+ assert "FASTA" in texts and ".ab1" in texts
+
+
+def test_drop_construct_and_reads_folder(qapp, window, construct):
+ ws = _loaded(qapp, window)
+ window.show()
+ assert not ws.empty_state.isVisible() and ws.matrix.isVisible()
+ expected = verify_all(construct, None, ws.grouping())
+ assert ws.matrix.visible_clones() == [r.clone for r in expected]
+ assert len(ws.matrix.visible_clones()) == 4
+ assert [r.verdict for r in ws.reports()] == [r.verdict for r in expected]
+ # same-lineage clones against the unmodified reference: no errors
+ same = [r for r in ws.reports() if r.clone.startswith(("Achl_ACHLO006", "Achl_ACHLO007"))]
+ assert len(same) == 2 and all(r.verdict in ("ok", "incomplete") for r in same)
+ assert ws.summary.title_text() == construct.name
+ assert window.messages == []
+
+
+def test_matrix_click_shows_details_and_open_read_switches_tab(qapp, window, tmp_path, construct):
+ seq = construct.seq
+ mutant = "G" if seq[P] != "G" else "T"
+ fasta = tmp_path / "mut.fa"
+ fasta.write_text(f">mut\n{seq[:P]}{mutant}{seq[P + 1:]}\n")
+ f_copy = tmp_path / F_NAME
+ f_copy.write_bytes(F.read_bytes())
+ ws = _loaded(qapp, window, fasta, [f_copy])
+ ws.set_region((100, 600))
+ _wait_idle(qapp, ws)
+ (report,) = ws.reports()
+ assert report.verdict == "errors" and [d.ref_pos for d in report.region_differences] == [P]
+
+ ws.matrix.difference_clicked.emit(report.clone, P)
+ assert len(ws.detail.views()) == 1
+
+ ws.detail.open_read.emit(f_copy)
+ assert window.tabs.currentWidget() is window.read_view
+ assert window.read_model is not None
+
+ # edit the read at the difference: the check re-runs and the verdict changes
+ window.apply_base_change(_f_index(construct, P), mutant)
+ assert ws.is_verifying()
+ _wait_idle(qapp, ws)
+ assert [r.verdict for r in ws.reports()] == ["ok"]
+
+ window.undo_read()
+ _wait_idle(qapp, ws)
+ assert [r.verdict for r in ws.reports()] == ["errors"]
+
+
+def test_read_edit_outside_project_does_not_reverify(qapp, window):
+ ws = _loaded(qapp, window)
+ starts = _count_starts(ws)
+ examples = Path(__file__).resolve().parents[1] / "examples"
+ window.read_view.read_edited.emit(examples / "310.ab1")
+ assert starts == []
+
+
+def test_project_roundtrip_restores_region_grouping_params(qapp, window, tmp_path):
+ ws = _loaded(qapp, window)
+ clone = ws.grouping().reads[0]
+ grouping = ws.grouping()
+ grouping.assign(clone.path, "renamed", clone.primer)
+ ws.set_grouping(grouping)
+ ws.set_region((100, 600))
+ ws.controls.min_q.setValue(25)
+ _wait_idle(qapp, ws)
+ project = tmp_path / "check.ab1proj"
+ assert ws.save_project(project)
+
+ ws.clear()
+ assert ws.reports() == [] and ws.region() is None
+ assert ws.load_project(project)
+ _wait_idle(qapp, ws)
+ assert ws.region() == (100, 600)
+ assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (101, 600)
+ assert ws.grouping().to_dict() == grouping.to_dict()
+ assert ws.params().min_q == 25 and ws.controls.min_q.value() == 25
+ assert "renamed" in ws.matrix.visible_clones()
+ assert window.messages == []
+
+
+def test_broken_project_shows_message_and_empty_workspace(qapp, window, tmp_path):
+ ws = _loaded(qapp, window)
+ bad = tmp_path / "bad.ab1proj"
+ bad.write_text("{not json", encoding="utf-8")
+ assert ws.load_project(bad) is False
+ assert len(window.messages) == 1 and "bad.ab1proj" in window.messages[0]
+ window.show()
+ assert ws.empty_state.isVisible()
+ assert ws.reports() == [] and ws.grouping().reads == []
+
+
+def test_invalid_region_shows_message_and_does_not_run(qapp, window):
+ ws = _loaded(qapp, window) # COI .gb is linear
+ starts = _count_starts(ws)
+ assert ws.set_region((500, 100)) is False
+ assert starts == [] and ws.region() is None
+ assert len(window.messages) == 1
+ assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (1, 658)
+ # the same through the spin boxes (1-based, inclusive): 500..658 is fine ...
+ ws.controls.start_spin.setValue(500)
+ assert starts == ["verify"] and ws.region() == (499, 658)
+ # ... 500..100 wraps the origin of a linear construct: refused, spins restored
+ ws.controls.end_spin.setValue(100)
+ assert starts == ["verify"] and ws.region() == (499, 658) and len(window.messages) == 2
+ assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (500, 658)
+ _wait_idle(qapp, ws)
+
+
+def test_region_spins_and_matrix_drag_stay_in_sync(qapp, window):
+ ws = _loaded(qapp, window)
+ starts = _count_starts(ws)
+ ws.matrix.region_changed.emit((50, 400))
+ assert ws.region() == (50, 400) and starts == ["verify"]
+ assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (51, 400)
+ ws.controls.whole_button.click()
+ assert ws.region() is None and starts == ["verify", "verify"]
+ _wait_idle(qapp, ws)
+
+
+def test_cancelled_reverification_keeps_previous_verdicts(qapp, window):
+ ws = _loaded(qapp, window)
+ before = ws.reports()
+ shown = ws.matrix.visible_clones()
+ info = ws.summary.info_text()
+ ws.set_region((100, 600))
+ assert ws.is_verifying()
+ window._cancel_tasks()
+ _wait_idle(qapp, ws)
+ assert ws.reports() is before
+ assert ws.matrix.visible_clones() == shown and ws.summary.info_text() == info
+
+
+def test_failed_reverification_keeps_previous_verdicts(qapp, window, monkeypatch):
+ ws = _loaded(qapp, window)
+ before = ws.reports()
+
+ def boom(*a, **k):
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr("ui.construct.workspace.verify_all", boom)
+ ws.controls.min_q.setValue(30)
+ _wait_idle(qapp, ws)
+ assert ws.reports() is before
+ assert any("boom" in m for m in window.messages)
+
+
+def test_params_and_circular_trigger_reverification(qapp, window):
+ ws = _loaded(qapp, window)
+ starts = _count_starts(ws)
+ ws.controls.het_ratio.setValue(0.5)
+ ws.controls.mixed_min.setValue(4)
+ ws.controls.circular.setChecked(True)
+ assert starts == ["verify"] * 3
+ _wait_idle(qapp, ws)
+ assert ws.params().het_ratio == 0.5 and ws.params().mixed_min_positions == 4
+ assert ws.construct().circular is True
+
+
+def test_export_report_csv_and_xlsx(qapp, window, tmp_path):
+ ws = _loaded(qapp, window)
+ assert ws.export_report(tmp_path / "report.csv")
+ assert (tmp_path / "report.csv").read_text(encoding="utf-8").startswith("clone,verdict")
+ assert (tmp_path / "report_differences.csv").read_text(encoding="utf-8").startswith("clone,")
+ assert ws.export_report(tmp_path / "report.xlsx")
+ assert (tmp_path / "report.xlsx").stat().st_size > 0
+
+
+def test_grouping_dialog_pattern_preview_and_invalid_regex(qapp):
+ from PySide6.QtWidgets import QDialogButtonBox
+
+ from chrom_core.clones import group_reads
+ from ui.construct.grouping_dialog import GroupingDialog
+
+ grouping = group_reads(sorted(REAL_DIR.glob("*.ab1")))
+ dialog = GroupingDialog(grouping, "", None)
+ ok = dialog.buttons.button(QDialogButtonBox.StandardButton.Ok)
+ assert dialog.table.rowCount() == 8 and ok.isEnabled()
+
+ dialog.pattern_edit.setText("([")
+ assert not ok.isEnabled() and dialog.pattern_invalid()
+ dialog.pattern_edit.setText("(.*)") # no named groups
+ assert not ok.isEnabled() and dialog.pattern_invalid()
+
+ pattern = r"Achl_ACHLO00(?P\d-09)_\d_(?P[FR])"
+ dialog.pattern_edit.setText(pattern)
+ assert ok.isEnabled() and not dialog.pattern_invalid()
+ result = dialog.result_grouping()
+ assert sorted(result.clones()) == ["6-09", "7-09"]
+ assert len(result.unassigned) == 4
+ empty_rows = [r for r in range(8) if dialog.table.item(r, 1).text() == ""]
+ assert dialog.unassigned_rows() == empty_rows and len(empty_rows) == 4
+ assert dialog.result_pattern() == pattern
+
+ # manual edit: clear a clone cell -> that read becomes unassigned
+ first = next(r for r in range(8) if dialog.table.item(r, 1).text())
+ dialog.table.item(first, 1).setText("")
+ assert first in dialog.unassigned_rows()
+ assert len(dialog.result_grouping().unassigned) == 5
+
+
+def test_new_ui_keys_exist_in_both_languages():
+ import re as _re
+
+ import i18n
+
+ root = Path(__file__).resolve().parents[1]
+ files = [root / "ui" / "main_window.py", *sorted((root / "ui" / "construct").glob("*.py"))]
+ keys = set()
+ for path in files:
+ text = path.read_text(encoding="utf-8")
+ keys |= set(_re.findall(r"""\btr\(\s*["']([a-z_.]+)["']""", text))
+ assert {"tab.construct", "menu.project.open", "workspace.drop_construct"} <= keys
+ assert not (keys - set(i18n.RU)), keys - set(i18n.RU)
+ assert not (keys - set(i18n.EN)), keys - set(i18n.EN)
+
+
+# ------------------------------------------------------------------ Task 16 review fixes
+
+
+def test_hidden_viewer_actions_do_nothing_on_construct_tab(qapp, window, tmp_path, construct):
+ f_copy = tmp_path / F_NAME
+ f_copy.write_bytes(F.read_bytes())
+ ws = _loaded(qapp, window, COI_GB, [f_copy])
+ ws.detail.open_read.emit(f_copy)
+ assert window.tabs.currentWidget() is window.read_view
+ window.apply_base_change(_f_index(construct, P), "N")
+ _wait_idle(qapp, ws)
+ edited = "".join(window.read_model.seq)
+ assert window.undo_action.isEnabled()
+
+ window.tabs.setCurrentWidget(ws)
+ starts = _count_starts(ws)
+ assert not window.undo_action.isEnabled()
+ window.undo_action.trigger()
+ for action in window.read_view.findChildren(QAction):
+ if action.parent() is window.read_view:
+ assert not action.isEnabled(), action.text()
+ assert "".join(window.read_model.seq) == edited and starts == []
+
+ window.tabs.setCurrentWidget(window.read_view)
+ assert window.undo_action.isEnabled() and not window.redo_action.isEnabled()
+
+
+def test_clear_resets_matrix_and_summary(qapp, window, tmp_path):
+ ws = _loaded(qapp, window)
+ assert ws.matrix.visible_clones()
+ ws.clear()
+ assert ws.matrix.visible_clones() == [] and ws.summary.title_text() == ""
+ assert not ws.detail.isVisible()
+ _drop(ws, [COI_GB, REAL_DIR])
+ assert ws.is_verifying()
+ window._cancel_tasks()
+ _wait_idle(qapp, ws)
+ assert ws.matrix.visible_clones() == [] and ws.reports() == []
+ assert ws.summary.title_text() == ""
+
+
+def test_project_with_missing_construct_is_not_reported_open(qapp, window, tmp_path):
+ from chrom_core.project import Project, save_project
+
+ project = tmp_path / "lost.ab1proj"
+ save_project(project, Project(tmp_path / "gone.gb", False, None,
+ Grouping([ReadRef(F, "c1", "F")]), VerifyParams()))
+ said = []
+ window.workspace.status_message.connect(lambda text, _ms: said.append(text))
+ assert window.workspace.load_project(project) is False
+ assert window.workspace.project_path() is None
+ assert len(window.messages) == 1 and "gone.gb" in window.messages[0]
+ assert not any("lost.ab1proj" in s for s in said)
+
+
+def test_drop_without_changes_does_not_reverify(qapp, window, tmp_path):
+ ws = _loaded(qapp, window)
+ starts = _count_starts(ws)
+ _drop(ws, [REAL_DIR]) # nothing new
+ bad = tmp_path / "broken.gb"
+ bad.write_text("LOCUS nonsense\n", encoding="utf-8")
+ _drop(ws, [bad]) # construct fails to load
+ assert starts == []
+ assert len(window.messages) == 1
+
+
+def test_wrapping_region_on_circular_construct_survives_project(qapp, window, tmp_path, construct):
+ fasta = tmp_path / "coi.fa" # FASTA constructs are circular
+ fasta.write_text(f">coi\n{construct.seq}\n")
+ ws = _loaded(qapp, window, fasta, [REAL_DIR])
+ assert ws.construct().circular
+ ws.controls.start_spin.setValue(600)
+ ws.controls.end_spin.setValue(50)
+ assert ws.region() == (599, 50)
+ _wait_idle(qapp, ws)
+ project = tmp_path / "wrap.ab1proj"
+ assert ws.save_project(project)
+ ws.clear()
+ assert ws.load_project(project)
+ _wait_idle(qapp, ws)
+ assert ws.region() == (599, 50) and ws.construct().circular
+ assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (600, 50)
+ assert window.messages == []
+
+
+def test_grouping_dialog_flags_invalid_initial_pattern(qapp):
+ from PySide6.QtWidgets import QDialogButtonBox
+
+ from chrom_core.clones import group_reads
+ from ui.construct.grouping_dialog import GroupingDialog
+
+ grouping = group_reads(sorted(REAL_DIR.glob("*.ab1")))
+ dialog = GroupingDialog(grouping, "(?P[", None)
+ assert dialog.pattern_invalid()
+ assert not dialog.buttons.button(QDialogButtonBox.StandardButton.Ok).isEnabled()
+ assert dialog.table.rowCount() == 8 and dialog.result_grouping().to_dict() == grouping.to_dict()
+
+
+def test_double_spin_follows_ui_language(qapp):
+ from i18n import set_language
+ from ui.construct.controls import ControlsBar
+
+ try:
+ set_language("en")
+ assert ControlsBar().het_ratio.text() == "0.40"
+ set_language("ru")
+ assert ControlsBar().het_ratio.text() == "0,40"
+ finally:
+ set_language("ru")
diff --git a/tests/test_data_corpus.py b/tests/test_data_corpus.py
new file mode 100644
index 0000000..3ee01cd
--- /dev/null
+++ b/tests/test_data_corpus.py
@@ -0,0 +1,25 @@
+from clone_fixtures import (
+ REAL_READS, COI_GB, PUC19_GB, SNAPGENE_DNA, real_read, coi_reference, reference_for,
+)
+from chrom_core.align import align_read
+
+
+def _expected_strand(name: str) -> str:
+ return "+" if "_F" in name else "-"
+
+
+def test_corpus_files_present():
+ assert len(REAL_READS) == 8
+ assert COI_GB.exists() and PUC19_GB.exists() and SNAPGENE_DNA.exists()
+
+
+def test_every_real_read_aligns_to_coi_with_high_identity():
+ for path in REAL_READS:
+ aln = align_read(real_read(path.name).seq, reference_for(path.name))
+ assert aln.identity >= 0.97, path.name
+ assert aln.strand == _expected_strand(path.name), path.name
+
+
+def test_achlo040_is_a_different_lineage_from_the_primary_reference():
+ aln = align_read(real_read("Achl_ACHLO040-09_1_F.ab1").seq, coi_reference())
+ assert aln.identity < 0.90
diff --git a/tests/test_editing.py b/tests/test_editing.py
index 141f43e..5b6dee5 100644
--- a/tests/test_editing.py
+++ b/tests/test_editing.py
@@ -65,10 +65,14 @@ def test_reverse_complement(model: EditableRead) -> None:
chrom = model.current()
# flipped traces: A channel is original T channel reversed
assert chrom.traces["A"] == tuple(reversed(model.original.traces["T"]))
- # rc twice restores sequence and quality
+ # peaks follow the flipped traces (reversed over the full trace length)
+ last = len(model.original.traces["A"]) - 1
+ assert chrom.peak_indices == tuple(last - p for p in reversed(model.original.peak_indices))
+ # rc twice restores sequence, quality and peaks
model.reverse_complement()
assert "".join(model.seq) == seq
assert model.quality == list(model.original.quality)
+ assert model.peaks == list(model.original.peak_indices)
assert not model.reversed_complement
diff --git a/tests/test_effects.py b/tests/test_effects.py
new file mode 100644
index 0000000..cdc6463
--- /dev/null
+++ b/tests/test_effects.py
@@ -0,0 +1,139 @@
+"""Amino-acid effects of clone differences inside CDS features."""
+
+from dataclasses import replace
+
+from Bio.Seq import Seq
+
+from chrom_core.clones import CloneDifference
+from chrom_core.effects import difference_effect
+from chrom_core.reference import Construct, Feature, load_construct
+from chrom_core.seqops import reverse_complement
+from tests.clone_fixtures import COI_GB
+
+COMP = {"A": "T", "C": "G", "G": "C", "T": "A"}
+
+
+def aa(codon: str) -> str:
+ return str(Seq(codon).translate(table=11))
+
+
+def sub(pos: int, ref: str, alt: str) -> CloneDifference:
+ return CloneDifference(pos, "substitution", ref, alt, 2, 0, 0, "confirmed", True)
+
+
+def coi():
+ c = load_construct(COI_GB)
+ cds = next(f for f in c.features if f.kind == "CDS")
+ return c, cds
+
+
+def codon_at(c, cds, n: int) -> tuple[int, str]:
+ """Start position and codon of amino acid ``n`` (1-based) on the plus strand."""
+ start = cds.start + cds.codon_start - 1 + (n - 1) * 3
+ return start, c.seq[start:start + 3].upper()
+
+
+def alt_changing(codon: str, within: int, synonymous: bool, avoid_stop=True) -> str:
+ for b in "ACGT":
+ if b == codon[within]:
+ continue
+ new = codon[:within] + b + codon[within + 1:]
+ if (aa(new) == aa(codon)) == synonymous and not (avoid_stop and aa(new) == "*"):
+ return b
+ raise AssertionError("no such alternative")
+
+
+def test_synonymous_third_codon_position():
+ c, cds = coi()
+ start, codon = codon_at(c, cds, 10)
+ alt = alt_changing(codon, 2, synonymous=True)
+ assert aa(codon[:2] + alt) == aa(codon)
+ assert difference_effect(c, sub(start + 2, codon[2], alt)) == "synonymous"
+
+
+def test_missense_label():
+ c, cds = coi()
+ n = 45
+ start, codon = codon_at(c, cds, n)
+ alt = alt_changing(codon, 0, synonymous=False)
+ d = sub(start, codon[0], alt)
+ aa_ref, aa_alt = aa(codon), aa(alt + codon[1:])
+ assert difference_effect(c, d) == f"{aa_ref}{n}{aa_alt}"
+
+
+def test_stop_gained():
+ c, cds = coi()
+ for n in range(2, 200):
+ start, codon = codon_at(c, cds, n)
+ for within in range(3):
+ for b in "ACGT":
+ new = codon[:within] + b + codon[within + 1:]
+ if b != codon[within] and aa(new) == "*" and aa(codon) != "*":
+ got = difference_effect(c, sub(start + within, codon[within], b))
+ assert got == f"{aa(codon)}{n}*"
+ return
+ raise AssertionError("no stop-gaining substitution found")
+
+
+def test_frameshift_and_inframe():
+ c, cds = coi()
+ pos = cds.start + 30
+ ins1 = CloneDifference(pos, "insertion", "-", "A", 2, 0, 0, "confirmed", True)
+ ins3 = replace(ins1, read_base="ACG")
+ del1 = CloneDifference(pos, "deletion", c.seq[pos], "-", 2, 0, 0, "confirmed", True)
+ assert difference_effect(c, ins1) == "frameshift"
+ assert difference_effect(c, ins3) == "inframe_ins"
+ assert difference_effect(c, del1) == "frameshift"
+ del3 = replace(del1, ref_base=c.seq[pos:pos + 3])
+ assert difference_effect(c, del3) == "inframe_del"
+
+
+def test_minus_strand_cds():
+ """Mirrored COI on the minus strand (controller ruling R11).
+
+ pUC19's GenBank has no CDS (only 'source'), so ``bla`` cannot be used: the
+ COI construct is reverse-complemented and its CDS mirrored to strand -1.
+ """
+ c, cds = coi()
+ L = len(c.seq)
+ rc = Construct(
+ "coi_rc", reverse_complement(c.seq.upper()), False,
+ (replace(cds, start=L - cds.end, end=L - cds.start, strand=-1),),
+ )
+ for n in (1, 45, 100):
+ start, codon = codon_at(c, cds, n)
+ for within, synonymous in ((0, False), (2, True)):
+ alt = alt_changing(codon, within, synonymous)
+ plus = difference_effect(c, sub(start + within, codon[within], alt))
+ mpos = L - 1 - (start + within)
+ assert rc.seq[mpos] == COMP[codon[within]]
+ minus = difference_effect(rc, sub(mpos, COMP[codon[within]], COMP[alt]))
+ assert minus == plus
+
+
+def test_wrapping_cds():
+ c, cds = coi()
+ # rotate so the CDS crosses the origin
+ shift = 100
+ seq = c.seq.upper()
+ L = len(seq)
+ rot = Construct(
+ "rot", seq[shift:] + seq[:shift], True,
+ (replace(cds, start=(cds.start - shift) % L, end=(cds.start + 600 - shift) % L),),
+ )
+ start, codon = codon_at(c, cds, 45)
+ alt = alt_changing(codon, 0, synonymous=False)
+ d = sub((start - shift) % L, codon[0], alt)
+ assert difference_effect(rot, d) == f"{aa(codon)}45{aa(alt + codon[1:])}"
+
+
+def test_outside_cds_empty():
+ c, cds = coi()
+ only = Construct("x", c.seq, False, (replace(cds, start=100, end=200),))
+ assert difference_effect(only, sub(5, c.seq[5], "A" if c.seq[5] != "A" else "C")) == ""
+
+
+def test_no_features_empty():
+ c, _ = coi()
+ plain = Construct("x", c.seq, False)
+ assert difference_effect(plain, sub(10, "A", "C")) == ""
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
new file mode 100644
index 0000000..ffbc480
--- /dev/null
+++ b/tests/test_grouping.py
@@ -0,0 +1,109 @@
+from pathlib import Path
+
+import pytest
+
+from chrom_core.clones import (
+ DEFAULT_PRIMER_TOKENS,
+ Grouping,
+ ReadRef,
+ group_reads,
+ parse_name,
+)
+from chrom_core.config import AppConfig, load_config
+from tests.clone_fixtures import REAL_READS
+
+
+@pytest.mark.parametrize("stem,expected", [
+ ("Achl_ACHLO006-09_1_F", ("Achl_ACHLO006-09", "F")),
+ ("Achl_ACHLO006-09_2_R", ("Achl_ACHLO006-09", "R")),
+ ("cl3_T7", ("cl3", "T7")),
+ ("cl3_T7_2", ("cl3", "T7")), # repeat sequencing
+ ("A05_K3-F", ("K3", "F")),
+ ("clone12.rev", ("clone12", "rev")),
+ ("клон 14_SP6", ("клон 14", "SP6")),
+ ("B2_F", ("B2", "F")), # lone well-shaped token is the clone
+ ("A1_F", ("A1", "F")),
+ ("T7", None),
+])
+def test_parse_name(stem, expected):
+ assert parse_name(stem) == expected
+
+
+def test_parse_name_fallback_to_last_token_and_custom_tokens():
+ assert parse_name("cl3_xyz") == ("cl3", "xyz")
+ assert parse_name("cl3_XYZ", ["xyz"]) == ("cl3", "XYZ")
+ assert parse_name("single") is None
+
+
+def test_parse_name_multi_part_primer_token():
+ assert parse_name("cl5_pUC-F") == ("cl5", "pUC-F")
+
+
+def test_default_tokens_contents():
+ assert "T7TERM" in DEFAULT_PRIMER_TOKENS and len(DEFAULT_PRIMER_TOKENS) == 17
+
+
+def test_group_real_reads_into_four_clones():
+ g = group_reads(REAL_READS)
+ assert len(g.clones()) == 4 and all(len(v) == 2 for v in g.clones().values())
+ assert g.unassigned == []
+
+
+def test_repeat_reads_both_kept():
+ g = group_reads([Path("d/cl3_T7.ab1"), Path("d/cl3_T7_2.ab1")])
+ assert [r.path.name for r in g.clones()["cl3"]] == ["cl3_T7.ab1", "cl3_T7_2.ab1"]
+
+
+def test_custom_pattern_and_unassigned():
+ g = group_reads(
+ [Path("S1-fw.ab1"), Path("S12-rv.ab1"), Path("junk.ab1")],
+ pattern=r"(?PS\d+)-(?P\w+)",
+ )
+ assert list(g.clones()) == ["S1", "S12"]
+ assert g.clones()["S12"][0].primer == "rv"
+ assert g.unassigned == [Path("junk.ab1")]
+
+
+def test_bad_regex_raises():
+ with pytest.raises(ValueError):
+ group_reads([Path("a.ab1")], pattern="(unclosed")
+ with pytest.raises(ValueError):
+ group_reads([Path("a.ab1")], pattern=r"(\w+)") # no named groups
+
+
+def test_no_clone_goes_to_unassigned():
+ g = group_reads([Path("T7.ab1")])
+ assert g.reads == [] and g.unassigned == [Path("T7.ab1")]
+
+
+def test_natural_sort():
+ paths = [Path(f"cl{n}_T7.ab1") for n in (10, 2, 1)]
+ assert list(group_reads(paths).clones()) == ["cl1", "cl2", "cl10"]
+
+
+def test_grouping_roundtrip_and_manual_assign():
+ g = group_reads([Path("cl1_T7.ab1"), Path("junk.ab1")])
+ g.assign(Path("junk.ab1"), "cl1", "SP6")
+ g.assign(Path("cl1_T7.ab1"), "cl2", "T7") # reassign moves, never duplicates
+ assert g.unassigned == []
+ assert [(r.clone, r.primer) for r in g.reads] == [("cl2", "T7"), ("cl1", "SP6")]
+ d = g.to_dict()
+ assert all(isinstance(r["path"], str) for r in d["reads"])
+ assert Grouping.from_dict(d) == g
+ assert ReadRef(Path("a"), "c", "p") in Grouping.from_dict(
+ {"reads": [{"path": "a", "clone": "c", "primer": "p"}], "unassigned": []}
+ ).reads
+
+
+def test_config_new_fields_and_old_files(tmp_path):
+ cfg = AppConfig()
+ assert cfg.primer_tokens == list(DEFAULT_PRIMER_TOKENS) and cfg.group_pattern == ""
+ f = tmp_path / "c.json"
+ f.write_text('{"theme": "dark"}', encoding="utf-8")
+ assert load_config(f).primer_tokens == list(DEFAULT_PRIMER_TOKENS)
+
+
+def test_natural_sort_with_non_decimal_digits():
+ # "²" is a digit to str.isdigit() but not int()-convertible
+ g = Grouping([ReadRef(Path(f"{c}_F.ab1"), c, "F") for c in ("x1²", "x10", "x2")])
+ assert list(g.clones()) == ["x1²", "x2", "x10"]
diff --git a/tests/test_peaks.py b/tests/test_peaks.py
new file mode 100644
index 0000000..a83b709
--- /dev/null
+++ b/tests/test_peaks.py
@@ -0,0 +1,136 @@
+import random
+from dataclasses import replace
+
+from chrom_core.align import align_read
+from chrom_core.peaks import (
+ SecondaryPeak,
+ channel_baseline,
+ peak_window,
+ scan_secondary_peaks,
+ secondary_peak,
+)
+from chrom_core.snps import peak_heights
+from clone_fixtures import REAL_READS, make_chrom, real_read, reference_for
+
+
+def _random_seq(seed: int, n: int = 120) -> str:
+ rng = random.Random(seed)
+ return "".join(rng.choice("ACGT") for _ in range(n))
+
+
+def test_weak_channel_normalisation_removes_false_het() -> None:
+ # The G dye is systematically half as bright: every called G peak is 500
+ # high, every other called peak 1000. At position 10 (called G) the A
+ # channel shows 225 — 45% of the raw G height, a false het by raw ratio.
+ # Normalised, the G peak itself is at its baseline (500/500 = 1.0) while
+ # A is 225/1000 = 0.225 of its own baseline -> ratio well below 0.40.
+ seq = list(_random_seq(1, 40))
+ seq[10] = "G"
+ seq = "".join(seq)
+ chrom = make_chrom(seq, primary={"G": 500}, heights={(10, "A"): 225})
+ raw = peak_heights(chrom, 10)
+ assert raw["A"] / raw["G"] >= 0.40 # the raw ratio would call a het
+ peak = secondary_peak(chrom, 10)
+ assert peak.primary_base == "G"
+ assert peak.base == "A"
+ assert peak.ratio < 0.40
+
+
+def test_true_het_detected_after_normalisation() -> None:
+ seq = list(_random_seq(2, 40))
+ seq[10] = "A"
+ seq = "".join(seq)
+ chrom = make_chrom(seq, heights={(10, "C"): 500}) # second C peak at 50%
+ peak = secondary_peak(chrom, 10)
+ assert peak.base == "C"
+ assert 0.45 <= peak.ratio <= 0.55
+
+
+def test_peak_window_tracks_spacing() -> None:
+ chrom_spacing_10 = make_chrom("ACGTACGTAC", spacing=10)
+ chrom_spacing_3 = make_chrom("ACGTACGTAC", spacing=3)
+ assert peak_window(chrom_spacing_10, 5) == 5
+ assert peak_window(chrom_spacing_3, 5) == 2
+
+
+def test_neighbour_shoulder_is_not_a_second_peak() -> None:
+ # Position 11 is a broad A peak (spacing 10): its rising flank reaches
+ # 600 at the edge of position 10's window. A plain window maximum would
+ # read that as a 60% second peak; it is a slope, not a peak.
+ seq = "CGTACGTACGCAGTCGATCGAT"
+ chrom = make_chrom(seq)
+ assert seq[10] == "C" and seq[11] == "A"
+ a = list(chrom.traces["A"])
+ for k, v in zip(range(111, 120), (300, 350, 400, 500, 600, 700, 800, 900, 950)):
+ a[k] = v
+ a[105:111] = [100, 120, 150, 180, 220, 260]
+ chrom = replace(chrom, traces={**chrom.traces, "A": tuple(a)})
+ assert peak_heights(chrom, 10)["A"] >= 600 # raw window max sees the shoulder
+ peak = secondary_peak(chrom, 10)
+ assert peak.ratio < 0.40
+
+
+def test_channel_baseline_uses_called_peaks_per_channel() -> None:
+ seq = "ACGT" * 10
+ chrom = make_chrom(seq, primary={"G": 500})
+ baseline = channel_baseline(chrom, 20)
+ assert baseline == {"A": 1000.0, "C": 1000.0, "G": 500.0, "T": 1000.0}
+
+
+def test_channel_baseline_falls_back_when_channel_never_called() -> None:
+ # no T is called anywhere: T falls back to the median of all called peaks
+ chrom = make_chrom("ACG" * 15, primary={"G": 400})
+ baseline = channel_baseline(chrom, 20)
+ assert baseline["G"] == 400.0
+ assert baseline["T"] == 1000.0 # median of 1000/1000/400 heights
+
+
+def test_secondary_peak_without_outside_channel_is_unmeasurable() -> None:
+ chrom = make_chrom("ACGTNACGTA")
+ peak = secondary_peak(chrom, 4)
+ assert peak == SecondaryPeak(primary_base=peak.primary_base, base="", ratio=None)
+
+
+def test_scan_finds_het_where_reference_base_was_called() -> None:
+ ref = list(_random_seq(3))
+ if ref[30] == "T":
+ ref[30] = "A"
+ ref = "".join(ref)
+ # the read equals the reference, but under position 30 a T peak at 50%
+ chrom = make_chrom(ref, heights={(30, "T"): 500})
+ hits = scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20)
+ assert [pos for pos, _ in hits] == [30]
+ assert hits[0][1].base == "T"
+
+
+def test_scan_maps_reverse_strand_to_displayed_read() -> None:
+ from chrom_core.seqops import reverse_complement
+
+ forward = list(_random_seq(4))
+ if forward[30] == "T":
+ forward[30] = "A"
+ forward = "".join(forward)
+ chrom = make_chrom(forward, heights={(30, "T"): 500})
+ ref = reverse_complement(forward)
+ aln = align_read(chrom.seq, ref)
+ assert aln.strand == "-"
+ hits = scan_secondary_peaks(chrom, aln, 0.40, 20)
+ assert [pos for pos, _ in hits] == [len(forward) - 1 - 30]
+
+
+def test_scan_skips_low_quality() -> None:
+ ref = list(_random_seq(5))
+ if ref[30] == "T":
+ ref[30] = "A"
+ ref = "".join(ref)
+ chrom = make_chrom(ref, quality=10, heights={(30, "T"): 500})
+ assert scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20) == []
+
+
+def test_real_reads_have_no_mass_false_hets() -> None:
+ assert REAL_READS
+ for path in REAL_READS:
+ chrom = real_read(path.name)
+ aln = align_read(chrom.seq, reference_for(path.name))
+ hits = scan_secondary_peaks(chrom, aln, 0.40, 20)
+ assert len(hits) <= 2, (path.name, hits)
diff --git a/tests/test_perf_marker.py b/tests/test_perf_marker.py
new file mode 100644
index 0000000..9469150
--- /dev/null
+++ b/tests/test_perf_marker.py
@@ -0,0 +1,31 @@
+"""Timing tests are opt-in (``-m perf``) and their bounds scale with AB1KIT_PERF_FACTOR."""
+
+import subprocess
+import sys
+from pathlib import Path
+
+from clone_fixtures import perf_factor
+
+ROOT = Path(__file__).resolve().parent.parent
+PERF = {"tests/test_clone_verify.py::test_performance_12_clones_x_4_reads",
+ "tests/test_construct_ui.py::test_paint_cost_10kb_12_rows"}
+
+
+def _collected(*extra: str) -> set[str]:
+ out = subprocess.run(
+ [sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider",
+ "tests/test_clone_verify.py", "tests/test_construct_ui.py", *extra],
+ cwd=ROOT, capture_output=True, text=True, check=False).stdout
+ return {line.strip().replace("\\", "/") for line in out.splitlines() if "::" in line}
+
+
+def test_perf_tests_deselected_by_default_and_selected_with_marker():
+ assert not PERF & _collected()
+ assert _collected("-m", "perf") == PERF
+
+
+def test_perf_factor(monkeypatch):
+ monkeypatch.delenv("AB1KIT_PERF_FACTOR", raising=False)
+ assert perf_factor() == 1.0
+ monkeypatch.setenv("AB1KIT_PERF_FACTOR", "2.5")
+ assert perf_factor() == 2.5
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 0000000..26c46bd
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,120 @@
+import json
+import shutil
+from pathlib import Path
+
+import pytest
+
+from chrom_core.clones import Grouping, ReadRef, VerifyParams
+from chrom_core.project import PROJECT_VERSION, Project, load_project, save_project
+
+
+def _make(folder: Path, outside: Path | None = None) -> Project:
+ reads = [
+ ReadRef(folder / "reads" / "c1_F.ab1", "c1", "F"),
+ ReadRef(folder / "reads" / "c1_R.ab1", "c1", "R"),
+ ]
+ unassigned = [folder / "x.ab1"]
+ if outside is not None:
+ reads.append(ReadRef(outside / "far.ab1", "c2", "F"))
+ return Project(
+ construct_path=folder / "plasmid.gb",
+ circular=True,
+ region=(900, 100),
+ grouping=Grouping(reads=reads, unassigned=unassigned),
+ params=VerifyParams(min_q=25, het_ratio=0.3, mixed_min_positions=4),
+ )
+
+
+def test_round_trip(tmp_path):
+ proj = _make(tmp_path, outside=tmp_path.parent / "elsewhere")
+ f = tmp_path / "s.ab1proj"
+ save_project(f, proj)
+ assert load_project(f) == proj
+
+
+def test_whole_construct_region_none(tmp_path):
+ proj = _make(tmp_path)
+ proj.region = None
+ f = tmp_path / "s.ab1proj"
+ save_project(f, proj)
+ assert load_project(f).region is None
+
+
+def test_paths_relative_and_posix_inside_folder(tmp_path):
+ outside = tmp_path.parent / "elsewhere"
+ f = tmp_path / "s.ab1proj"
+ save_project(f, _make(tmp_path, outside))
+ data = json.loads(f.read_text(encoding="utf-8"))
+ assert data["app"] == "ab1kit"
+ assert data["version"] == PROJECT_VERSION
+ assert data["construct_path"] == "plasmid.gb"
+ paths = [r["path"] for r in data["grouping"]["reads"]]
+ assert paths[0] == "reads/c1_F.ab1"
+ assert Path(paths[2]).is_absolute()
+
+
+def test_moved_folder_still_resolves(tmp_path):
+ src = tmp_path / "a"
+ src.mkdir()
+ save_project(src / "s.ab1proj", _make(src))
+ dst = tmp_path / "b" / "moved"
+ dst.parent.mkdir()
+ shutil.move(str(src), str(dst))
+ loaded = load_project(dst / "s.ab1proj")
+ assert loaded == _make(dst)
+
+
+def test_cyrillic_folder_with_space(tmp_path):
+ folder = tmp_path / "Данные" / "клоны 1"
+ folder.mkdir(parents=True)
+ f = folder / "проект.ab1proj"
+ proj = _make(folder)
+ save_project(f, proj)
+ assert "Данные" not in f.read_text(encoding="utf-8")
+ assert load_project(f) == proj
+ moved = tmp_path / "Другие данные" / "клоны 1"
+ moved.parent.mkdir()
+ shutil.move(str(folder), str(moved))
+ assert load_project(moved / "проект.ab1proj") == _make(moved)
+
+
+def test_corrupt_json(tmp_path):
+ f = tmp_path / "s.ab1proj"
+ f.write_text("{not json", encoding="utf-8")
+ with pytest.raises(ValueError, match="JSON"):
+ load_project(f)
+
+
+def test_foreign_file(tmp_path):
+ f = tmp_path / "s.ab1proj"
+ f.write_text(json.dumps({"app": "other", "version": 1}), encoding="utf-8")
+ with pytest.raises(ValueError, match="ab1kit"):
+ load_project(f)
+
+
+def test_future_version(tmp_path):
+ f = tmp_path / "s.ab1proj"
+ f.write_text(
+ json.dumps({"app": "ab1kit", "version": PROJECT_VERSION + 1}),
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match="version"):
+ load_project(f)
+
+
+def test_missing_fields(tmp_path):
+ f = tmp_path / "s.ab1proj"
+ f.write_text(json.dumps({"app": "ab1kit", "version": 1}), encoding="utf-8")
+ with pytest.raises(ValueError):
+ load_project(f)
+
+
+@pytest.mark.parametrize("grouping", [None, [], ["x"], {"reads": [None]}])
+def test_malformed_grouping_is_a_value_error(tmp_path, grouping):
+ f = tmp_path / "s.ab1proj"
+ save_project(f, _make(tmp_path))
+ data = json.loads(f.read_text(encoding="utf-8"))
+ data["grouping"] = grouping
+ f.write_text(json.dumps(data), encoding="utf-8")
+ with pytest.raises(ValueError, match="s.ab1proj"):
+ load_project(f)
diff --git a/tests/test_reference.py b/tests/test_reference.py
new file mode 100644
index 0000000..0f9317b
--- /dev/null
+++ b/tests/test_reference.py
@@ -0,0 +1,90 @@
+import pytest
+from Bio import SeqIO
+
+from clone_fixtures import COI_GB, PUC19_GB, SNAPGENE_DNA
+from chrom_core.reference import Construct, Feature, construct_from_text, load_construct
+
+
+def test_genbank_puc19():
+ c = load_construct(PUC19_GB)
+ assert len(c.seq) == len(SeqIO.read(PUC19_GB, "genbank").seq) and c.circular
+ # The committed L09137 record annotates only "source" (no CDS); CDS is covered by COI below.
+ assert [f.kind for f in c.features] == ["source"]
+
+
+def test_coi_has_cds():
+ assert any(f.kind == "CDS" for f in load_construct(COI_GB).features)
+
+
+def test_puc19_length_is_2686():
+ assert len(load_construct(PUC19_GB).seq) == 2686
+
+
+def test_snapgene_sample():
+ c = load_construct(SNAPGENE_DNA)
+ assert c.seq and c.features
+
+
+def test_coi_cds_codon_start_and_name():
+ c = load_construct(COI_GB)
+ cds = [f for f in c.features if f.kind == "CDS"]
+ assert cds and all(f.codon_start in (1, 2, 3) for f in cds)
+ rec = SeqIO.read(COI_GB, "genbank")
+ want = int(next(f for f in rec.features if f.type == "CDS").qualifiers.get("codon_start", ["1"])[0])
+ assert cds[0].codon_start == want
+ assert all(f.name for f in c.features)
+
+
+def test_fasta_multi_record_lowercase(tmp_path):
+ p = tmp_path / "x.fasta"
+ p.write_text(">a\nacgtn\n>b\nTTTT\n")
+ c = load_construct(p)
+ assert c.seq == "ACGTN" and c.circular and c.features == ()
+
+
+def test_unknown_extension_sniffed_fasta(tmp_path):
+ p = tmp_path / "x.seq"
+ p.write_text(">a\nACGT\n")
+ assert load_construct(p).seq == "ACGT"
+
+
+def test_unknown_extension_sniffed_genbank(tmp_path):
+ p = tmp_path / "x.seq"
+ p.write_bytes(PUC19_GB.read_bytes())
+ assert len(load_construct(p).seq) == 2686
+
+
+def test_garbage_rejected(tmp_path):
+ p = tmp_path / "x.seq"
+ p.write_text("hello world")
+ with pytest.raises(ValueError):
+ load_construct(p)
+
+
+def test_invalid_chars_rejected(tmp_path):
+ p = tmp_path / "x.fa"
+ p.write_text(">a\nAC!GT\n")
+ with pytest.raises(ValueError):
+ load_construct(p)
+
+
+def test_with_circular_toggle():
+ assert not construct_from_text("ACGT").with_circular(False).circular
+
+
+def test_construct_from_text_defaults():
+ c = construct_from_text(">x\nacgt\n")
+ assert c == Construct("pasted", "ACGT", True, ())
+
+
+def test_origin_spanning_feature(tmp_path):
+ p = tmp_path / "o.gb"
+ p.write_text(
+ "LOCUS t 12 bp DNA circular SYN 01-JAN-2000\n"
+ "FEATURES Location/Qualifiers\n"
+ " misc_feature join(9..12,1..3)\n"
+ ' /label="wrap"\n'
+ "ORIGIN\n 1 acgtacgtac gt\n//\n"
+ )
+ f = load_construct(p).features[0]
+ assert (f.name, f.start, f.end, f.strand, f.codon_start) == ("wrap", 8, 3, 1, 1)
diff --git a/tests/test_smoke_gui.py b/tests/test_smoke_gui.py
index 9ac345e..d517168 100644
--- a/tests/test_smoke_gui.py
+++ b/tests/test_smoke_gui.py
@@ -24,11 +24,41 @@ def no_config_writes(monkeypatch, tmp_path):
"""Keep tests away from the real config and from session sidecars next to
the example files (closing a modified window autosaves one)."""
monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None)
+ monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None)
+ monkeypatch.setattr("ui.construct.workspace.save_config", lambda *a, **k: None)
from chrom_core import sessions
monkeypatch.setattr(sessions, "session_path", lambda _p: tmp_path / "session.json")
+@pytest.fixture(autouse=True)
+def delete_windows():
+ """Destroy the windows a test created: closed windows otherwise stay alive
+ and every later theme switch restyles all of them."""
+ yield
+ from PySide6.QtCore import QCoreApplication, QEvent
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication.instance()
+ if app is None:
+ return
+ for widget in app.topLevelWidgets():
+ widget.close()
+ widget.deleteLater()
+ QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
+ app.processEvents()
+
+
+def _wait_for(app, predicate, timeout=5.0):
+ import time
+
+ deadline = time.monotonic() + timeout
+ while not predicate() and time.monotonic() < deadline:
+ app.processEvents()
+ time.sleep(0.005)
+ assert predicate()
+
+
def test_entry_point_importable():
from chrom_cli import main
from ui.main_window import MainWindow, run
@@ -68,7 +98,7 @@ def test_open_broken_file_shows_error(qapp, monkeypatch):
shown = {}
monkeypatch.setattr(
- "ui.main_window.QMessageBox.critical",
+ "ui.read_view.read_view.QMessageBox.critical",
lambda *a, **k: shown.setdefault("called", True),
)
window = MainWindow(AppConfig(recent_files=[]))
@@ -121,7 +151,7 @@ def test_trim_dialog_wiring(qapp, monkeypatch):
warned = {}
monkeypatch.setattr(
- "ui.main_window.QMessageBox.warning",
+ "ui.read_view.read_view.QMessageBox.warning",
lambda *a, **k: warned.setdefault("called", True),
)
sample = next(p for p in AB1 if "3730" in p.name) # high-quality read
@@ -150,12 +180,13 @@ def test_batch_panel_flow(qapp, monkeypatch, tmp_path):
from ui.main_window import MainWindow
monkeypatch.setattr(
- "ui.main_window.QFileDialog.getExistingDirectory",
+ "ui.read_view.read_view.QFileDialog.getExistingDirectory",
lambda *a, **k: str(tmp_path / "export"),
)
window = MainWindow(AppConfig(recent_files=[]))
window.show()
window._scan_folder(EXAMPLES)
+ _wait_for(qapp, lambda: window.batch_panel.table.rowCount() > 0)
assert window.batch_dock.isVisible()
assert window.batch_panel.table.rowCount() == len(AB1)
@@ -205,7 +236,7 @@ def test_reference_alignment_flow(qapp, monkeypatch, tmp_path):
out = tmp_path / "aligned.fasta"
monkeypatch.setattr(
- "ui.main_window.QFileDialog.getSaveFileName",
+ "ui.read_view.read_view.QFileDialog.getSaveFileName",
lambda *a, **k: (str(out), "FASTA (*.fasta)"),
)
window._export_aligned()
@@ -256,7 +287,7 @@ def test_expected_snp_verification_flow(qapp, monkeypatch, tmp_path):
out = tmp_path / "report.csv"
monkeypatch.setattr(
- "ui.main_window.QFileDialog.getSaveFileName",
+ "ui.read_view.read_view.QFileDialog.getSaveFileName",
lambda *a, **k: (str(out), "CSV (*.csv)"),
)
window._save_verify_csv()
@@ -287,3 +318,365 @@ def test_session_autosave_and_restore(qapp, monkeypatch, tmp_path):
assert reopened.read_model.modified
reopened.close()
assert sidecar.exists()
+
+
+def test_verify_table_note_is_localised(qapp):
+ import i18n
+ from chrom_core.config import AppConfig
+ from i18n import tr
+ from ui.main_window import MainWindow
+
+ sample = next(p for p in AB1 if "3730" in p.name)
+ i18n.set_language("ru")
+ try:
+ window = MainWindow(AppConfig(recent_files=[], language="ru"))
+ assert window.open_path(sample)
+ seq = "".join(window.read_model.seq)
+ ref = seq[100:400]
+ window._set_reference_seq(ref)
+ wrong = "A" if ref[50] != "A" else "C"
+ window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n")
+ note_cell_text = window.verify_table.item(0, 7).text()
+ assert "table ref base" not in note_cell_text
+ assert note_cell_text == tr("verify.note.ref_differs")
+ window.close()
+ finally:
+ i18n.set_language("ru")
+
+
+def test_reverse_read_reference_overlay_in_displayed_orientation(qapp):
+ from chrom_core.config import AppConfig
+ from clone_fixtures import DATA, reference_for
+ from ui.main_window import MainWindow
+
+ name = "Achl_ACHLO006-09_2_R.ab1"
+ window = MainWindow(AppConfig(recent_files=[]))
+ assert window.open_path(DATA / "real" / name)
+ window._set_reference_seq(reference_for(name))
+ assert window.alignment.strand == "-"
+
+ letters, mapping = window.chrom_view.reference
+ seq = window.read_model.seq
+ assert mapping and all(0 <= idx < len(seq) for idx in mapping.values())
+ matches = sum(letters[pos].upper() == seq[idx].upper() for pos, idx in mapping.items())
+ assert matches / len(mapping) > 0.9
+
+ # the "№ базы" column shows displayed 1-based indices
+ for row in range(window.diff_table.rowCount()):
+ shown = window.diff_table.item(row, 4).text()
+ ref_pos = int(window.diff_table.item(row, 0).text()) - 1
+ if shown and ref_pos in mapping:
+ assert int(shown) - 1 == mapping[ref_pos]
+ window.close()
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_read_view_emits_read_edited(qapp):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ emitted = []
+ window.read_view.read_edited.connect(emitted.append)
+ view = window.read_view
+ assert view.open_path(AB1[0])
+ assert emitted == [] # opening is not an edit
+ assert view.current_model() is window.read_model
+
+ expected = [Path(AB1[0])]
+ steps = [
+ lambda: view.apply_base_change(0, "N"),
+ view.undo_read,
+ view.redo_read,
+ lambda: view.apply_trim_range(10, 100),
+ view.reverse_complement_read,
+ view._save_session,
+ ]
+ for count, step in enumerate(steps, start=1):
+ step()
+ assert emitted == expected * count
+ window.close()
+ assert emitted == expected * len(steps) # close-time autosave does not emit
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_bare_read_view_works_without_shell_menus(qapp):
+ from chrom_core.config import AppConfig
+ from ui.read_view import ReadView
+
+ view = ReadView(AppConfig(recent_files=[]), lambda *a, **k: None)
+ assert view.open_path(AB1[0])
+ view.apply_base_change(0, "N")
+ assert view.undo_action.isEnabled()
+ view.close()
+
+
+# ------------------------------------------------------------------ Task 13: theme
+def test_toolbar_actions_have_icons(qapp):
+ from PySide6.QtWidgets import QToolBar
+
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ bar = window.read_view.findChild(QToolBar)
+ actions = [a for a in bar.actions() if not a.isSeparator() and a.text()]
+ assert actions
+ assert all(not a.icon().isNull() for a in actions), [a.text() for a in actions if a.icon().isNull()]
+ window.close()
+
+
+def test_quality_colour_thresholds_and_palette(qapp):
+ from chrom_core.clone_report import STATUS_HEX
+ from ui.status import quality_color, status_color
+
+ for theme in ("light", "dark"):
+ assert quality_color(15, theme) != quality_color(35, theme)
+ assert quality_color(19, theme) != quality_color(20, theme)
+ assert quality_color(20, theme) == quality_color(29, theme)
+ assert quality_color(29, theme) != quality_color(30, theme)
+ assert quality_color(30, theme) == quality_color(60, theme)
+ for verdict, (light, dark) in STATUS_HEX.items():
+ assert status_color(verdict, "light").name() == light
+ assert status_color(verdict, "dark").name() == dark
+
+
+def test_minimap_and_chrom_view_share_quality_buckets(qapp):
+ from ui.status import quality_bucket
+
+ assert [quality_bucket(q) for q in (0, 19, 20, 29, 30, 99)] == [
+ "low_q", "low_q", "mid_q", "mid_q", "high_q", "high_q"]
+
+
+def test_status_chip_is_label(qapp):
+ from PySide6.QtWidgets import QLabel
+
+ from ui.status import status_chip
+
+ chip = status_chip("ok", "OK")
+ assert isinstance(chip, QLabel) and chip.text() == "OK"
+ assert "border-radius" in chip.styleSheet()
+
+
+def test_stylesheet_applies_in_both_themes(qapp):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ seen = []
+ for name in ("light", "dark"):
+ window.set_theme(name)
+ assert qapp.styleSheet().strip()
+ seen.append(qapp.styleSheet())
+ assert seen[0] != seen[1]
+ window.close()
+
+
+def test_tables_stretch_last_column_and_tooltip_notes(qapp):
+ from PySide6.QtWidgets import QHeaderView
+
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ sample = next(p for p in AB1 if "3730" in p.name)
+ window = MainWindow(AppConfig(recent_files=[]))
+ assert window.open_path(sample)
+ seq = "".join(window.read_model.seq)
+ ref = seq[100:400]
+ window._set_reference_seq(ref)
+ wrong = "A" if ref[50] != "A" else "C"
+ window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n")
+ for table in (window.verify_table, window.diff_table):
+ assert table.horizontalHeader().stretchLastSection()
+ assert table.horizontalHeader().sectionResizeMode(0) != QHeaderView.ResizeMode.Fixed
+ note = window.verify_table.item(0, 7)
+ assert len(note.text()) > 40 and note.toolTip() == note.text()
+ window.close()
+
+
+def test_status_cells_repaint_on_theme_switch(qapp):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+ from ui.status import status_color
+
+ sample = next(p for p in AB1 if "3730" in p.name)
+ window = MainWindow(AppConfig(recent_files=[], theme="light"))
+ assert window.open_path(sample)
+ seq = "".join(window.read_model.seq)
+ ref = seq[100:400]
+ window._set_reference_seq(ref)
+ wrong = "A" if ref[50] != "A" else "C"
+ window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n")
+ cell = window.verify_table.item(0, 4)
+ assert cell.background().color() == status_color("errors", "light")
+ window.set_theme("dark")
+ assert cell.background().color() == status_color("errors", "dark")
+ window.close()
+
+
+def test_icon_pixmap_changes_with_theme(qapp):
+ from PySide6.QtGui import QAction
+
+ from ui import status
+
+ action = QAction("x")
+ status.bind_icon(action, "undo-2")
+ try:
+ status.set_theme("light")
+ light = action.icon().pixmap(24, 24).toImage()
+ status.set_theme("dark")
+ dark = action.icon().pixmap(24, 24).toImage()
+ assert light != dark
+ finally:
+ status.set_theme("light")
+
+
+def test_channel_checkboxes_use_channel_colours_and_tick(qapp):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+ from ui.theme import theme as palette
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ for name in ("light", "dark"):
+ window.set_theme(name)
+ for base, box in window.read_view._channel_boxes.items():
+ sheet = box.styleSheet()
+ colour = palette(name)["bases"][base]
+ assert f"QCheckBox::indicator:checked {{ background-color: {colour}" in sheet
+ assert "image: url(" in sheet
+ assert "image: url(" in qapp.styleSheet()
+ window.close()
+
+
+def test_unwritable_temp_falls_back_to_no_tick_image(qapp, monkeypatch, tmp_path):
+ import tempfile
+
+ from chrom_core.config import AppConfig
+ from ui import theme
+ from ui.main_window import MainWindow
+
+ def fail(*_a, **_k):
+ raise OSError("read-only temp")
+
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path / "nowhere"))
+ monkeypatch.setattr(Path, "mkdir", fail)
+ assert theme.tick_image("#2f6fed") == ""
+ for dark in (False, True):
+ qss = theme.app_qss(dark)
+ assert "@" not in qss.replace("@media", "") and "url()" not in qss
+ window = MainWindow(AppConfig(recent_files=[]))
+ window.set_theme("dark")
+ assert all("url(" not in box.styleSheet()
+ for box in window.read_view._channel_boxes.values())
+ window.close()
+ monkeypatch.undo()
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
+ monkeypatch.setattr(Path, "write_text", fail)
+ assert theme.tick_image("#2f6fed") == ""
+
+
+def test_batch_table_stretches_file_column(qapp):
+ from PySide6.QtWidgets import QHeaderView
+
+ from ui.read_view.batch_dock import BatchPanel
+
+ panel = BatchPanel()
+ header = panel.table.horizontalHeader()
+ assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.Stretch
+ assert not header.stretchLastSection()
+
+
+def test_tooltips_only_on_long_cells(qapp):
+ from PySide6.QtWidgets import QTableWidgetItem
+
+ from ui.tables import set_full_text_tooltip
+
+ short, long_ = QTableWidgetItem("12"), QTableWidgetItem("x" * 60)
+ set_full_text_tooltip(short)
+ set_full_text_tooltip(long_)
+ assert short.toolTip() == "" and long_.toolTip() == "x" * 60
+
+
+# ------------------------------------------------------------------ Task 16: tabs, projects
+def _coi_project(tmp_path, region):
+ from chrom_core.clones import Grouping, VerifyParams
+ from chrom_core.project import Project, save_project
+
+ refs = Path(__file__).resolve().parent / "data" / "refs"
+ project = tmp_path / "p.ab1proj"
+ save_project(project, Project(refs / "coi_HM417896.gb", False, region, Grouping(),
+ VerifyParams()))
+ return project
+
+
+def test_construct_tab_first_with_project_menu(qapp):
+ from chrom_core.config import AppConfig
+ from i18n import tr
+ from ui.main_window import MainWindow
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ assert window.tabs.count() == 2
+ assert window.tabs.currentIndex() == 0 and window.tabs.currentWidget() is window.workspace
+ assert window.tabs.tabText(0) == tr("tab.construct")
+ assert window.tabs.tabText(1) == tr("tab.read")
+ titles = [a.text() for a in window.project_menu.actions() if not a.isSeparator()]
+ assert titles == [tr("menu.project.new"), tr("menu.project.open"), tr("menu.project.save"),
+ tr("menu.project.save_as"), tr("menu.project.export")]
+ window.close()
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_open_ab1_switches_to_read_tab(qapp):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ window = MainWindow(AppConfig(recent_files=[]))
+ assert window.open_path(AB1[0])
+ assert window.tabs.currentWidget() is window.read_view
+ window.close()
+
+
+def test_open_project_path_goes_to_construct_tab(qapp, tmp_path):
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ project = _coi_project(tmp_path, (10, 200))
+ window = MainWindow(AppConfig(recent_files=[]))
+ window.tabs.setCurrentWidget(window.read_view)
+ assert window.open_path(project)
+ assert window.tabs.currentWidget() is window.workspace
+ assert window.workspace.region() == (10, 200)
+ assert window.workspace.construct().name == "HM417896.1"
+ window.close()
+
+
+def test_run_opens_project_argument(qapp, monkeypatch, tmp_path):
+ from PySide6.QtWidgets import QApplication
+
+ import ui.main_window as mw
+
+ project = _coi_project(tmp_path, None)
+ opened = []
+ class ExistingApp: # run() must not create a second QApplication or block
+ instance = staticmethod(QApplication.instance)
+
+ def __init__(self, _args):
+ pass
+
+ def setStyle(self, _name): # noqa: N802
+ pass
+
+ def exec(self):
+ return 0
+
+ monkeypatch.setattr(mw, "QApplication", ExistingApp)
+ from PySide6.QtWidgets import QMessageBox
+
+ for box in ("critical", "warning"):
+ monkeypatch.setattr(QMessageBox, box, lambda *a, **k: QMessageBox.StandardButton.Ok)
+ monkeypatch.setattr(mw.MainWindow, "show", lambda self: opened.append(self))
+ assert mw.run([str(project)]) == 0
+ (window,) = opened
+ assert window.tabs.currentWidget() is window.workspace
+ assert window.workspace.construct() is not None
+ window.close()
diff --git a/tests/test_snps.py b/tests/test_snps.py
index 51b800a..519b296 100644
--- a/tests/test_snps.py
+++ b/tests/test_snps.py
@@ -4,44 +4,13 @@
import pytest
from chrom_core import read_ab1
-from chrom_core.abif import Chromatogram
from chrom_core.align import align_read
-from chrom_core.seqops import IUPAC, reverse_complement
+from chrom_core.seqops import reverse_complement
from chrom_core.snps import alleles_to_iupac, call_snps, peak_heights
+from clone_fixtures import make_chrom
EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
-SPACING = 10 # trace points between called bases
-
-
-def make_chrom(seq: str, quality: int = 40, heights: dict[tuple[int, str], float] | None = None) -> Chromatogram:
- """Synthetic chromatogram: clean primary peaks, optional extra heights.
-
- ``heights`` maps (base index, channel) -> trace value at that peak, e.g.
- {(10, "C"): 500} models a secondary C peak at read position 10.
- """
- heights = heights or {}
- n = len(seq)
- total = (n + 1) * SPACING
- traces = {base: [80] * total for base in "ACGT"}
- peaks = []
- for i, called in enumerate(seq.upper()):
- peak = (i + 1) * SPACING
- peaks.append(peak)
- # an IUPAC called base lights up all of its channels
- active = set(IUPAC[called]) if called in IUPAC else set("ACGT")
- for base in "ACGT":
- traces[base][peak] = 1000 if base in active else 60
- for (index, base), value in heights.items():
- traces[base][(index + 1) * SPACING] = value
- return Chromatogram(
- sample_id="synthetic",
- seq=seq,
- quality=(quality,) * n,
- traces={b: tuple(t) for b, t in traces.items()},
- peak_indices=tuple(peaks),
- )
-
def test_alleles_to_iupac() -> None:
assert alleles_to_iupac({"A"}) == "A"
diff --git a/tests/test_ui_tasks.py b/tests/test_ui_tasks.py
new file mode 100644
index 0000000..4f21ce6
--- /dev/null
+++ b/tests/test_ui_tasks.py
@@ -0,0 +1,245 @@
+"""Background task runner tests (offscreen, no pytest-qt)."""
+
+import os
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import time
+from concurrent.futures import CancelledError
+
+from pathlib import Path
+
+import pytest
+
+AB1 = sorted((Path(__file__).resolve().parent.parent / "examples").glob("*.ab1"))
+
+
+@pytest.fixture(scope="module")
+def qapp():
+ from PySide6.QtWidgets import QApplication
+
+ return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture()
+def parent(qapp):
+ from PySide6.QtCore import QObject
+
+ return QObject()
+
+
+def _wait(qapp, predicate, timeout=5.0):
+ deadline = time.monotonic() + timeout
+ while not predicate() and time.monotonic() < deadline:
+ qapp.processEvents()
+ time.sleep(0.005)
+ return predicate()
+
+
+def _collect(task):
+ events = []
+ task.finished.connect(lambda v: events.append(("finished", v)))
+ task.failed.connect(lambda m: events.append(("failed", m)))
+ task.cancelled.connect(lambda: events.append(("cancelled", None)))
+ task.progress.connect(lambda d, t: events.append(("progress", (d, t))))
+ return events
+
+
+def test_result_delivered_in_finished(qapp, parent):
+ from ui.tasks import run_task
+
+ task = run_task(lambda progress, is_cancelled: (progress(1, 2), 42)[1], parent)
+ events = _collect(task)
+ assert _wait(qapp, lambda: any(e[0] == "finished" for e in events))
+ assert ("finished", 42) in events
+ assert ("progress", (1, 2)) in events
+
+
+def test_exception_goes_to_failed(qapp, parent):
+ from ui.tasks import run_task
+
+ def boom(progress, is_cancelled):
+ raise ValueError("bad thing")
+
+ task = run_task(boom, parent)
+ events = _collect(task)
+ assert _wait(qapp, lambda: bool(events))
+ assert events == [("failed", "bad thing")]
+
+
+def test_cancel_emits_cancelled_and_drops_result(qapp, parent):
+ from ui.tasks import run_task
+
+ def loop(progress, is_cancelled):
+ for _ in range(500):
+ if is_cancelled():
+ raise CancelledError()
+ time.sleep(0.01)
+ return "late"
+
+ task = run_task(loop, parent)
+ events = _collect(task)
+ task.cancel()
+ assert task.is_cancelled()
+ assert events == [("cancelled", None)] # emitted immediately
+ assert _wait(qapp, lambda: bool(events))
+ assert events == [("cancelled", None)]
+
+
+def test_cancel_drops_late_result_of_uninterruptible_fn(qapp, parent):
+ from ui.tasks import run_task
+
+ task = run_task(lambda p, c: (time.sleep(0.2), "late")[1], parent)
+ events = _collect(task)
+ task.cancel()
+ assert _wait(qapp, lambda: bool(events))
+ assert events == [("cancelled", None)]
+
+
+def test_gui_thread_not_blocked(qapp, parent):
+ from PySide6.QtCore import QTimer
+
+ from ui.tasks import run_task
+
+ ticks = []
+ timer = QTimer()
+ timer.timeout.connect(lambda: ticks.append(1))
+ timer.start(20)
+ task = run_task(lambda p, c: (time.sleep(0.3), "ok")[1], parent)
+ events = _collect(task)
+ assert _wait(qapp, lambda: bool(events))
+ timer.stop()
+ assert events == [("finished", "ok")]
+ assert len(ticks) >= 5
+
+
+def test_signals_delivered_in_gui_thread(qapp, parent):
+ from PySide6.QtCore import QThread
+
+ from ui.tasks import run_task
+
+ threads = []
+ task = run_task(lambda p, c: 1, parent)
+ task.finished.connect(lambda v: threads.append(QThread.currentThread()))
+ assert _wait(qapp, lambda: bool(threads))
+ assert threads[0] is qapp.thread()
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_main_window_blast_uses_task_and_cancel_drops_result(qapp, monkeypatch, tmp_path):
+ from chrom_core import blast
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None)
+ monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None)
+ opened = []
+ monkeypatch.setattr(
+ "ui.read_view.read_view.QDesktopServices.openUrl", lambda url: opened.append(url)
+ )
+ monkeypatch.setattr(blast, "submit", lambda params, timeout=20: (time.sleep(0.2), "RID1")[1])
+ window = MainWindow(AppConfig(recent_files=[]))
+ window.show()
+ assert window.open_path(AB1[0])
+ window._blast_submit()
+ assert window.task_cancel_button.isVisible()
+ assert window.task_progress.isVisible()
+ assert _wait(qapp, lambda: bool(opened))
+ assert _wait(qapp, lambda: not window.task_progress.isVisible())
+
+ opened.clear()
+ window._blast_submit()
+ window.task_cancel_button.click()
+ assert _wait(qapp, lambda: not window.task_progress.isVisible())
+ time.sleep(0.3)
+ qapp.processEvents()
+ assert opened == []
+ window.close()
+
+
+def test_base_exception_still_terminal(qapp, parent):
+ from ui.tasks import run_task
+
+ def bail(progress, is_cancelled):
+ raise KeyboardInterrupt()
+
+ task = run_task(bail, parent)
+ events = _collect(task)
+ assert _wait(qapp, lambda: bool(events))
+ assert events == [("failed", "KeyboardInterrupt")]
+
+
+def test_parent_deleted_mid_run_is_clean(qapp, capfd):
+ import shiboken6
+ from PySide6.QtCore import QObject
+
+ from ui import tasks
+ from ui.tasks import run_task
+
+ def slow(progress, is_cancelled):
+ time.sleep(0.2)
+ progress(1, 2)
+ raise RuntimeError("after delete")
+
+ parent = QObject()
+ run_task(slow, parent)
+ shiboken6.delete(parent)
+ assert _wait(qapp, lambda: not tasks._running)
+ time.sleep(0.05)
+ assert "Traceback" not in capfd.readouterr().err
+ assert not tasks._running
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_scan_last_start_wins(qapp, monkeypatch, tmp_path):
+ from chrom_core import batch
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None)
+ monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None)
+ real = batch.scan_folder
+
+ def fake(folder, *a, **k):
+ if folder.name == "slow":
+ time.sleep(0.4)
+ return real(folder.parent, *a, **k)[:1]
+ return real(folder.parent, *a, **k)[:2]
+
+ monkeypatch.setattr(batch, "scan_folder", fake)
+ window = MainWindow(AppConfig(recent_files=[]))
+ window.show()
+ ex = AB1[0].parent
+ window._scan_folder(ex / "slow")
+ window._scan_folder(ex / "fast")
+ assert _wait(qapp, lambda: window.batch_panel.table.rowCount() == min(2, len(AB1)))
+ time.sleep(0.6)
+ qapp.processEvents()
+ assert window.batch_panel.table.rowCount() == min(2, len(AB1))
+ assert not window.task_progress.isVisible()
+ window.close()
+
+
+@pytest.mark.skipif(not AB1, reason="no example .ab1 files")
+def test_second_blast_cancels_first(qapp, monkeypatch):
+ from chrom_core import blast
+ from chrom_core.config import AppConfig
+ from ui.main_window import MainWindow
+
+ monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None)
+ monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None)
+ opened = []
+ monkeypatch.setattr(
+ "ui.read_view.read_view.QDesktopServices.openUrl", lambda url: opened.append(url.toString())
+ )
+ rids = iter(["FIRST", "SECOND"])
+ monkeypatch.setattr(blast, "submit", lambda params, timeout=20: (time.sleep(0.2), next(rids))[1])
+ window = MainWindow(AppConfig(recent_files=[]))
+ assert window.open_path(AB1[0])
+ window._blast_submit()
+ window._blast_submit()
+ assert _wait(qapp, lambda: bool(opened))
+ time.sleep(0.4)
+ qapp.processEvents()
+ assert len(opened) == 1
+ window.close()
diff --git a/tests/test_verify.py b/tests/test_verify.py
index bdb9ef4..1a2aba6 100644
--- a/tests/test_verify.py
+++ b/tests/test_verify.py
@@ -11,35 +11,9 @@
verify_expected,
verdicts_csv,
)
+from clone_fixtures import make_chrom
EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
-SPACING = 10
-
-
-def make_chrom(seq: str, quality: int = 40, heights: dict | None = None) -> object:
- from chrom_core.abif import Chromatogram
- from chrom_core.seqops import IUPAC
-
- heights = heights or {}
- n = len(seq)
- total = (n + 1) * SPACING
- traces = {base: [80] * total for base in "ACGT"}
- peaks = []
- for i, called in enumerate(seq.upper()):
- peak = (i + 1) * SPACING
- peaks.append(peak)
- active = set(IUPAC[called]) if called in IUPAC else set("ACGT")
- for base in "ACGT":
- traces[base][peak] = 1000 if base in active else 60
- for (index, base), value in heights.items():
- traces[base][(index + 1) * SPACING] = value
- return Chromatogram(
- sample_id="synthetic",
- seq=seq,
- quality=(quality,) * n,
- traces={b: tuple(t) for b, t in traces.items()},
- peak_indices=tuple(peaks),
- )
# ------------------------------------------------------------------- parsing
@@ -196,6 +170,64 @@ def test_ref_mismatch(setup) -> None:
assert verdicts[0].observed == reference[50]
+def test_ref_mismatch_carries_note_code(setup) -> None:
+ reference = setup
+ wrong_ref = "A" if reference[50] != "A" else "C"
+ verdicts, _ = _verify(reference, reference, [ExpectedVariant(51, wrong_ref, "T")])
+ assert verdicts[0].note_code == "ref_differs"
+ assert verdicts[0].note # English text stays for CSV/XLSX
+
+
+def test_other_note_codes(setup) -> None:
+ reference = setup
+ verdicts, _ = _verify(reference, reference, [ExpectedVariant(51, "AT", "GC")])
+ assert verdicts[0].note_code == "only_snv"
+ verdicts, _ = _verify(reference, reference, [ExpectedVariant(5000, ".", "A")])
+ assert verdicts[0].note_code == "pos_outside_ref"
+ read = reference[:40] + reference[60:]
+ verdicts, _ = _verify(reference, read, [ExpectedVariant(51, reference[50], "A")])
+ assert verdicts[0].note_code == "deleted_in_read"
+ alt = "T" if reference[50] != "T" else "A"
+ chrom = make_chrom(confirmed_case(reference, 50, alt), quality=10)
+ verdicts, _ = _verify(reference, confirmed_case(reference, 50, alt),
+ [ExpectedVariant(51, reference[50], alt)], chrom=chrom)
+ assert verdicts[0].note_code == "low_q"
+ assert verdicts[0].note_args == {"q": 10, "min_q": 20}
+
+
+_COMP = {"A": "T", "C": "G", "G": "C", "T": "A"}
+
+
+@pytest.mark.parametrize("name", ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1"])
+def test_real_read_confirmed_on_both_strands(name) -> None:
+ from clone_fixtures import real_read, reference_for
+
+ chrom = real_read(name)
+ reference = reference_for(name)
+ alignment = align_read("".join(chrom.seq), reference)
+ assert alignment.strand == ("-" if name.endswith("_R.ab1") else "+")
+ n = len(chrom.seq)
+ checked = 0
+ for pos, oriented_idx in sorted(alignment.ref_to_read.items()):
+ if pos < 100:
+ continue
+ idx = oriented_idx if alignment.strand == "+" else n - 1 - oriented_idx
+ if chrom.quality[idx] < 30 or chrom.seq[idx].upper() not in "ACGT":
+ continue
+ base = chrom.seq[idx].upper()
+ ref_strand = base if alignment.strand == "+" else _COMP[base]
+ verdict = verify_expected(
+ alignment, chrom, reference, [ExpectedVariant(pos + 1, ".", ref_strand)]
+ )[0]
+ assert verdict.verdict == "confirmed", (pos, verdict)
+ assert verdict.observed == ref_strand
+ assert verdict.read_pos == idx
+ checked += 1
+ if checked >= 40:
+ break
+ assert checked == 40
+
+
def test_low_quality(setup) -> None:
reference = setup
read = confirmed_case(reference, 50, "T" if reference[50] != "T" else "A")
diff --git a/translations_en.py b/translations_en.py
index 3efc92a..b16b4fc 100644
--- a/translations_en.py
+++ b/translations_en.py
@@ -72,6 +72,12 @@
"verify.ref_mismatch": "ref mismatch",
"verify.low_quality": "low quality",
"verify.unsupported": "unsupported",
+ "verify.note.only_snv": "only SNV rows are supported",
+ "verify.note.pos_outside_ref": "position outside the reference",
+ "verify.note.ref_differs": "table ref base differs from the reference",
+ "verify.note.deleted_in_read": "deleted in the read",
+ "verify.note.outside_coverage": "outside read coverage",
+ "verify.note.low_q": "Q{q} < {min_q}",
"verify.summary": "{n} positions: {confirmed} confirmed, {het} heterozygous, {notfound} not confirmed, {nodata} no data",
"verify.need_reference": "Load a reference and align the read first",
"verify.save_csv": "Save CSV…",
@@ -81,6 +87,8 @@
"tb.zoom_out": "Zoom out",
"tb.fit": "Fit read",
"tb.reset": "Reset view",
+ "tb.quality_legend": "Quality Q:",
+ "tb.quality_legend.tip": "Quality bar colours: red Q<20, yellow 20–29, green ≥30",
"goto.title": "Go to base",
"goto.label": "Base number",
"search.title": "Find subsequence",
@@ -141,8 +149,105 @@
"batch.col.q20": "Q≥20",
"batch.col.trim": "After trim",
"batch.col.status": "Status",
+ "task.cancel": "Cancel",
+ "task.running": "Working…",
+ "task.cancelled": "Operation cancelled",
"batch.scan_done": "Scanned files: {n}",
"batch.exported": "Exported: {n}, errors: {err}",
"batch.export_dir": "Export to folder",
"err.open_failed": "Failed to open file",
+ "construct.bp": "{n} bp",
+ "construct.circular": "circular",
+ "construct.linear": "linear",
+ "construct.region": "region {start}–{end}",
+ "construct.region_all": "region: whole construct",
+ "construct.count.ok": "OK: {n}",
+ "construct.count.errors": "Errors: {n}",
+ "construct.count.incomplete": "Incomplete: {n}",
+ "construct.count.mixed": "Mixed?: {n}",
+ "construct.count.no_data": "No data: {n}",
+ "construct.filter_tip": "Show or hide clones with this verdict",
+ "matrix.reference": "reference",
+ "matrix.chip.ok": "OK",
+ "matrix.chip.errors": "{n} diff.",
+ "matrix.chip.incomplete": "{pct}%",
+ "matrix.chip.mixed": "mixed?",
+ "matrix.chip.no_data": "no data",
+ "matrix.legend.cov_both": "both strands",
+ "matrix.legend.cov_one": "one strand",
+ "matrix.legend.cov_none": "none (Q<20)",
+ "matrix.legend.confirmed": "confirmed",
+ "matrix.legend.disputed": "disputed",
+ "matrix.legend.low_quality": "low Q",
+ "matrix.legend.mixed": "double peak",
+ "matrix.legend.unchecked": "not checked",
+ "matrix.legend.outside": "outside region",
+ "matrix.empty": "No clones to show",
+ "matrix.hint": "Wheel: zoom, drag: pan, Shift+drag on the reference: set the region",
+ "detail.col.pos": "Position",
+ "detail.col.change": "Ref→read",
+ "detail.col.effect": "Effect",
+ "detail.col.support": "For / against",
+ "detail.col.status": "Status",
+ "effect.synonymous": "synonymous",
+ "effect.frameshift": "frameshift",
+ "effect.inframe_ins": "in-frame insertion",
+ "effect.inframe_del": "in-frame deletion",
+ "detail.err.no_alignment": "no alignment",
+ "detail.ambiguous": "ambiguous — not counted in the verdict",
+ "detail.uncovered": "position not covered",
+ "tab.construct": "Construct check",
+ "tab.read": "Read viewer",
+ "menu.project": "&Project",
+ "menu.project.new": "&New",
+ "menu.project.open": "&Open project…",
+ "menu.project.save": "&Save project",
+ "menu.project.save_as": "Save project &as…",
+ "menu.project.export": "&Export report…",
+ "grouping.title": "Group reads into clones",
+ "grouping.pattern": "Name pattern:",
+ "grouping.pattern_placeholder": "empty — automatic, by file name",
+ "grouping.pattern_tip": "Regular expression on the file name without extension, with groups (?P…) and (?P…)",
+ "grouping.col.file": "File",
+ "grouping.col.clone": "Clone",
+ "grouping.col.primer": "Primer",
+ "grouping.no_clone": "No clone — the read is not checked",
+ "grouping.summary": "Clones: {clones} · reads: {reads} · without clone: {unassigned}",
+ "workspace.empty_title": "Clone verification",
+ "workspace.empty_hint": "Give a construct and reads — every clone gets its verdict right away",
+ "workspace.drop_construct": "Drop the construct file (FASTA, GenBank, SnapGene)",
+ "workspace.drop_reads": "…and a folder or files of .ab1 reads",
+ "workspace.click_to_choose": "or click to choose",
+ "workspace.choose_files": "Choose individual files…",
+ "workspace.reads_loaded": "reads: {n}",
+ "workspace.region_from": "Region:",
+ "workspace.region_to": "–",
+ "workspace.whole": "Whole construct",
+ "workspace.whole_tip": "Check the whole construct sequence",
+ "workspace.circular": "Circular",
+ "workspace.circular_tip": "Circular construct: reads and the region may cross the origin",
+ "workspace.min_q": "Min Q",
+ "workspace.min_q_tip": "Bases below this quality neither confirm nor dispute a difference",
+ "workspace.het_ratio": "Double peak",
+ "workspace.het_ratio_tip": "Second-to-main peak ratio from which a position counts as mixed",
+ "workspace.mixed_min": "Mixed from",
+ "workspace.mixed_min_tip": "How many mixed positions make the clone's verdict “mixed?”",
+ "workspace.grouping": "Grouping…",
+ "workspace.add_reads": "Add reads…",
+ "workspace.construct_button": "Construct…",
+ "workspace.construct_failed": "Cannot open the construct",
+ "workspace.construct_too_long": "The construct is too long: {n} bp (limit {limit})",
+ "workspace.no_new_reads": "No new .ab1 files found",
+ "workspace.region_invalid": "Invalid region",
+ "workspace.region_invalid_text": "Region {start}–{end} cannot be checked: {reason}",
+ "workspace.verify_failed": "Clone verification failed",
+ "workspace.verified": "Clones checked: {n}",
+ "workspace.project_failed": "Cannot open the project",
+ "workspace.project_loaded": "Project opened: {name}",
+ "workspace.project_save_failed": "Cannot save the project",
+ "workspace.need_construct": "Choose a construct file first",
+ "workspace.project_saved": "Project saved: {name}",
+ "workspace.export_failed": "Cannot save the report",
+ "workspace.no_results": "No verification results yet",
+ "workspace.exported": "Report saved: {name}",
}
diff --git a/ui/chrom_view.py b/ui/chrom_view.py
index 0478ac5..3ed01f0 100644
--- a/ui/chrom_view.py
+++ b/ui/chrom_view.py
@@ -10,6 +10,7 @@
from chrom_core import Chromatogram
from i18n import tr
+from ui.status import quality_bucket
from ui.theme import LIGHT
MIN_ZOOM = 0.02
@@ -525,7 +526,7 @@ def _paint_bases(self, painter: QPainter, w: int, h: int) -> None:
prev_peak = peak
continue
prev_peak = peak
- bucket = "low_q" if q < 10 else "mid_q" if q < 20 else "high_q"
+ bucket = quality_bucket(q)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(self.theme[bucket]))
bar_h = int((Q_STRIP_H - 6) * q / max_q)
diff --git a/ui/construct/__init__.py b/ui/construct/__init__.py
new file mode 100644
index 0000000..7d37761
--- /dev/null
+++ b/ui/construct/__init__.py
@@ -0,0 +1,4 @@
+"""Construct verification screen: summary bar, clone matrix (and later details, grouping)."""
+
+# verdict order everywhere on this screen (counters, legend, filters)
+VERDICTS = ("ok", "errors", "incomplete", "mixed", "no_data")
diff --git a/ui/construct/controls.py b/ui/construct/controls.py
new file mode 100644
index 0000000..cfa3fb2
--- /dev/null
+++ b/ui/construct/controls.py
@@ -0,0 +1,266 @@
+"""Construct-screen controls: drop zones of the empty state and the row of
+region / parameter inputs above the matrix."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtCore import QLocale, Qt, Signal
+from PySide6.QtGui import QDragEnterEvent, QDropEvent, QMouseEvent
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QDoubleSpinBox,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from chrom_core.clones import Region, VerifyParams
+from i18n import language, tr
+from ui.theme import theme as palette
+
+
+def dropped_paths(event: QDropEvent | QDragEnterEvent) -> list[Path]:
+ return [Path(u.toLocalFile()) for u in event.mimeData().urls() if u.toLocalFile()]
+
+
+class DropZone(QFrame):
+ """Big dashed area: accepts dropped files/folders and works as a button."""
+
+ clicked = Signal()
+ dropped = Signal(list) # list[Path]
+
+ def __init__(self, text: str, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setAcceptDrops(True)
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setMinimumSize(280, 180)
+ self._prompt = text
+ self._done = ""
+ self._hover = False
+ self._theme = "light"
+ self._label = QLabel(text)
+ self._label.setWordWrap(True)
+ self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._detail = QLabel()
+ self._detail.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._detail.setWordWrap(True)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(24, 24, 24, 24)
+ layout.addStretch(1)
+ layout.addWidget(self._label)
+ layout.addWidget(self._detail)
+ self._extra = QVBoxLayout()
+ layout.addLayout(self._extra)
+ layout.addStretch(1)
+ self._restyle()
+
+ def text(self) -> str:
+ return self._label.text()
+
+ def add_widget(self, widget: QWidget) -> None:
+ """Extra control under the texts (e.g. a secondary "choose files" button)."""
+ self._extra.addWidget(widget, 0, Qt.AlignmentFlag.AlignHCenter)
+
+ def detail_text(self) -> str:
+ return self._detail.text()
+
+ def set_done(self, text: str) -> None:
+ """Mark the zone as filled (``text`` names what was loaded); "" resets it."""
+ self._done = text
+ self._detail.setText(f"✓ {text}" if text else tr("workspace.click_to_choose"))
+ self._restyle()
+
+ def set_theme(self, name: str) -> None:
+ self._theme = name
+ self._restyle()
+
+ def _restyle(self) -> None:
+ colors = palette(self._theme)
+ accent = colors["region"]
+ border = accent if (self._hover or self._done) else colors["dim"]
+ self.setStyleSheet(
+ f"DropZone {{ border: 2px dashed {border}; border-radius: 12px;"
+ f" background-color: {colors['panel']}; }}"
+ )
+ self._label.setStyleSheet(f"font-size: 15pt; color: {colors['text']};")
+ self._detail.setStyleSheet(f"color: {accent if self._done else colors['dim']};")
+ if not self._detail.text():
+ self._detail.setText(tr("workspace.click_to_choose"))
+
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(
+ event.position().toPoint()):
+ self.clicked.emit()
+
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
+ if dropped_paths(event):
+ self._hover = True
+ self._restyle()
+ event.acceptProposedAction()
+
+ def dragLeaveEvent(self, event) -> None: # noqa: N802
+ self._hover = False
+ self._restyle()
+
+ def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
+ self._hover = False
+ self._restyle()
+ paths = dropped_paths(event)
+ if paths:
+ event.acceptProposedAction()
+ self.dropped.emit(paths)
+
+
+class EmptyState(QWidget):
+ """Shown until both a construct and reads are loaded."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.construct_zone = DropZone(tr("workspace.drop_construct"))
+ self.reads_zone = DropZone(tr("workspace.drop_reads"))
+ self.reads_files = QPushButton(tr("workspace.choose_files"))
+ self.reads_files.setFlat(True)
+ self.reads_files.setCursor(Qt.CursorShape.PointingHandCursor)
+ title = QLabel(tr("workspace.empty_title"))
+ title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ title.setStyleSheet("font-size: 18pt; font-weight: 600;")
+ hint = QLabel(tr("workspace.empty_hint"))
+ hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ hint.setWordWrap(True)
+ self._hint = hint
+
+ zones = QHBoxLayout()
+ zones.setSpacing(24)
+ zones.addStretch(1)
+ zones.addWidget(self.construct_zone, 3)
+ self.reads_zone.add_widget(self.reads_files)
+ zones.addWidget(self.reads_zone, 3)
+ zones.addStretch(1)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(24, 24, 24, 24)
+ layout.addStretch(1)
+ layout.addWidget(title)
+ layout.addWidget(hint)
+ layout.addSpacing(16)
+ layout.addLayout(zones, 2)
+ layout.addStretch(1)
+
+ def zones(self) -> list[DropZone]:
+ return [self.construct_zone, self.reads_zone]
+
+ def set_theme(self, name: str) -> None:
+ for zone in self.zones():
+ zone.set_theme(name)
+ self._hint.setStyleSheet(f"color: {palette(name)['dim']};")
+
+
+class ControlsBar(QWidget):
+ """Region (1-based, inclusive), circular flag, thresholds and data buttons.
+
+ Spin boxes report only finished input (no keyboard tracking). Programmatic
+ updates (``set_region`` etc.) emit nothing.
+ """
+
+ region_edited = Signal(object) # Region (0-based half-open)
+ whole_requested = Signal()
+ params_changed = Signal()
+ circular_toggled = Signal(bool)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.start_spin = self._spin(1, 1)
+ self.end_spin = self._spin(1, 1)
+ self.whole_button = QPushButton(tr("workspace.whole"))
+ self.whole_button.setToolTip(tr("workspace.whole_tip"))
+ self.circular = QCheckBox(tr("workspace.circular"))
+ self.circular.setToolTip(tr("workspace.circular_tip"))
+ defaults = VerifyParams()
+ self.min_q = self._spin(0, 60, defaults.min_q)
+ self.min_q.setToolTip(tr("workspace.min_q_tip"))
+ self.het_ratio = QDoubleSpinBox()
+ self.het_ratio.setRange(0.05, 0.95)
+ self.het_ratio.setSingleStep(0.05)
+ self.het_ratio.setDecimals(2)
+ self.het_ratio.setLocale(QLocale(
+ QLocale.Language.English if language() == "en" else QLocale.Language.Russian))
+ self.het_ratio.setValue(defaults.het_ratio)
+ self.het_ratio.setKeyboardTracking(False)
+ self.het_ratio.setToolTip(tr("workspace.het_ratio_tip"))
+ self.mixed_min = self._spin(1, 99, defaults.mixed_min_positions)
+ self.mixed_min.setToolTip(tr("workspace.mixed_min_tip"))
+ self.grouping_button = QPushButton(tr("workspace.grouping"))
+ self.construct_button = QPushButton(tr("workspace.construct_button"))
+ self.reads_button = QPushButton(tr("workspace.add_reads"))
+
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(8, 2, 8, 2)
+ layout.setSpacing(6)
+ for widget in (QLabel(tr("workspace.region_from")), self.start_spin,
+ QLabel(tr("workspace.region_to")), self.end_spin, self.whole_button):
+ layout.addWidget(widget)
+ layout.addSpacing(10)
+ layout.addWidget(self.circular)
+ layout.addSpacing(10)
+ for text, widget in ((tr("workspace.min_q"), self.min_q),
+ (tr("workspace.het_ratio"), self.het_ratio),
+ (tr("workspace.mixed_min"), self.mixed_min)):
+ layout.addWidget(QLabel(text))
+ layout.addWidget(widget)
+ layout.addStretch(1)
+ # data buttons live in their own strip (placed by the workspace next to
+ # the summary) so the parameter row stays narrow
+ self.data_buttons = QWidget()
+ buttons = QHBoxLayout(self.data_buttons)
+ buttons.setContentsMargins(0, 0, 8, 0)
+ for button in (self.grouping_button, self.reads_button, self.construct_button):
+ buttons.addWidget(button)
+
+ self.start_spin.valueChanged.connect(self._region_from_spins)
+ self.end_spin.valueChanged.connect(self._region_from_spins)
+ self.whole_button.clicked.connect(self.whole_requested)
+ self.circular.toggled.connect(self.circular_toggled)
+ for spin in (self.min_q, self.het_ratio, self.mixed_min):
+ spin.valueChanged.connect(lambda _v: self.params_changed.emit())
+
+ @staticmethod
+ def _spin(lo: int, hi: int, value: int | None = None) -> QSpinBox:
+ spin = QSpinBox()
+ spin.setRange(lo, hi)
+ spin.setKeyboardTracking(False)
+ if value is not None:
+ spin.setValue(value)
+ return spin
+
+ # ------------------------------------------------------------- public API
+ def set_region(self, region: Region | None, length: int) -> None:
+ """Show ``region`` (``None``: 1..length) without emitting ``region_edited``."""
+ start, end = (0, length) if region is None else region
+ for spin, value in ((self.start_spin, start + 1), (self.end_spin, end or length)):
+ spin.blockSignals(True)
+ spin.setRange(1, max(1, length))
+ spin.setValue(value)
+ spin.blockSignals(False)
+
+ def set_circular(self, flag: bool) -> None:
+ self.circular.blockSignals(True)
+ self.circular.setChecked(flag)
+ self.circular.blockSignals(False)
+
+ def params(self) -> VerifyParams:
+ return VerifyParams(self.min_q.value(), round(self.het_ratio.value(), 2),
+ self.mixed_min.value())
+
+ def set_params(self, params: VerifyParams) -> None:
+ for spin, value in ((self.min_q, params.min_q), (self.het_ratio, params.het_ratio),
+ (self.mixed_min, params.mixed_min_positions)):
+ spin.blockSignals(True)
+ spin.setValue(value)
+ spin.blockSignals(False)
+
+ def _region_from_spins(self) -> None:
+ self.region_edited.emit((self.start_spin.value() - 1, self.end_spin.value()))
diff --git a/ui/construct/detail_pane.py b/ui/construct/detail_pane.py
new file mode 100644
index 0000000..3f681f9
--- /dev/null
+++ b/ui/construct/detail_pane.py
@@ -0,0 +1,329 @@
+"""Difference detail: every read of a clone centred on one reference position,
+plus the clone's differences table, so the user can judge an error by eye."""
+
+from __future__ import annotations
+
+import struct
+from pathlib import Path
+
+from PySide6.QtCore import QEvent, Qt, Signal
+from PySide6.QtGui import QColor, QFont
+from PySide6.QtWidgets import (
+ QAbstractItemView, QGraphicsOpacityEffect, QLabel, QScrollArea, QSplitter, QTableWidget, QTableWidgetItem,
+ QVBoxLayout, QWidget,
+)
+
+from chrom_core.align import ReadAlignment
+from chrom_core.clones import CloneReport, load_read
+from chrom_core.reference import Construct
+from i18n import tr
+from ui import status
+from ui.chrom_view import MAX_ZOOM, MIN_ZOOM, ChromatogramWidget
+from ui.read_view.analysis_dock import reference_overlay
+from ui.tables import STATUS_ROLE, configure_table
+from ui.theme import theme as palette
+
+CONTEXT = 10 # reference bases shown on each side of the position
+VIEW_HEIGHT = 190 # compact height of one chromatogram
+ROW_STATUS = {"confirmed": "errors", "disputed": "errors", "low_quality": "no_data"}
+STATUS_KEY = {"confirmed": "matrix.legend.confirmed", "disputed": "matrix.legend.disputed",
+ "low_quality": "matrix.legend.low_quality"}
+READ_ERRORS = (OSError, ValueError, KeyError, TypeError, IndexError, AssertionError, struct.error) # AssertionError: BioPython ABIF parser on corrupt files
+DISPUTED_TINT = 0.35 # share of the errors red mixed into the background
+UNCOVERED_OPACITY = 0.35
+EFFECT_CODES = ("synonymous", "frameshift", "inframe_ins", "inframe_del")
+
+
+def displayed_index(alignment: ReadAlignment, ref_pos: int, read_length: int) -> int | None:
+ """Index in the *displayed* read of the base aligned to ``ref_pos``.
+
+ ``ref_to_read`` indexes the aligned orientation (the reverse complement on
+ '-'), so the displayed index is ``n-1-idx`` there. A position deleted in the
+ read falls back to the nearest mapped neighbour; ``None`` when not covered,
+ including an index outside ``0..read_length-1`` (the read was edited after
+ the alignment was stored).
+ """
+ if not alignment.covers(ref_pos):
+ return None
+ mapping, length = alignment.ref_to_read, alignment.ref_length
+ idx = None
+ for step in range(len(mapping) + (length or 0) + 1):
+ for pos in ((ref_pos + step, ref_pos - step) if step else (ref_pos,)):
+ if length:
+ pos %= length
+ if pos in mapping:
+ idx = mapping[pos]
+ break
+ if idx is not None:
+ break
+ if idx is None:
+ return None
+ idx = idx if alignment.strand == "+" else read_length - 1 - idx
+ return idx if 0 <= idx < read_length else None
+
+
+class DetailPane(QWidget):
+ open_read = Signal(Path)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._report: CloneReport | None = None
+ self._construct: Construct | None = None
+ self._views: list[ChromatogramWidget] = []
+ self._results: list = []
+ self._rows: list[QWidget] = [] # one container per read (view or placeholder)
+ self._notes: list[QLabel] = [] # 'position not covered' label per view
+ self._paths: dict[ChromatogramWidget, Path] = {}
+ self._ref_pos: int | None = None
+ self._zoom: float | None = None
+ self._zoom_chosen = False # the user zoomed: keep it across resizes
+ self._syncing = False
+ self._theme_name = status.current_theme()
+
+ self._ref_label = QLabel()
+ mono = QFont("Consolas")
+ mono.setStyleHint(QFont.StyleHint.Monospace)
+ self._ref_label.setFont(mono)
+ self._ref_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ self._stack = QWidget()
+ self._stack_layout = QVBoxLayout(self._stack)
+ self._stack_layout.setContentsMargins(0, 0, 0, 0)
+ self._stack_layout.setSpacing(2)
+ self._stack_layout.addStretch(1)
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(self._stack)
+
+ left = QWidget()
+ left_layout = QVBoxLayout(left)
+ left_layout.setContentsMargins(0, 0, 0, 0)
+ left_layout.addWidget(self._ref_label)
+ left_layout.addWidget(scroll, 1)
+
+ self.table = QTableWidget(0, 5)
+ self.table.setHorizontalHeaderLabels([
+ tr("detail.col.pos"), tr("detail.col.change"), tr("detail.col.effect"),
+ tr("detail.col.support"), tr("detail.col.status"),
+ ])
+ configure_table(self.table, stretch_column=2)
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+ self.table.itemSelectionChanged.connect(self._row_selected)
+
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+ splitter.addWidget(left)
+ splitter.addWidget(self.table)
+ splitter.setStretchFactor(0, 3)
+ splitter.setStretchFactor(1, 2)
+ self._splitter = splitter
+ self._split_set = False # first real width: chromatograms 3/5, table 2/5
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(splitter)
+
+ # ---------------------------------------------------------------- API
+ def views(self) -> list[ChromatogramWidget]:
+ return list(self._views)
+
+ def reference_text(self) -> str:
+ return self._ref_label.text()
+
+ def set_theme(self, name: str) -> None:
+ self._theme_name = name
+ for view in self._views:
+ view.set_theme(palette(name))
+ self._paint_table()
+
+ def show_clone(self, report: CloneReport, construct: Construct) -> None:
+ self._report, self._construct = report, construct
+ self._ref_pos, self._zoom, self._zoom_chosen = None, None, False
+ self._ref_label.clear()
+ for row in self._rows:
+ row.hide()
+ row.deleteLater()
+ self._views, self._results, self._paths, self._rows, self._notes = [], [], {}, [], []
+ for result in report.reads:
+ name = result.ref.path.name
+ if result.alignment is None:
+ error = tr("detail.err.no_alignment") if result.error == "no_alignment" else result.error
+ self._add_row(QLabel(f"{result.ref.primer} · {name}: {error}"))
+ continue
+ try:
+ chrom = load_read(result.ref.path)
+ except READ_ERRORS as exc:
+ self._add_row(QLabel(f"{result.ref.primer} · {name}: {exc}"))
+ continue
+ view = ChromatogramWidget()
+ view.setMinimumSize(0, VIEW_HEIGHT)
+ view.setMaximumHeight(VIEW_HEIGHT)
+ view.set_theme(palette(self._theme_name))
+ view.set_chromatogram(chrom)
+ letters, mapping = reference_overlay(result.alignment, construct.seq, len(chrom.seq))
+ view.set_reference(letters, mapping)
+ title = f"{result.ref.primer} · {name} ({result.alignment.strand})"
+ if result.ambiguous:
+ title += " — " + tr("detail.ambiguous")
+ view.setToolTip(title)
+ view.installEventFilter(self)
+ view.view_changed.connect(lambda v=view: self._view_changed(v))
+ note = QLabel(tr("detail.uncovered"))
+ note.hide()
+ container = QWidget()
+ box = QVBoxLayout(container)
+ box.setContentsMargins(0, 0, 0, 0)
+ box.setSpacing(0)
+ box.addWidget(QLabel(title))
+ box.addWidget(note)
+ box.addWidget(view)
+ self._paths[view] = result.ref.path
+ self._views.append(view)
+ self._results.append(result)
+ self._notes.append(note)
+ self._add_row(container)
+ self._reset_default_zoom()
+ self._fill_table(report)
+
+ def _add_row(self, widget: QWidget) -> None:
+ self._rows.append(widget)
+ self._stack_layout.insertWidget(self._stack_layout.count() - 1, widget)
+
+ def show_position(self, ref_pos: int) -> None:
+ if self._report is None or self._construct is None:
+ return
+ self._ref_pos = ref_pos
+ self._select_row(ref_pos)
+ self._ref_label.setText(self._reference_context(ref_pos))
+ for view, result, note in zip(self._views, self._results, self._notes):
+ exact = ref_pos in result.alignment.ref_to_read
+ idx = displayed_index(result.alignment, ref_pos, len(view.chrom.seq))
+ view.set_highlights([(idx, idx + 1)] if exact and idx is not None else [])
+ note.setVisible(idx is None)
+ effect = None
+ if idx is None:
+ effect = QGraphicsOpacityEffect(view)
+ effect.setOpacity(UNCOVERED_OPACITY)
+ view.setGraphicsEffect(effect)
+ self._apply_view()
+
+ # ----------------------------------------------------------- internals
+ def _reference_context(self, ref_pos: int) -> str:
+ seq, n = self._construct.seq, len(self._construct.seq)
+
+ def around(a: int, b: int) -> str:
+ if self._construct.circular:
+ return "".join(seq[i % n] for i in range(a, b))
+ return seq[max(0, a):max(0, b)]
+
+ return (f"{ref_pos + 1}: {around(ref_pos - CONTEXT, ref_pos)}[{seq[ref_pos % n]}]"
+ f"{around(ref_pos + 1, ref_pos + CONTEXT + 1)}")
+
+ def _reset_default_zoom(self) -> None:
+ """Default scale (about 21 bases visible), unless the user chose one."""
+ if not self._views or self._zoom_chosen:
+ return
+ spacing = self._views[0]._avg_spacing
+ width = max(self._views[0].width(), 200)
+ self._zoom = min(MAX_ZOOM, max(MIN_ZOOM, width / ((2 * CONTEXT + 1) * spacing)))
+ self._apply_view()
+
+ def _apply_view(self) -> None:
+ """Give every view the shared zoom and centre it on the position."""
+ if self._zoom is None:
+ return
+ self._syncing = True
+ try:
+ for view, result in zip(self._views, self._results):
+ view.zoom = self._zoom
+ if self._ref_pos is not None:
+ idx = displayed_index(result.alignment, self._ref_pos, len(view.chrom.seq))
+ if idx is not None:
+ view.center_on_point(view.chrom.peak_indices[idx])
+ view.update()
+ finally:
+ self._syncing = False
+
+ def _view_changed(self, source: ChromatogramWidget) -> None:
+ """A zoom change in one view is applied to all (shared scale)."""
+ if self._syncing or source.zoom == self._zoom:
+ return
+ self._zoom, self._zoom_chosen = source.zoom, True
+ self._apply_view()
+
+ def resizeEvent(self, event) -> None: # noqa: N802
+ super().resizeEvent(event)
+ if not self._split_set and self.isVisible() and self.width() > 100:
+ self._split_set = True
+ self._splitter.setSizes([self.width() * 3 // 5, self.width() * 2 // 5])
+ self._reset_default_zoom()
+ if self._zoom_chosen:
+ self._apply_view()
+
+ def eventFilter(self, obj, event) -> bool: # noqa: N802
+ if event.type() == QEvent.Type.MouseButtonDblClick and obj in self._paths:
+ self.open_read.emit(self._paths[obj])
+ return True
+ return super().eventFilter(obj, event)
+
+ def _fill_table(self, report: CloneReport) -> None:
+ self.table.blockSignals(True)
+ self.table.setRowCount(len(report.differences))
+ for row, d in enumerate(report.differences):
+ effect = tr(f"effect.{d.effect}") if d.effect in EFFECT_CODES else d.effect
+ cells = [str(d.ref_pos + 1), f"{d.ref_base or '-'}→{d.read_base or '-'}", effect,
+ f"{d.support} / {d.against}", tr(STATUS_KEY[d.status])]
+ for col, text in enumerate(cells):
+ item = QTableWidgetItem(text)
+ if col == 0:
+ item.setData(Qt.ItemDataRole.UserRole, d.ref_pos)
+ if col == 4:
+ item.setData(STATUS_ROLE, d.status)
+ if not d.in_region:
+ item.setToolTip(tr("matrix.legend.outside"))
+ font = item.font()
+ font.setItalic(True)
+ item.setFont(font)
+ self.table.setItem(row, col, item)
+ self.table.blockSignals(False)
+ self._paint_table()
+
+ def _paint_table(self) -> None:
+ """Status cells: confirmed solid red, disputed a weak red tint, low quality
+ grey (shared palette); rows outside the region dimmed. Current theme."""
+ theme, colours = self._theme_name, palette(self._theme_name)
+ base = QColor(colours["bg"])
+ red = status.status_color("errors", theme)
+
+ def mix(a: int, b: int) -> int:
+ return round(a * DISPUTED_TINT + b * (1 - DISPUTED_TINT))
+
+ for row, d in enumerate(self._report.differences if self._report else ()):
+ for col in range(self.table.columnCount()):
+ item = self.table.item(row, col)
+ if item is None:
+ continue
+ if col == 4 and d.status == "disputed":
+ item.setBackground(QColor(mix(red.red(), base.red()), mix(red.green(), base.green()),
+ mix(red.blue(), base.blue())))
+ item.setForeground(QColor(colours["text"]))
+ elif col == 4:
+ verdict = ROW_STATUS[d.status]
+ item.setBackground(status.status_color(verdict, theme))
+ item.setForeground(status.status_text_color(verdict, theme))
+ elif not d.in_region:
+ item.setForeground(QColor(colours["dim"]))
+
+ def _row_selected(self) -> None:
+ rows = self.table.selectionModel().selectedRows()
+ if rows:
+ self.show_position(self.table.item(rows[0].row(), 0).data(Qt.ItemDataRole.UserRole))
+
+ def _select_row(self, ref_pos: int) -> None:
+ self.table.blockSignals(True)
+ self.table.clearSelection()
+ for row in range(self.table.rowCount()):
+ if self.table.item(row, 0).data(Qt.ItemDataRole.UserRole) == ref_pos:
+ self.table.selectRow(row)
+ break
+ self.table.blockSignals(False)
diff --git a/ui/construct/grouping_dialog.py b/ui/construct/grouping_dialog.py
new file mode 100644
index 0000000..339e67b
--- /dev/null
+++ b/ui/construct/grouping_dialog.py
@@ -0,0 +1,165 @@
+"""Grouping dialog: which read belongs to which clone (and primer).
+
+A table "file / clone / primer" with editable clone and primer cells; reads
+without a clone are highlighted. A custom regex (named groups ``clone`` and
+``primer``, matched against the file stem) regroups all reads at once as a
+live preview; an invalid pattern gets a red frame and disables OK.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QBrush, QColor
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QDialog,
+ QDialogButtonBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from chrom_core.clones import DEFAULT_PRIMER_TOKENS, Grouping, ReadRef, group_reads
+from i18n import tr
+from ui import status
+from ui.tables import configure_table
+from ui.theme import theme as palette
+
+COL_FILE, COL_CLONE, COL_PRIMER = range(3)
+_PATH_ROLE = Qt.ItemDataRole.UserRole + 2
+_INVALID_STYLE = "QLineEdit { border: 2px solid #d32f2f; }"
+
+
+class GroupingDialog(QDialog):
+ def __init__(self, grouping: Grouping, pattern: str, parent: QWidget | None = None,
+ primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(tr("grouping.title"))
+ self.resize(640, 480)
+ self._tokens = tuple(primer_tokens)
+ self._paths = [r.path for r in grouping.reads] + list(grouping.unassigned)
+ self._invalid = False
+
+ self.pattern_edit = QLineEdit(pattern)
+ self.pattern_edit.setPlaceholderText(tr("grouping.pattern_placeholder"))
+ self.pattern_edit.setToolTip(tr("grouping.pattern_tip"))
+ self._error = QLabel()
+ self._error.setStyleSheet("color: #d32f2f;")
+ self._summary = QLabel()
+
+ self.table = QTableWidget(0, 3)
+ self.table.setHorizontalHeaderLabels(
+ [tr("grouping.col.file"), tr("grouping.col.clone"), tr("grouping.col.primer")])
+ configure_table(self.table, stretch_column=COL_FILE)
+ self.table.setAlternatingRowColors(False) # the highlight must stay readable
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.table.itemChanged.connect(self._item_changed)
+
+ self.buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ self.buttons.accepted.connect(self.accept)
+ self.buttons.rejected.connect(self.reject)
+
+ row = QHBoxLayout()
+ row.addWidget(QLabel(tr("grouping.pattern")))
+ row.addWidget(self.pattern_edit, 1)
+ layout = QVBoxLayout(self)
+ layout.addLayout(row)
+ layout.addWidget(self._error)
+ layout.addWidget(self.table, 1)
+ layout.addWidget(self._summary)
+ layout.addWidget(self.buttons)
+
+ self._fill(grouping)
+ self._error.hide()
+ try: # a stale pattern from the config: flag it, keep the given grouping
+ group_reads([], pattern or None, self._tokens)
+ except ValueError as exc:
+ self._set_invalid(str(exc))
+ self.pattern_edit.textChanged.connect(self._pattern_changed)
+
+ # ------------------------------------------------------------- public API
+ def result_grouping(self) -> Grouping:
+ grouping = Grouping()
+ for row in range(self.table.rowCount()):
+ path = Path(self.table.item(row, COL_FILE).data(_PATH_ROLE))
+ clone = self.table.item(row, COL_CLONE).text().strip()
+ primer = self.table.item(row, COL_PRIMER).text().strip()
+ if clone:
+ grouping.reads.append(ReadRef(path, clone, primer))
+ else:
+ grouping.unassigned.append(path)
+ return grouping
+
+ def result_pattern(self) -> str:
+ return self.pattern_edit.text()
+
+ def pattern_invalid(self) -> bool:
+ return self._invalid
+
+ def unassigned_rows(self) -> list[int]:
+ return [row for row in range(self.table.rowCount())
+ if not self.table.item(row, COL_CLONE).text().strip()]
+
+ # --------------------------------------------------------------- private
+ def _pattern_changed(self, text: str) -> None:
+ try:
+ grouping = group_reads(self._paths, text or None, self._tokens)
+ except ValueError as exc:
+ self._set_invalid(str(exc))
+ return
+ self._set_invalid("")
+ self._fill(grouping)
+
+ def _set_invalid(self, message: str) -> None:
+ self._invalid = bool(message)
+ self.pattern_edit.setStyleSheet(_INVALID_STYLE if message else "")
+ self._error.setText(message)
+ self._error.setVisible(bool(message))
+ self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not message)
+
+ def _fill(self, grouping: Grouping) -> None:
+ refs = {r.path: r for r in grouping.reads}
+ self.table.blockSignals(True)
+ self.table.setRowCount(len(self._paths))
+ for row, path in enumerate(self._paths):
+ ref = refs.get(path)
+ name = QTableWidgetItem(path.name)
+ name.setData(_PATH_ROLE, str(path))
+ name.setToolTip(str(path))
+ name.setFlags(name.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ self.table.setItem(row, COL_FILE, name)
+ self.table.setItem(row, COL_CLONE, QTableWidgetItem(ref.clone if ref else ""))
+ self.table.setItem(row, COL_PRIMER, QTableWidgetItem(ref.primer if ref else ""))
+ self.table.blockSignals(False)
+ self._highlight()
+
+ def _item_changed(self, _item: QTableWidgetItem) -> None:
+ self._highlight()
+
+ def _highlight(self) -> None:
+ """Rows without a clone get the theme highlight; the summary counts them."""
+ colors = palette(status.current_theme())
+ mark = QBrush(QColor(colors["highlight"]))
+ unassigned = set(self.unassigned_rows())
+ self.table.blockSignals(True)
+ for row in range(self.table.rowCount()):
+ for col in range(3):
+ item = self.table.item(row, col)
+ item.setBackground(mark if row in unassigned else QBrush())
+ if row in unassigned and col == COL_CLONE:
+ item.setToolTip(tr("grouping.no_clone"))
+ elif col == COL_CLONE:
+ item.setToolTip("")
+ self.table.blockSignals(False)
+ clones = {self.table.item(r, COL_CLONE).text().strip()
+ for r in range(self.table.rowCount())} - {""}
+ self._summary.setText(tr("grouping.summary", clones=len(clones),
+ reads=self.table.rowCount(), unassigned=len(unassigned)))
diff --git a/ui/construct/matrix.py b/ui/construct/matrix.py
new file mode 100644
index 0000000..17587a3
--- /dev/null
+++ b/ui/construct/matrix.py
@@ -0,0 +1,623 @@
+"""Clone matrix: every clone as a row on one shared coordinate axis under the
+reference track (layout "B" of the construct screen).
+
+Colours
+-------
+- Coverage (per region position, reads with Q >= min_q): both strands -> theme
+ ``cov_both`` (saturated), one strand -> ``cov_one`` (light), none -> the empty
+ ``track`` background. Outside the region the core computes no coverage, so
+ the track there is hatched as "not checked".
+- Difference marks use the saturated variant of the status palette
+ (``STATUS_HEX`` dark tone) in both themes, so a thin mark stays visible on
+ light coverage: ``confirmed`` -> solid errors red, ``disputed`` -> hollow
+ errors-red outline (at least 5 px wide), ``low_quality`` -> solid no-data
+ grey; mixed (double-peak) positions -> solid mixed purple. Differences
+ outside the region (they do not affect the verdict) keep their style but are
+ shorter and translucent.
+- Verdict chips use ``status_color`` of the current theme.
+
+The axis is linear 0..L also for circular constructs; anything crossing the
+origin (``start > end``) is drawn as two segments.
+"""
+
+from __future__ import annotations
+
+import math
+
+from PySide6.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QMouseEvent, QPainter, QPen, QWheelEvent
+from PySide6.QtWidgets import QWidget
+
+from chrom_core.clones import CloneReport, Region
+from chrom_core.reference import Construct
+from i18n import tr
+from ui import status
+from ui.construct import VERDICTS
+from ui.theme import theme as palette
+
+MARGIN = 6
+RULER_H = 16
+REF_ROW_H = 28
+ROW_H = 24
+TRACK_H = 10 # clone coverage bar
+REF_TRACK_H = 14 # reference / feature bar
+MARK_H = 16
+OUT_MARK_H = 10 # differences outside the region: shorter ...
+OUT_ALPHA = 180 # ... and translucent
+MARK_MIN_W = 3
+OUTLINE_MIN_W = 5 # hollow (disputed) marks need room for an inside
+LEGEND_H = 20
+CHIP_W = 84
+CHIP_H = 16
+GAP = 6
+NAME_MIN, NAME_MAX = 44, 160
+ZOOM_STEP = 1.25 # per wheel notch
+MIN_SPAN = 20.0 # bp visible at most zoom
+DRAG_SLOP = 3 # px before a press becomes a pan
+HIT_SLOP = 4 # px around a mark that still hits it
+
+_STATUS_ORDER = ("low_quality", "disputed", "confirmed") # drawn bottom -> top
+
+
+def segments(start: int, end: int, length: int) -> list[tuple[int, int]]:
+ """Linear pieces of a half-open interval; ``start > end`` wraps the origin."""
+ if start <= end:
+ return [(start, end)]
+ return [(start, length), (0, end)]
+
+
+def mark_colors() -> dict[str, QColor]:
+ """Mark colour per difference status, plus ``mixed``. The same saturated
+ tones are used in both themes (see the module docstring)."""
+ return {
+ "confirmed": status.status_color("errors", "dark"),
+ "disputed": status.status_color("errors", "dark"),
+ "low_quality": status.status_color("no_data", "dark"),
+ "mixed": status.status_color("mixed", "dark"),
+ }
+
+
+def outside(region: tuple[int, int] | None, length: int) -> list[tuple[int, int]]:
+ """Linear pieces of the construct outside ``region`` (none for ``None``)."""
+ if region is None:
+ return []
+ start, end = region
+ return [(a, b) for a, b in segments(end, start, length) if b > a]
+
+
+def _coverage_runs(report: CloneReport) -> list[tuple[int, int, int]]:
+ """``(start, end, level)`` runs of consecutive positions; level 2 = both
+ strands, 1 = one strand (positions without coverage are left out)."""
+ runs: list[list[int]] = []
+ for pos in sorted(report.coverage):
+ plus, minus = report.coverage[pos]
+ level = 2 if plus and minus else 1 if plus + minus else 0
+ if level == 0:
+ continue
+ if runs and runs[-1][1] == pos and runs[-1][2] == level:
+ runs[-1][1] = pos + 1
+ else:
+ runs.append([pos, pos + 1, level])
+ return [tuple(r) for r in runs]
+
+
+class _Row:
+ def __init__(self, report: CloneReport) -> None:
+ self.report = report
+ self.runs = _coverage_runs(report)
+ self.differences = sorted( # in-region and stronger statuses painted last, on top
+ report.differences,
+ key=lambda d: (d.in_region,
+ _STATUS_ORDER.index(d.status) if d.status in _STATUS_ORDER else 0))
+ self.mixed = report.mixed_positions
+
+
+class CloneMatrix(QWidget):
+ """Reference track plus one row per clone; custom painted.
+
+ Wheel zooms the shared axis around the cursor, dragging pans it, a Shift+drag
+ on the reference track picks a region. A click on a mark emits
+ ``difference_clicked(clone, ref_pos)``, elsewhere on a row ``clone_clicked``.
+ """
+
+ difference_clicked = Signal(str, int)
+ clone_clicked = Signal(str)
+ region_changed = Signal(object)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._theme = status.current_theme()
+ self._construct: Construct | None = None
+ self._region: Region | None = None
+ self._rows: list[_Row] = []
+ self._filter = set(VERDICTS)
+ self._view_start = 0.0
+ self._view_span = 1.0
+ self._press: QPoint | None = None
+ self._press_view = 0.0
+ self._mode = "" # "", "click", "pan", "region"
+ self._band: tuple[float, float] | None = None
+ self._name_w = NAME_MIN
+ self.setToolTip(tr("matrix.hint"))
+ self.setMinimumWidth(320)
+
+ # ------------------------------------------------------------- public API
+ def set_data(self, construct: Construct, region: Region | None,
+ reports: list[CloneReport]) -> None:
+ self._construct = construct
+ self._region = region
+ self._rows = [_Row(r) for r in reports]
+ self._update_name_width()
+ self._view_start, self._view_span = 0.0, float(max(1, len(construct.seq)))
+ self.updateGeometry()
+ self.update()
+
+ def clear(self) -> None:
+ """No construct, no rows (the empty placeholder is painted)."""
+ self._construct, self._region, self._rows = None, None, []
+ self._band, self._press, self._mode = None, None, ""
+ self.updateGeometry()
+ self.update()
+
+ def set_filter(self, verdicts: set[str]) -> None:
+ self._filter = set(verdicts)
+ self.updateGeometry()
+ self.update()
+
+ def set_theme(self, name: str) -> None:
+ self._theme = "dark" if name == "dark" else "light"
+ self.update()
+
+ def visible_clones(self) -> list[str]:
+ return [row.report.clone for row in self._visible()]
+
+ def view_range(self) -> tuple[float, float]:
+ """``(first position, number of positions)`` shown on the axis."""
+ return (self._view_start, self._view_span)
+
+ def x_for(self, pos: float) -> float:
+ left, width = self._track_left(), self._track_width()
+ return left + (pos - self._view_start) / self._view_span * width
+
+ def pos_at(self, x: float) -> float:
+ return self._view_start + (x - self._track_left()) / self._track_width() * self._view_span
+
+ def ruler_ticks(self) -> list[tuple[int, float]]:
+ """``(label, x)`` of the ruler ticks: 1-based base numbers at base centres,
+ so they read like the summary's region text."""
+ fm = QFontMetrics(self._small_font())
+ label_w = fm.horizontalAdvance(f"{self._length():,}") + 16
+ raw = self._view_span * label_w / self._track_width()
+ magnitude = 10 ** math.floor(math.log10(max(raw, 1)))
+ step = next(m * magnitude for m in (1, 2, 5, 10) if m * magnitude >= raw)
+ end = self._view_start + self._view_span
+ v = max(step, math.ceil((self._view_start + 0.5) / step) * step)
+ ticks = []
+ while v - 0.5 <= end and v <= self._length():
+ ticks.append((int(v), self.x_for(v - 0.5)))
+ v += step
+ return ticks
+
+ def reference_rect(self) -> QRect:
+ return QRect(round(self._track_left()), MARGIN + RULER_H,
+ round(self._track_width()), REF_ROW_H)
+
+ def row_rect(self, clone: str) -> QRect:
+ """Full-width rectangle of a shown clone's row; null when hidden or unknown."""
+ for i, row in enumerate(self._visible()):
+ if row.report.clone == clone:
+ return QRect(0, self._rows_top() + i * ROW_H, self.width(), ROW_H)
+ return QRect()
+
+ def chip_rect(self, clone: str) -> QRect:
+ row = self.row_rect(clone)
+ if row.isNull():
+ return QRect()
+ return QRect(self.width() - MARGIN - CHIP_W, row.center().y() - CHIP_H // 2 + 1,
+ CHIP_W, CHIP_H)
+
+ def mark_point(self, clone: str, ref_pos: int) -> QPoint:
+ """Centre of the mark drawn for ``ref_pos`` in ``clone``'s row."""
+ data = self._row(clone)
+ diffs = data.differences if data is not None else []
+ kind = next((d.kind for d in diffs if d.ref_pos == ref_pos), "substitution")
+ return QPoint(round(self._mark_x(ref_pos, kind)), self.row_rect(clone).center().y())
+
+ def hit_test(self, pos: QPoint) -> tuple[str, int] | None:
+ """``(clone, ref_pos)`` of the difference or mixed mark under ``pos``."""
+ index = self._row_index_at(pos.y())
+ if index is None or not self._track_left() - HIT_SLOP <= pos.x() <= self._track_right() + HIT_SLOP:
+ return None
+ row = self._visible()[index]
+ slop = max(HIT_SLOP, self._px_per_base() / 2 + 1)
+ best: tuple[float, int, int] | None = None # (distance, priority, ref_pos)
+ candidates = [(d.ref_pos, d.kind, 0) for d in row.differences]
+ candidates += [(p, "substitution", 1) for p in row.mixed]
+ for ref_pos, kind, priority in candidates:
+ distance = abs(self._mark_x(ref_pos, kind) - pos.x())
+ if distance <= slop and (best is None or (distance, priority) < best[:2]):
+ best = (distance, priority, ref_pos)
+ return (row.report.clone, best[2]) if best else None
+
+ def sizeHint(self) -> QSize: # noqa: N802
+ return QSize(720, self._content_height())
+
+ def minimumSizeHint(self) -> QSize: # noqa: N802
+ return QSize(320, self._content_height())
+
+ # --------------------------------------------------------------- geometry
+ def _visible(self) -> list[_Row]:
+ return [r for r in self._rows if r.report.verdict in self._filter]
+
+ def _row(self, clone: str) -> _Row | None:
+ return next((r for r in self._rows if r.report.clone == clone), None)
+
+ def _length(self) -> int:
+ return len(self._construct.seq) if self._construct else 1
+
+ def _name_width(self) -> int:
+ return self._name_w
+
+ def _update_name_width(self) -> None:
+ """Measure the clone names once (on new data or a font/style change)."""
+ fm = QFontMetrics(self._bold_font())
+ widest = max((fm.horizontalAdvance(r.report.clone) for r in self._rows), default=0)
+ self._name_w = min(NAME_MAX, max(NAME_MIN, widest + GAP))
+
+ def changeEvent(self, event: QEvent) -> None: # noqa: N802
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange):
+ self._update_name_width()
+ self.update()
+ super().changeEvent(event)
+
+ def _track_left(self) -> float:
+ return float(MARGIN + self._name_width())
+
+ def _track_right(self) -> float:
+ return float(self.width() - MARGIN - CHIP_W - GAP)
+
+ def _track_width(self) -> float:
+ return max(1.0, self._track_right() - self._track_left())
+
+ def _px_per_base(self) -> float:
+ return self._track_width() / self._view_span
+
+ def _rows_top(self) -> int:
+ return MARGIN + RULER_H + REF_ROW_H
+
+ def _content_height(self) -> int:
+ rows = max(1, len(self._visible()))
+ return self._rows_top() + rows * ROW_H + GAP + LEGEND_H + MARGIN
+
+ def _row_index_at(self, y: int) -> int | None:
+ offset = y - self._rows_top()
+ if offset < 0:
+ return None
+ index = offset // ROW_H
+ return index if index < len(self._visible()) else None
+
+ def _mark_x(self, ref_pos: int, kind: str) -> float:
+ # an insertion sits before ``ref_pos``: on the boundary, not the base centre
+ return self.x_for(ref_pos if kind == "insertion" else ref_pos + 0.5)
+
+ def _bold_font(self) -> QFont:
+ font = QFont(self.font())
+ font.setBold(True)
+ return font
+
+ def _small_font(self) -> QFont:
+ font = QFont(self.font())
+ font.setPointSizeF(max(6.0, font.pointSizeF() - 1.5))
+ return font
+
+ def _set_view(self, start: float, span: float) -> None:
+ length = float(self._length())
+ span = min(length, max(min(MIN_SPAN, length), span))
+ start = min(max(0.0, start), length - span)
+ if (start, span) != (self._view_start, self._view_span):
+ self._view_start, self._view_span = start, span
+ self.update()
+
+ # ------------------------------------------------------------------ input
+ def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
+ if self._construct is None:
+ return
+ steps = event.angleDelta().y() / 120.0
+ if not steps:
+ return
+ x = event.position().x()
+ if not self._track_left() <= x <= self._track_right():
+ x = (self._track_left() + self._track_right()) / 2
+ anchor = self.pos_at(x)
+ fraction = (x - self._track_left()) / self._track_width()
+ span = self._view_span / ZOOM_STEP ** steps
+ length = float(self._length())
+ span = min(length, max(min(MIN_SPAN, length), span))
+ self._set_view(anchor - fraction * span, span)
+ event.accept()
+
+ def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if event.button() != Qt.MouseButton.LeftButton or self._construct is None:
+ return
+ point = event.position().toPoint()
+ self._press, self._press_view = point, self._view_start
+ on_reference = self.reference_rect().adjusted(-HIT_SLOP, 0, HIT_SLOP, 0).contains(point)
+ if event.modifiers() & Qt.KeyboardModifier.ShiftModifier and on_reference:
+ self._mode = "region"
+ x = self._clamp_x(point.x())
+ self._band = (x, x)
+ self.update()
+ else:
+ self._mode = "click"
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if self._press is None:
+ return
+ x = event.position().x()
+ if self._mode == "region":
+ self._band = (self._band[0], self._clamp_x(x))
+ self.update()
+ return
+ dx = x - self._press.x()
+ if self._mode == "click" and abs(dx) > DRAG_SLOP:
+ self._mode = "pan"
+ if self._mode == "pan":
+ self._set_view(self._press_view - dx / self._track_width() * self._view_span,
+ self._view_span)
+
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
+ if self._press is None or event.button() != Qt.MouseButton.LeftButton:
+ return
+ mode, press = self._mode, self._press
+ self._press, self._mode = None, ""
+ if mode == "region":
+ a, b = sorted(self._band or (0.0, 0.0))
+ self._band = None
+ self.update()
+ start, end = round(self.pos_at(a)), round(self.pos_at(b))
+ start, end = max(0, start), min(self._length(), end)
+ if end > start:
+ self.region_changed.emit((start, end))
+ elif mode == "click":
+ hit = self.hit_test(press)
+ if hit is not None:
+ self.difference_clicked.emit(*hit)
+ return
+ index = self._row_index_at(press.y())
+ if index is not None:
+ self.clone_clicked.emit(self._visible()[index].report.clone)
+
+ def _clamp_x(self, x: float) -> float:
+ lo, hi = self.x_for(0), self.x_for(self._length())
+ return min(max(x, max(lo, self._track_left())), min(hi, self._track_right()))
+
+ # --------------------------------------------------------------- painting
+ def paintEvent(self, event) -> None: # noqa: N802, ARG002
+ colors = palette(self._theme)
+ painter = QPainter(self)
+ painter.fillRect(self.rect(), QColor(colors["bg"]))
+ if self._construct is None:
+ self._paint_empty(painter, colors)
+ painter.end()
+ return
+ self._paint_ruler(painter, colors)
+ self._paint_reference(painter, colors)
+ rows = self._visible()
+ for i, row in enumerate(rows):
+ self._paint_row(painter, colors, row, self._rows_top() + i * ROW_H)
+ if not rows:
+ self._paint_empty(painter, colors)
+ self._paint_region_lines(painter, colors, len(rows))
+ self._paint_legend(painter, colors, max(1, len(rows)))
+ painter.end()
+
+ def _track_clip(self) -> QRectF:
+ return QRectF(self._track_left(), 0, self._track_width(), self.height())
+
+ def _spans(self, start: int, end: int) -> list[QRectF]:
+ """Pixel x-extents (as zero-height rects) of an interval, split at the origin."""
+ return [QRectF(self.x_for(a), 0, self.x_for(b) - self.x_for(a), 0)
+ for a, b in segments(start, end, self._length())]
+
+ def _paint_empty(self, painter: QPainter, colors: dict) -> None:
+ painter.setPen(QColor(colors["dim"]))
+ rect = QRect(0, self._rows_top(), self.width(), ROW_H)
+ painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, tr("matrix.empty"))
+
+ def _paint_ruler(self, painter: QPainter, colors: dict) -> None:
+ painter.setFont(self._small_font())
+ label_w = painter.fontMetrics().horizontalAdvance(f"{self._length():,}") + 16
+ y = MARGIN + RULER_H
+ painter.setPen(QPen(QColor(colors["dim"]), 1))
+ painter.save()
+ painter.setClipRect(self._track_clip().adjusted(-label_w, 0, label_w, 0))
+ for v, x in self.ruler_ticks():
+ painter.drawLine(QPointF(x, y - 4), QPointF(x, y))
+ painter.drawText(QRectF(x - label_w / 2, MARGIN - 2, label_w, RULER_H - 3),
+ Qt.AlignmentFlag.AlignCenter, f"{v:,}".replace(",", "\u202f"))
+ painter.restore()
+ painter.drawLine(QPointF(self._track_left(), y), QPointF(self._track_right(), y))
+
+ def _paint_reference(self, painter: QPainter, colors: dict) -> None:
+ top = MARGIN + RULER_H
+ cy = top + REF_ROW_H / 2
+ track = QRectF(self._track_left(), cy - REF_TRACK_H / 2, self._track_width(), REF_TRACK_H)
+ painter.setFont(self._small_font())
+ painter.setPen(QColor(colors["dim"]))
+ painter.drawText(QRectF(self.width() - MARGIN - CHIP_W, top, CHIP_W, REF_ROW_H),
+ Qt.AlignmentFlag.AlignCenter, tr("matrix.reference"))
+ painter.save()
+ painter.setClipRect(self._track_clip())
+ self._fill_axis(painter, track, 0, self._length(), QColor(colors["track"]))
+ seen = set()
+ for feature in self._construct.features:
+ key = (feature.name, feature.start, feature.end)
+ if feature.kind == "source" or key in seen:
+ continue
+ seen.add(key)
+ colour = QColor(colors["feature_cds" if feature.kind in ("CDS", "gene") else "feature"])
+ for span in self._spans(feature.start, feature.end):
+ rect = QRectF(span.left(), track.top(), span.width(), track.height())
+ painter.fillRect(rect, colour)
+ if rect.width() > 24:
+ painter.setPen(QColor("#ffffff"))
+ text = painter.fontMetrics().elidedText(
+ feature.name, Qt.TextElideMode.ElideRight, int(rect.width()) - 6)
+ visible = rect.intersected(self._track_clip())
+ painter.drawText(visible.adjusted(6, 0, -3, 0),
+ Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text)
+ start, end = self._region if self._region is not None else (0, self._length())
+ painter.setPen(QPen(QColor(colors["region"]), 2))
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ for span in self._spans(start, end):
+ painter.drawRoundedRect(QRectF(span.left(), track.top() - 3, span.width(),
+ track.height() + 6), 3, 3)
+ if self._band is not None:
+ a, b = sorted(self._band)
+ band = QColor(colors["region"])
+ band.setAlpha(80)
+ painter.fillRect(QRectF(a, track.top() - 3, b - a, track.height() + 6), band)
+ painter.restore()
+
+ def _fill_axis(self, painter: QPainter, track: QRectF, start: int, end: int,
+ colour: QColor) -> None:
+ for span in self._spans(start, end):
+ painter.fillRect(QRectF(span.left(), track.top(), span.width(), track.height()), colour)
+
+ def _paint_row(self, painter: QPainter, colors: dict, row: _Row, top: int) -> None:
+ report = row.report
+ cy = top + ROW_H / 2
+ painter.setFont(self._bold_font())
+ painter.setPen(QColor(colors["text"]))
+ name = painter.fontMetrics().elidedText(report.clone, Qt.TextElideMode.ElideRight,
+ self._name_width() - GAP)
+ painter.drawText(QRectF(MARGIN, top, self._name_width() - GAP, ROW_H),
+ Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, name)
+
+ track = QRectF(self._track_left(), cy - TRACK_H / 2, self._track_width(), TRACK_H)
+ painter.save()
+ painter.setClipRect(self._track_clip())
+ self._fill_axis(painter, track, 0, self._length(), QColor(colors["track"]))
+ self._paint_unchecked(painter, colors, track)
+ shades = {2: QColor(colors["cov_both"]), 1: QColor(colors["cov_one"])}
+ first, last = self._view_start, self._view_start + self._view_span
+ for start, end, level in row.runs: # runs are linear (never wrap)
+ if end <= first or start >= last:
+ continue
+ x0 = self.x_for(start)
+ painter.fillRect(QRectF(x0, track.top(), self.x_for(end) - x0, track.height()),
+ shades[level])
+ marks = mark_colors()
+ for pos in row.mixed:
+ self._paint_mark(painter, self._mark_x(pos, "substitution"), cy, marks["mixed"])
+ for d in row.differences:
+ if not first - 1 <= d.ref_pos <= last:
+ continue
+ colour = QColor(marks.get(d.status, marks["low_quality"]))
+ if not d.in_region:
+ colour.setAlpha(OUT_ALPHA)
+ self._paint_mark(painter, self._mark_x(d.ref_pos, d.kind), cy, colour,
+ hollow=d.status == "disputed",
+ height=MARK_H if d.in_region else OUT_MARK_H)
+ painter.restore()
+ self._paint_chip(painter, report)
+
+ def _paint_unchecked(self, painter: QPainter, colors: dict, track: QRectF) -> None:
+ """Hatch the track outside the region: not checked, not "no coverage"."""
+ hatch = QColor(colors["dim"])
+ hatch.setAlpha(140)
+ for a, b in outside(self._region, self._length()):
+ x0 = self.x_for(a)
+ painter.fillRect(QRectF(x0, track.top(), self.x_for(b) - x0, track.height()),
+ QBrush(hatch, Qt.BrushStyle.BDiagPattern))
+
+ def _paint_mark(self, painter: QPainter, x: float, cy: float, colour: QColor,
+ hollow: bool = False, height: int = MARK_H) -> None:
+ """A whole-pixel mark centred on ``x``; ``hollow`` draws a 1 px outline."""
+ width = max(OUTLINE_MIN_W if hollow else MARK_MIN_W, round(self._px_per_base()))
+ left, top = round(x - width / 2), round(cy - height / 2)
+ if not hollow:
+ painter.fillRect(QRect(left, top, width, height), colour)
+ return
+ for rect in (QRect(left, top, width, 1), QRect(left, top + height - 1, width, 1),
+ QRect(left, top, 1, height), QRect(left + width - 1, top, 1, height)):
+ painter.fillRect(rect, colour)
+
+ def _chip_text(self, report: CloneReport) -> str:
+ if report.verdict == "errors":
+ n = sum(1 for d in report.region_differences if d.status != "low_quality")
+ return tr("matrix.chip.errors", n=n)
+ if report.verdict == "incomplete":
+ return tr("matrix.chip.incomplete", pct=math.floor(report.region_coverage_pct))
+ return tr(f"matrix.chip.{report.verdict}") if report.verdict in VERDICTS else report.verdict
+
+ def _paint_chip(self, painter: QPainter, report: CloneReport) -> None:
+ rect = QRectF(self.chip_rect(report.clone))
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(status.status_color(report.verdict, self._theme))
+ painter.drawRoundedRect(rect, CHIP_H / 2, CHIP_H / 2)
+ painter.setPen(status.status_text_color(report.verdict, self._theme))
+ painter.setFont(self._small_font())
+ text = painter.fontMetrics().elidedText(self._chip_text(report),
+ Qt.TextElideMode.ElideRight, CHIP_W - 8)
+ painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
+ painter.restore()
+
+ def _paint_region_lines(self, painter: QPainter, colors: dict, n_rows: int) -> None:
+ """Thin dashed region boundaries through the clone rows."""
+ if self._region is None or n_rows == 0:
+ return
+ top, bottom = self._rows_top(), self._rows_top() + n_rows * ROW_H
+ pen = QPen(QColor(colors["region"]), 1, Qt.PenStyle.DashLine)
+ painter.save()
+ painter.setClipRect(self._track_clip())
+ painter.setPen(pen)
+ for x in {self.x_for(self._region[0]), self.x_for(self._region[1])}:
+ painter.drawLine(QPointF(x, top), QPointF(x, bottom))
+ painter.restore()
+
+ def _paint_legend(self, painter: QPainter, colors: dict, n_rows: int) -> None:
+ top = self._rows_top() + n_rows * ROW_H + GAP
+ marks = mark_colors()
+ dim_red = QColor(marks["confirmed"])
+ dim_red.setAlpha(OUT_ALPHA)
+ items = [
+ ("block", QColor(colors["cov_both"]), "matrix.legend.cov_both"),
+ ("block", QColor(colors["cov_one"]), "matrix.legend.cov_one"),
+ ("block", QColor(colors["track"]), "matrix.legend.cov_none"),
+ ("hatch", QColor(colors["track"]), "matrix.legend.unchecked"),
+ ("mark", marks["confirmed"], "matrix.legend.confirmed"),
+ ("hollow", marks["disputed"], "matrix.legend.disputed"),
+ ("mark", marks["low_quality"], "matrix.legend.low_quality"),
+ ("mark", marks["mixed"], "matrix.legend.mixed"),
+ ("short", dim_red, "matrix.legend.outside"),
+ ]
+ painter.setFont(self._small_font())
+ fm = painter.fontMetrics()
+ hatch = QColor(colors["dim"])
+ hatch.setAlpha(140)
+ x, cy = float(self._track_left()), top + LEGEND_H / 2
+ for kind, colour, key in items:
+ if kind in ("block", "hatch"):
+ painter.fillRect(QRectF(x, cy - 4, 14, 8), colour)
+ if kind == "hatch":
+ painter.fillRect(QRectF(x, cy - 4, 14, 8),
+ QBrush(hatch, Qt.BrushStyle.BDiagPattern))
+ x += 18
+ else:
+ rect = QRect(round(x), round(cy) - 6, 5 if kind == "hollow" else 4, 12)
+ if kind == "short":
+ rect = QRect(rect.left(), round(cy) - 4, 4, 8)
+ if kind == "hollow":
+ painter.setPen(colour)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawRect(rect.adjusted(0, 0, -1, -1))
+ else:
+ painter.fillRect(rect, colour)
+ x += 9
+ text = tr(key)
+ painter.setPen(QColor(colors["dim"]))
+ painter.drawText(QPointF(x, cy + fm.ascent() / 2 - 1), text)
+ x += fm.horizontalAdvance(text) + 12
diff --git a/ui/construct/session_files.py b/ui/construct/session_files.py
new file mode 100644
index 0000000..f298b8e
--- /dev/null
+++ b/ui/construct/session_files.py
@@ -0,0 +1,41 @@
+"""File helpers of the construct workspace: read discovery, path identity, reports."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Iterable
+from pathlib import Path
+
+from chrom_core.clone_report import clone_report_xlsx, clones_csv, differences_csv
+from chrom_core.clones import CloneReport
+
+PROJECT_SUFFIX = ".ab1proj"
+CONSTRUCT_FILTER = "Constructs (*.fa *.fasta *.fna *.txt *.gb *.gbk *.genbank *.dna);;All files (*)"
+
+
+def path_key(path: Path | str) -> str:
+ """Comparable identity of a file path (absolute, case-normalised on Windows)."""
+ return os.path.normcase(os.path.abspath(path))
+
+
+def ab1_files(paths: Iterable[Path | str]) -> list[Path]:
+ """``.ab1`` files among ``paths``; a folder contributes its own ``.ab1`` files."""
+ out: list[Path] = []
+ for p in map(Path, paths):
+ if p.is_dir():
+ out += sorted(x for x in p.iterdir() if x.suffix.lower() == ".ab1" and x.is_file())
+ elif p.suffix.lower() == ".ab1":
+ out.append(p)
+ return out
+
+
+def write_report(path: Path, reports: list[CloneReport], construct_name: str) -> None:
+ """``.xlsx``: one workbook; otherwise ``path`` gets the per-clone CSV and
+ ``_differences.csv`` next to it the per-difference CSV."""
+ path = Path(path)
+ if path.suffix.lower() == ".xlsx":
+ clone_report_xlsx(path, reports, construct_name)
+ return
+ path.write_text(clones_csv(reports), encoding="utf-8")
+ path.with_name(f"{path.stem}_differences.csv").write_text(
+ differences_csv(reports), encoding="utf-8")
diff --git a/ui/construct/summary_bar.py b/ui/construct/summary_bar.py
new file mode 100644
index 0000000..315887a
--- /dev/null
+++ b/ui/construct/summary_bar.py
@@ -0,0 +1,124 @@
+"""One-line summary of a construct check: name, length, topology, region and
+per-verdict clone counters that double as row filters for the matrix."""
+
+from __future__ import annotations
+
+from collections import Counter
+
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
+
+from chrom_core.clones import CloneReport, Region
+from chrom_core.reference import Construct
+from i18n import tr
+from ui import status
+from ui.construct import VERDICTS
+from ui.theme import theme as palette
+
+
+def region_text(region: Region | None) -> str:
+ """``region 7–647`` (1-based, inclusive); ``None`` is the whole construct."""
+ if region is None:
+ return tr("construct.region_all")
+ start, end = region
+ return tr("construct.region", start=start + 1, end=end)
+
+
+def _thousands(n: int) -> str:
+ return f"{n:,}".replace(",", " ")
+
+
+class SummaryBar(QWidget):
+ """Construct name and facts, then a counter chip per verdict.
+
+ Clicking a counter toggles that verdict in the shown set and emits
+ ``filter_changed`` with the verdicts still shown.
+ """
+
+ filter_changed = Signal(set)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._theme = status.current_theme()
+ self._shown = set(VERDICTS)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(8, 4, 8, 4)
+ layout.setSpacing(8)
+ self._title = QLabel()
+ self._title.setStyleSheet("font-weight: 600;")
+ self._info = QLabel()
+ layout.addWidget(self._title)
+ layout.addWidget(self._info)
+ self._counters: dict[str, QPushButton] = {}
+ for verdict in VERDICTS:
+ button = QPushButton()
+ button.setCheckable(True)
+ button.setChecked(True)
+ button.setFlat(True)
+ button.setToolTip(tr("construct.filter_tip"))
+ button.toggled.connect(lambda on, v=verdict: self._toggle(v, on))
+ button.hide()
+ layout.addWidget(button)
+ self._counters[verdict] = button
+ layout.addStretch(1)
+ self._restyle()
+
+ # ------------------------------------------------------------- public API
+ def set_data(self, construct: Construct, region: Region | None,
+ reports: list[CloneReport]) -> None:
+ self._title.setText(construct.name)
+ topology = tr("construct.circular" if construct.circular else "construct.linear")
+ self._info.setText(" · ".join((
+ f"{tr('construct.bp', n=_thousands(len(construct.seq)))}, {topology}",
+ region_text(region),
+ )))
+ counts = Counter(r.verdict for r in reports)
+ for verdict, button in self._counters.items():
+ n = counts.get(verdict, 0)
+ button.setText(tr(f"construct.count.{verdict}", n=n))
+ button.setVisible(n > 0)
+
+ def clear(self) -> None:
+ self._title.clear()
+ self._info.clear()
+ for button in self._counters.values():
+ button.hide()
+
+ def counter(self, verdict: str) -> QPushButton:
+ return self._counters[verdict]
+
+ def shown_verdicts(self) -> set[str]:
+ return set(self._shown)
+
+ def title_text(self) -> str:
+ return self._title.text()
+
+ def info_text(self) -> str:
+ return self._info.text()
+
+ def set_theme(self, name: str) -> None:
+ self._theme = "dark" if name == "dark" else "light"
+ self._restyle()
+
+ # --------------------------------------------------------------- private
+ def _toggle(self, verdict: str, on: bool) -> None:
+ if on:
+ self._shown.add(verdict)
+ else:
+ self._shown.discard(verdict)
+ self._restyle()
+ self.filter_changed.emit(set(self._shown))
+
+ def _restyle(self) -> None:
+ dim = palette(self._theme)["dim"]
+ for verdict, button in self._counters.items():
+ bg = status.status_color(verdict, self._theme).name()
+ fg = status.status_text_color(verdict, self._theme).name()
+ # shown: filled chip; hidden: outline only, struck through
+ button.setStyleSheet(
+ "QPushButton { border-radius: 9px; padding: 1px 10px; font-weight: 600;"
+ f" border: 1px solid {bg}; background-color: transparent; color: {dim};"
+ " text-decoration: line-through; }"
+ f"QPushButton:checked {{ background-color: {bg}; color: {fg};"
+ " text-decoration: none; }"
+ )
diff --git a/ui/construct/workspace.py b/ui/construct/workspace.py
new file mode 100644
index 0000000..816c208
--- /dev/null
+++ b/ui/construct/workspace.py
@@ -0,0 +1,482 @@
+"""The "construct check" workspace: construct + reads in, per-clone verdicts out.
+
+Holds the session state (construct file, circular flag, region, grouping,
+thresholds), re-runs ``verify_all`` in the background on every change (key
+``"verify"``: a newer run cancels the older one; a cancelled or failed run
+leaves the previous verdicts on screen) and reads/writes ``.ab1proj`` files.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from pathlib import Path
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QDragEnterEvent, QDropEvent
+from PySide6.QtWidgets import (
+ QDialog,
+ QFileDialog,
+ QHBoxLayout,
+ QMessageBox,
+ QScrollArea,
+ QSplitter,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+from chrom_core import project as project_io
+from chrom_core.align import MAX_REFERENCE_LENGTH
+from chrom_core.clones import (
+ CloneReport,
+ Grouping,
+ Region,
+ VerifyParams,
+ check_region,
+ group_reads,
+ verify_all,
+)
+from chrom_core.config import AppConfig, save_config
+from chrom_core.reference import Construct, load_construct
+from i18n import tr
+from ui import status
+from ui.construct.controls import ControlsBar, EmptyState, dropped_paths
+from ui.construct.detail_pane import DetailPane
+from ui.construct.grouping_dialog import GroupingDialog
+from ui.construct.matrix import CloneMatrix
+from ui.construct.session_files import (
+ CONSTRUCT_FILTER,
+ PROJECT_SUFFIX,
+ ab1_files,
+ path_key,
+ write_report,
+)
+from ui.construct.summary_bar import SummaryBar
+
+
+
+class ConstructWorkspace(QWidget):
+ open_read = Signal(Path)
+ status_message = Signal(str, int)
+
+ def __init__(self, config: AppConfig, start_task: Callable,
+ parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.config = config
+ self._start_task = start_task
+ self._construct_path: Path | None = None
+ self._base: Construct | None = None
+ self._circular = False
+ self._region: Region | None = None
+ self._grouping = Grouping()
+ self._pattern = config.group_pattern
+ self._reports: list[CloneReport] = []
+ self._shown_construct: Construct | None = None
+ self._task = None
+ self._generation = 0
+ self._loading = False
+ self._project_path: Path | None = None
+ self._selected: tuple[str, int | None] | None = None # (clone, ref_pos)
+
+ self.empty_state = EmptyState()
+ self.summary = SummaryBar()
+ self.controls = ControlsBar()
+ self.matrix = CloneMatrix()
+ self.detail = DetailPane()
+ self.detail.hide()
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
+ scroll.setWidget(self.matrix)
+ splitter = QSplitter(Qt.Orientation.Vertical)
+ splitter.addWidget(scroll)
+ splitter.addWidget(self.detail)
+ splitter.setStretchFactor(0, 2)
+ splitter.setStretchFactor(1, 3)
+ main = QWidget()
+ box = QVBoxLayout(main)
+ box.setContentsMargins(0, 0, 0, 0)
+ box.setSpacing(2)
+ top = QHBoxLayout()
+ top.addWidget(self.summary, 1)
+ top.addWidget(self.controls.data_buttons)
+ box.addLayout(top)
+ box.addWidget(self.controls)
+ box.addWidget(splitter, 1)
+ self._main_page = main
+ self._pages = QStackedWidget()
+ self._pages.addWidget(self.empty_state)
+ self._pages.addWidget(main)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._pages)
+ self.setAcceptDrops(True)
+ self._connect()
+ self.set_theme(status.current_theme())
+ self._refresh_zones()
+
+ def _connect(self) -> None:
+ empty = self.empty_state
+ empty.construct_zone.clicked.connect(self.choose_construct)
+ empty.reads_zone.clicked.connect(self.choose_reads_folder)
+ empty.reads_files.clicked.connect(self.choose_read_files)
+ for zone in empty.zones():
+ zone.dropped.connect(self.handle_drop)
+ c = self.controls
+ c.region_edited.connect(self.set_region)
+ c.whole_requested.connect(lambda: self.set_region(None))
+ c.circular_toggled.connect(self.set_circular)
+ c.params_changed.connect(lambda: self.set_params(c.params()))
+ c.grouping_button.clicked.connect(self.edit_grouping)
+ c.reads_button.clicked.connect(self.choose_reads_folder)
+ c.construct_button.clicked.connect(self.choose_construct)
+ self.summary.filter_changed.connect(self.matrix.set_filter)
+ self.matrix.region_changed.connect(self.set_region)
+ self.matrix.difference_clicked.connect(self._show_difference)
+ self.matrix.clone_clicked.connect(lambda clone: self._show_difference(clone, None))
+ self.detail.open_read.connect(self.open_read)
+
+ # ------------------------------------------------------------- state API
+ def construct(self) -> Construct | None:
+ return self._base.with_circular(self._circular) if self._base else None
+
+ def region(self) -> Region | None:
+ return self._region
+
+ def grouping(self) -> Grouping:
+ return Grouping(list(self._grouping.reads), list(self._grouping.unassigned))
+
+ def params(self) -> VerifyParams:
+ return self.controls.params()
+
+ def reports(self) -> list[CloneReport]:
+ return self._reports
+
+ def is_verifying(self) -> bool:
+ return self._task is not None
+
+ def project_path(self) -> Path | None:
+ return self._project_path
+
+ def contains_read(self, path: Path | str) -> bool:
+ key = path_key(path)
+ return any(path_key(p) == key for p in self._all_paths())
+
+ # -------------------------------------------------------------- changes
+ def set_construct(self, path: Path | str) -> bool:
+ path = Path(path)
+ try:
+ construct = load_construct(path)
+ except (ValueError, OSError) as exc:
+ self._warn(tr("workspace.construct_failed"), f"{path}:\n{exc}")
+ return False
+ if len(construct.seq) > MAX_REFERENCE_LENGTH:
+ self._warn(tr("workspace.construct_failed"),
+ tr("workspace.construct_too_long", n=len(construct.seq),
+ limit=MAX_REFERENCE_LENGTH))
+ return False
+ self._construct_path, self._base, self._circular = path, construct, construct.circular
+ if not self._region_ok(self._region, construct.circular):
+ self._region = None
+ self._sync_controls()
+ self._reverify()
+ return True
+
+ def add_reads(self, paths: Iterable[Path | str]) -> int:
+ known = {path_key(p) for p in self._all_paths()}
+ new = [p for p in ab1_files(paths) if path_key(p) not in known]
+ if not new:
+ self.status_message.emit(tr("workspace.no_new_reads"), 5000)
+ return 0
+ try:
+ added = group_reads(new, self._pattern or None, self.config.primer_tokens)
+ except ValueError as exc: # stale custom pattern in the config
+ self._warn(tr("grouping.title"), str(exc))
+ added = group_reads(new, None, self.config.primer_tokens)
+ self._grouping.reads += added.reads
+ self._grouping.unassigned += added.unassigned
+ self._reverify()
+ return len(new)
+
+ def set_grouping(self, grouping: Grouping) -> None:
+ self._grouping = Grouping(list(grouping.reads), list(grouping.unassigned))
+ self._reverify()
+
+ def set_region(self, region: Region | None) -> bool:
+ if region is not None:
+ region = (int(region[0]), int(region[1]))
+ if not self._region_ok(region, self._circular, warn=True):
+ self._sync_controls()
+ return False
+ if region == self._region:
+ self._sync_controls()
+ return True
+ self._region = region
+ self._sync_controls()
+ self._reverify()
+ return True
+
+ def set_circular(self, flag: bool) -> bool:
+ if not self._region_ok(self._region, flag, warn=True):
+ self._sync_controls()
+ return False
+ self._circular = bool(flag)
+ self._sync_controls()
+ self._reverify()
+ return True
+
+ def set_params(self, params: VerifyParams) -> None:
+ self.controls.set_params(params)
+ self._reverify()
+
+ def read_edited(self, path: Path | str) -> bool:
+ """A read was edited elsewhere: re-run the check if it is part of this session."""
+ if not self.contains_read(path):
+ return False
+ self._reverify()
+ return True
+
+ def clear(self) -> None:
+ self._cancel_run()
+ self._construct_path = self._base = None
+ self._circular, self._region = False, None
+ self._grouping, self._reports = Grouping(), []
+ self._project_path = None
+ self._selected = None
+ self._shown_construct = None
+ self.matrix.clear()
+ self.summary.clear()
+ self.detail.hide()
+ self.controls.set_params(VerifyParams())
+ self._sync_controls()
+ self._refresh_zones()
+
+ # --------------------------------------------------------------- project
+ def load_project(self, path: Path | str) -> bool:
+ path = Path(path)
+ try:
+ project = project_io.load_project(path)
+ except ValueError as exc:
+ self.clear()
+ self._warn(tr("workspace.project_failed"), str(exc))
+ return False
+ self.clear()
+ self._loading = True
+ try:
+ ok = self.set_construct(project.construct_path)
+ self._grouping = project.grouping
+ self.controls.set_params(project.params)
+ if ok:
+ self._circular = project.circular
+ if self._region_ok(project.region, project.circular, warn=True):
+ self._region = project.region
+ self._project_path = path
+ finally:
+ self._loading = False
+ self._sync_controls()
+ self._reverify()
+ if ok: # without its construct the project is not open: Save asks for a file
+ self.status_message.emit(tr("workspace.project_loaded", name=path.name), 5000)
+ return ok
+
+ def save_project(self, path: Path | str) -> bool:
+ path = Path(path)
+ if self._construct_path is None:
+ self._warn(tr("workspace.project_save_failed"), tr("workspace.need_construct"))
+ return False
+ project = project_io.Project(self._construct_path, self._circular, self._region,
+ self.grouping(), self.params())
+ try:
+ project_io.save_project(path, project)
+ except OSError as exc:
+ self._warn(tr("workspace.project_save_failed"), f"{path}:\n{exc}")
+ return False
+ self._project_path = path
+ self.status_message.emit(tr("workspace.project_saved", name=path.name), 5000)
+ return True
+
+ def export_report(self, path: Path | str) -> bool:
+ path = Path(path)
+ if not self._reports or self._shown_construct is None:
+ self._warn(tr("workspace.export_failed"), tr("workspace.no_results"))
+ return False
+ try:
+ write_report(path, self._reports, self._shown_construct.name)
+ except (OSError, RuntimeError) as exc:
+ self._warn(tr("workspace.export_failed"), f"{path}:\n{exc}")
+ return False
+ self.status_message.emit(tr("workspace.exported", name=path.name), 5000)
+ return True
+
+ # --------------------------------------------------------------- dialogs
+ def choose_construct(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self, tr("workspace.construct_button"), self._start_dir(), CONSTRUCT_FILTER)
+ if path:
+ self.set_construct(path)
+
+ def choose_reads_folder(self) -> None:
+ folder = QFileDialog.getExistingDirectory(self, tr("workspace.add_reads"),
+ self._start_dir())
+ if folder:
+ self.add_reads([folder])
+
+ def choose_read_files(self) -> None:
+ paths, _ = QFileDialog.getOpenFileNames(
+ self, tr("workspace.add_reads"), self._start_dir(),
+ "Sanger chromatograms (*.ab1);;All files (*)")
+ if paths:
+ self.add_reads(paths)
+
+ def edit_grouping(self) -> None:
+ dialog = GroupingDialog(self._grouping, self._pattern, self, self.config.primer_tokens)
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return
+ if dialog.result_pattern() != self._pattern:
+ self._pattern = self.config.group_pattern = dialog.result_pattern()
+ save_config(self.config)
+ self.set_grouping(dialog.result_grouping())
+
+ def handle_drop(self, paths: list[Path]) -> None:
+ """Projects open, ``.ab1`` files and folders become reads, anything else
+ is taken as the construct."""
+ paths = [Path(p) for p in paths]
+ project = next((p for p in paths if p.suffix.lower() == PROJECT_SUFFIX), None)
+ if project is not None:
+ self.load_project(project)
+ return
+ constructs = [p for p in paths if p.is_file() and p.suffix.lower() != ".ab1"]
+ reads = [p for p in paths if p.is_dir() or p.suffix.lower() == ".ab1"]
+ changed = False
+ self._loading = True
+ try:
+ if constructs:
+ changed = self.set_construct(constructs[0])
+ if reads:
+ changed = self.add_reads(reads) > 0 or changed
+ finally:
+ self._loading = False
+ if changed:
+ self._reverify()
+
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
+ if dropped_paths(event):
+ event.acceptProposedAction()
+
+ def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
+ paths = dropped_paths(event)
+ if paths:
+ event.acceptProposedAction()
+ self.handle_drop(paths)
+
+ def set_theme(self, name: str) -> None:
+ self.summary.set_theme(name)
+ self.matrix.set_theme(name)
+ self.detail.set_theme(name)
+ self.empty_state.set_theme(name)
+
+ # ------------------------------------------------------------ verification
+ def _reverify(self) -> None:
+ if self._loading:
+ return
+ self._refresh_zones()
+ construct = self.construct()
+ if construct is None:
+ return
+ self._cancel_run()
+ if not self._grouping.reads:
+ self._show([], construct, self._region)
+ return
+ region, grouping, params = self._region, self.grouping(), self.params()
+ generation = self._generation
+
+ def work(progress, cancelled):
+ return verify_all(construct, region, grouping, params, progress, cancelled)
+
+ def done(reports):
+ if generation == self._generation:
+ self._task = None
+ self._show(reports, construct, region)
+
+ task = self._start_task(work, done, tr("workspace.verify_failed"), key="verify")
+ self._task = task
+ task.cancelled.connect(lambda: self._task_ended(task))
+ task.failed.connect(lambda _m: self._task_ended(task))
+
+ def _task_ended(self, task) -> None:
+ if self._task is task:
+ self._task = None
+
+ def _cancel_run(self) -> None:
+ self._generation += 1
+ task, self._task = self._task, None
+ if task is not None:
+ task.cancel()
+
+ def _show(self, reports: list[CloneReport], construct: Construct,
+ region: Region | None) -> None:
+ self._reports, self._shown_construct = reports, construct
+ self.summary.set_data(construct, region, reports)
+ self.matrix.set_data(construct, region, reports)
+ self.matrix.set_filter(self.summary.shown_verdicts())
+ if self._selected is not None:
+ clone, pos = self._selected
+ if any(r.clone == clone for r in reports):
+ self._show_difference(clone, pos)
+ else:
+ self._selected = None
+ self.detail.hide()
+ if reports:
+ self.status_message.emit(tr("workspace.verified", n=len(reports)), 5000)
+
+ def _show_difference(self, clone: str, ref_pos: int | None) -> None:
+ report = next((r for r in self._reports if r.clone == clone), None)
+ if report is None or self._shown_construct is None:
+ return
+ self._selected = (clone, ref_pos)
+ self.detail.show()
+ self.detail.show_clone(report, self._shown_construct)
+ if ref_pos is not None:
+ self.detail.show_position(ref_pos)
+
+ # --------------------------------------------------------------- helpers
+ def _region_ok(self, region: Region | None, circular: bool, warn: bool = False) -> bool:
+ if region is None or self._base is None:
+ return True
+ try:
+ check_region(region, len(self._base.seq), circular)
+ except ValueError as exc:
+ if warn:
+ start, end = region
+ self._warn(tr("workspace.region_invalid"),
+ tr("workspace.region_invalid_text", start=start + 1, end=end,
+ reason=exc))
+ return False
+ return True
+
+ def _sync_controls(self) -> None:
+ length = len(self._base.seq) if self._base else 1
+ self.controls.set_region(self._region, length)
+ self.controls.set_circular(self._circular)
+
+ def _refresh_zones(self) -> None:
+ empty = self.empty_state
+ empty.construct_zone.set_done(
+ f"{self._base.name} · {self._construct_path.name}" if self._base else "")
+ n = len(self._grouping.reads) + len(self._grouping.unassigned)
+ empty.reads_zone.set_done(tr("workspace.reads_loaded", n=n) if n else "")
+ ready = self._base is not None and n > 0
+ self._pages.setCurrentWidget(self._main_page if ready else self.empty_state)
+
+ def _all_paths(self) -> list[Path]:
+ return [r.path for r in self._grouping.reads] + list(self._grouping.unassigned)
+
+ def _start_dir(self) -> str:
+ if self._project_path is not None:
+ return str(self._project_path.parent)
+ if self._construct_path is not None:
+ return str(self._construct_path.parent)
+ return ""
+
+ def _warn(self, title: str, text: str) -> None:
+ QMessageBox.warning(self, title, text)
diff --git a/ui/icons/LICENSE b/ui/icons/LICENSE
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/ui/icons/LICENSE
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ui/icons/README.md b/ui/icons/README.md
new file mode 100644
index 0000000..9869754
--- /dev/null
+++ b/ui/icons/README.md
@@ -0,0 +1,3 @@
+Icons are from Lucide (https://lucide.dev), release 1.65.0, downloaded from
+https://raw.githubusercontent.com/lucide-icons/lucide/1.65.0/icons/.svg
+License: ISC (some icons MIT, Feather) - see LICENSE in this folder. Only the icons used by ab1kit are included.
diff --git a/ui/icons/app/ab1kit.ico b/ui/icons/app/ab1kit.ico
new file mode 100644
index 0000000..ad1ef34
Binary files /dev/null and b/ui/icons/app/ab1kit.ico differ
diff --git a/ui/icons/app/ab1kit.png b/ui/icons/app/ab1kit.png
new file mode 100644
index 0000000..a772ebb
Binary files /dev/null and b/ui/icons/app/ab1kit.png differ
diff --git a/ui/icons/app/ab1kit.svg b/ui/icons/app/ab1kit.svg
new file mode 100644
index 0000000..29aa796
--- /dev/null
+++ b/ui/icons/app/ab1kit.svg
@@ -0,0 +1,29 @@
+
diff --git a/ui/icons/check.svg b/ui/icons/check.svg
new file mode 100644
index 0000000..5abe152
--- /dev/null
+++ b/ui/icons/check.svg
@@ -0,0 +1,13 @@
+
diff --git a/ui/icons/chevron-down.svg b/ui/icons/chevron-down.svg
new file mode 100644
index 0000000..e70d30b
--- /dev/null
+++ b/ui/icons/chevron-down.svg
@@ -0,0 +1,13 @@
+
diff --git a/ui/icons/chevron-up.svg b/ui/icons/chevron-up.svg
new file mode 100644
index 0000000..b011f77
--- /dev/null
+++ b/ui/icons/chevron-up.svg
@@ -0,0 +1,13 @@
+
diff --git a/ui/icons/copy.svg b/ui/icons/copy.svg
new file mode 100644
index 0000000..f62ce99
--- /dev/null
+++ b/ui/icons/copy.svg
@@ -0,0 +1,14 @@
+
diff --git a/ui/icons/dna.svg b/ui/icons/dna.svg
new file mode 100644
index 0000000..f2e63c0
--- /dev/null
+++ b/ui/icons/dna.svg
@@ -0,0 +1,23 @@
+
diff --git a/ui/icons/flask-conical.svg b/ui/icons/flask-conical.svg
new file mode 100644
index 0000000..5917608
--- /dev/null
+++ b/ui/icons/flask-conical.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/icons/folder-open.svg b/ui/icons/folder-open.svg
new file mode 100644
index 0000000..b01f60f
--- /dev/null
+++ b/ui/icons/folder-open.svg
@@ -0,0 +1,13 @@
+
diff --git a/ui/icons/globe.svg b/ui/icons/globe.svg
new file mode 100644
index 0000000..53c80ee
--- /dev/null
+++ b/ui/icons/globe.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/icons/info.svg b/ui/icons/info.svg
new file mode 100644
index 0000000..2a46eac
--- /dev/null
+++ b/ui/icons/info.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/icons/maximize.svg b/ui/icons/maximize.svg
new file mode 100644
index 0000000..f78ca32
--- /dev/null
+++ b/ui/icons/maximize.svg
@@ -0,0 +1,16 @@
+
diff --git a/ui/icons/moon.svg b/ui/icons/moon.svg
new file mode 100644
index 0000000..3c4bc31
--- /dev/null
+++ b/ui/icons/moon.svg
@@ -0,0 +1,13 @@
+
diff --git a/ui/icons/redo-2.svg b/ui/icons/redo-2.svg
new file mode 100644
index 0000000..72371c8
--- /dev/null
+++ b/ui/icons/redo-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/ui/icons/rotate-ccw.svg b/ui/icons/rotate-ccw.svg
new file mode 100644
index 0000000..eb0fe34
--- /dev/null
+++ b/ui/icons/rotate-ccw.svg
@@ -0,0 +1,14 @@
+
diff --git a/ui/icons/save.svg b/ui/icons/save.svg
new file mode 100644
index 0000000..07af070
--- /dev/null
+++ b/ui/icons/save.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/icons/scissors.svg b/ui/icons/scissors.svg
new file mode 100644
index 0000000..f87bf20
--- /dev/null
+++ b/ui/icons/scissors.svg
@@ -0,0 +1,17 @@
+
diff --git a/ui/icons/search.svg b/ui/icons/search.svg
new file mode 100644
index 0000000..03e442a
--- /dev/null
+++ b/ui/icons/search.svg
@@ -0,0 +1,14 @@
+
diff --git a/ui/icons/table-2.svg b/ui/icons/table-2.svg
new file mode 100644
index 0000000..fa89be6
--- /dev/null
+++ b/ui/icons/table-2.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/icons/undo-2.svg b/ui/icons/undo-2.svg
new file mode 100644
index 0000000..3e93594
--- /dev/null
+++ b/ui/icons/undo-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/ui/icons/zoom-in.svg b/ui/icons/zoom-in.svg
new file mode 100644
index 0000000..11173c0
--- /dev/null
+++ b/ui/icons/zoom-in.svg
@@ -0,0 +1,16 @@
+
diff --git a/ui/icons/zoom-out.svg b/ui/icons/zoom-out.svg
new file mode 100644
index 0000000..70abec4
--- /dev/null
+++ b/ui/icons/zoom-out.svg
@@ -0,0 +1,15 @@
+
diff --git a/ui/main_window.py b/ui/main_window.py
index 7fc29cf..8446515 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -1,4 +1,7 @@
-"""Main ab1kit window: toolbar, menus, overview map, chromatogram, docks."""
+"""ab1kit shell window: tabs "construct check" / "read viewer", File/Project/
+View/Help menus, language, theme, recent files, background-task status bar.
+The single-read viewer lives in ``ui.read_view``, the construct check in
+``ui.construct.workspace``."""
from __future__ import annotations
@@ -6,47 +9,25 @@
from importlib.metadata import PackageNotFoundError, version as pkg_version
from pathlib import Path
-from PySide6.QtCore import QMarginsF, QSizeF, Qt, QUrl, Signal
-from PySide6.QtGui import QAction, QColor, QDesktopServices, QDragEnterEvent, QDropEvent, QPageSize, QPainter, QPdfWriter
+from PySide6.QtGui import QAction, QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import (
QApplication,
- QCheckBox,
- QDialog,
- QDockWidget,
QFileDialog,
- QFormLayout,
- QHBoxLayout,
- QInputDialog,
- QLabel,
- QLineEdit,
QMainWindow,
- QMenu,
QMessageBox,
+ QProgressBar,
QPushButton,
- QScrollBar,
- QSpinBox,
- QPlainTextEdit,
- QTableWidget,
- QTableWidgetItem,
- QToolBar,
- QVBoxLayout,
- QWidget,
+ QTabWidget,
)
-from chrom_core import Chromatogram, EditableRead, batch, read_ab1, trim_window
-from chrom_core import blast as blast_api
-from chrom_core import export, sessions
-from chrom_core.align import ReadAlignment, align_read, gapped_sequences, parse_sequence
from chrom_core.config import AppConfig, load_config, save_config
-from chrom_core.editing import INSERT_LETTERS, IUPAC_LETTERS
-from chrom_core.snps import call_snps
-from chrom_core.verify import parse_expected, verify_expected, verdicts_csv, verdicts_xlsx
-from chrom_core.seqops import find_matches
from i18n import set_language, tr
-from ui.batch_panel import BatchPanel
-from ui.chrom_view import ChromatogramWidget
-from ui.minimap import MiniMap
-from ui.theme import app_qss, theme as palette
+from ui import status
+from ui.construct.workspace import PROJECT_SUFFIX, ConstructWorkspace
+from ui.read_view import ReadView
+from ui.status import bind_icon
+from ui.tasks import Task, run_task
+from ui.theme import app_icon, app_qss
try:
VERSION = pkg_version("ab1kit")
@@ -54,265 +35,163 @@
VERSION = "dev"
-class SearchDialog(QDialog):
- def __init__(self, parent=None) -> None:
- super().__init__(parent)
- self.setWindowTitle(tr("search.title"))
- layout = QVBoxLayout(self)
- self.edit = QLineEdit()
- self.edit.setPlaceholderText(tr("search.pattern"))
- self.reverse_box = QCheckBox(tr("search.reverse"))
- button = QPushButton(tr("search.button"))
- button.setDefault(True)
- button.clicked.connect(self.accept)
- layout.addWidget(self.edit)
- layout.addWidget(self.reverse_box)
- layout.addWidget(button)
-
- def pattern(self) -> str:
- return self.edit.text().strip().upper()
-
- def want_reverse(self) -> bool:
- return self.reverse_box.isChecked()
-
-
-class TrimDialog(QDialog):
- """Non-modal quality-trim dialog; preview streams into the chromatogram."""
-
- region_changed = Signal(int, int)
- apply_requested = Signal(int, int)
-
- def __init__(self, quality_fn, window: int, min_q: int, total: int, parent=None) -> None:
- super().__init__(parent)
- self.setWindowTitle(tr("trim.title"))
- self.quality_fn = quality_fn
- self.total = total
- self._region = (0, total)
-
- layout = QVBoxLayout(self)
- form = QFormLayout()
- self.window_spin = QSpinBox()
- self.window_spin.setRange(2, 100)
- self.window_spin.setValue(window)
- self.minq_spin = QSpinBox()
- self.minq_spin.setRange(0, 60)
- self.minq_spin.setValue(min_q)
- form.addRow(QLabel(tr("trim.window")), self.window_spin)
- form.addRow(QLabel(tr("trim.minq")), self.minq_spin)
- layout.addLayout(form)
- self.preview = QLabel()
- layout.addWidget(self.preview)
- buttons = QWidget()
- row = QVBoxLayout(buttons)
- apply_button = QPushButton(tr("trim.apply"))
- apply_button.setDefault(True)
- apply_button.clicked.connect(self._apply)
- close_button = QPushButton(tr("dialog.close"))
- close_button.clicked.connect(self.close)
- row.addWidget(apply_button)
- row.addWidget(close_button)
- layout.addWidget(buttons)
-
- self.window_spin.valueChanged.connect(self._recompute)
- self.minq_spin.valueChanged.connect(self._recompute)
- self._recompute()
-
- def window_value(self) -> int:
- return self.window_spin.value()
-
- def minq_value(self) -> int:
- return self.minq_spin.value()
-
- def _recompute(self) -> None:
- start, end = trim_window(self.quality_fn(), self.window_spin.value(), self.minq_spin.value())
- self._set_region(start, end)
- self.region_changed.emit(start, end)
-
- def _set_region(self, start: int, end: int) -> None:
- self._region = (start, end)
- self.preview.setText(tr("trim.preview", n=end - start, total=self.total))
-
- def set_region(self, start: int, end: int) -> None:
- """Sync preview when the user drags trim handles in the view."""
- self._set_region(start, end)
-
- def _apply(self) -> None:
- start, end = self._region
- self.apply_requested.emit(start, end)
- self.close()
+def _delegate(name: str) -> property:
+ """Read-only view onto ``ReadView.`` (keeps the old MainWindow API)."""
+ return property(lambda self: getattr(self.read_view, name))
class MainWindow(QMainWindow):
+ # State and widgets that used to live on the window itself.
+ read_model = _delegate("read_model")
+ chrom_view = _delegate("chrom_view")
+ minimap = _delegate("minimap")
+ trim_dialog = _delegate("trim_dialog")
+ reference_seq = _delegate("reference_seq")
+ alignment = _delegate("alignment")
+ snp_calls = _delegate("snp_calls")
+ variant_verdicts = _delegate("variant_verdicts")
+ batch_reports = _delegate("batch_reports")
+ batch_panel = _delegate("batch_panel")
+ batch_dock = _delegate("batch_dock")
+ diff_dock = _delegate("diff_dock")
+ diff_table = _delegate("diff_table")
+ verify_dock = _delegate("verify_dock")
+ verify_table = _delegate("verify_table")
+ undo_action = _delegate("undo_action")
+ redo_action = _delegate("redo_action")
+
def __init__(self, config: AppConfig | None = None) -> None:
super().__init__()
self.config = config or load_config()
set_language(self.config.language)
+ # App-wide and idempotent; here (not only in run()) so directly constructed
+ # windows, e.g. in tests, carry the icon too.
+ QApplication.instance().setWindowIcon(app_icon())
self.setWindowTitle("ab1kit")
self.resize(1200, 780)
- self._path: Path | None = None
- self.read_model: EditableRead | None = None
- self.trim_dialog: TrimDialog | None = None
- self.batch_reports: list[batch.FileReport] = []
- self.reference_seq: str = ""
- self.alignment: ReadAlignment | None = None
- self.snp_calls: list = []
- self.variant_verdicts: list = []
- self._search_ranges: list[tuple[int, int]] = []
- self._search_pos = -1
-
- self.chrom_view = ChromatogramWidget()
- self.minimap = MiniMap()
- self.scrollbar = QScrollBar(Qt.Orientation.Horizontal)
-
- central = QWidget()
- layout = QVBoxLayout(central)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
- layout.addWidget(self.minimap)
- layout.addWidget(self.chrom_view, 1)
- layout.addWidget(self.scrollbar)
- self.setCentralWidget(central)
-
- self._channel_boxes: dict[str, QCheckBox] = {}
- self._build_toolbar()
- self._build_info_dock()
- self._build_batch_dock()
- self._build_analysis_dock()
- self._build_verify_dock()
+ self.read_view = ReadView(self.config, self._start_task)
+ self.workspace = ConstructWorkspace(self.config, self._start_task)
+ self.tabs = QTabWidget()
+ self.tabs.setDocumentMode(True)
+ self.tabs.addTab(self.workspace, tr("tab.construct"))
+ self.tabs.addTab(self.read_view, tr("tab.read"))
+ self.setCentralWidget(self.tabs)
+ self.tabs.currentChanged.connect(
+ lambda _i: self.read_view.set_active(self.tabs.currentWidget() is self.read_view))
+ self.read_view.file_opened.connect(self._push_recent)
+ self.read_view.file_opened.connect(lambda _p: self.tabs.setCurrentWidget(self.read_view))
+ self.read_view.status_message.connect(self.statusBar().showMessage)
+ self.read_view.title_changed.connect(self.setWindowTitle)
+ self.read_view.read_edited.connect(self._read_edited)
+ self.workspace.status_message.connect(self.statusBar().showMessage)
+ self.workspace.open_read.connect(self._open_read)
+
self._build_menus()
- self._connect()
self.setAcceptDrops(True)
self.set_theme(self.config.theme)
self.statusBar().showMessage(tr("status.ready"))
+ self._build_task_status()
self._fill_recent_menu()
-
- # ------------------------------------------------------------------ setup
- def _build_toolbar(self) -> None:
- bar = QToolBar()
- bar.setMovable(False)
- self.addToolBar(bar)
-
- def add(text: str, slot, shortcut: str | None = None) -> None:
- action = QAction(text, self)
- if shortcut:
- action.setShortcut(shortcut)
- action.triggered.connect(slot)
- bar.addAction(action)
-
- add(tr("tb.open"), self._open_dialog)
- bar.addSeparator()
- add(tr("tb.zoom_in"), self.chrom_view.zoom_in, "Ctrl++")
- add(tr("tb.zoom_out"), self.chrom_view.zoom_out, "Ctrl+-")
- add(tr("tb.fit"), self.chrom_view.zoom_fit)
- add(tr("tb.reset"), self.chrom_view.zoom_reset)
- bar.addSeparator()
- for base in "ACGT":
- box = QCheckBox(base)
- box.setChecked(True)
- box.toggled.connect(
- lambda on, b=base: self.chrom_view.set_visible_channel(b, on)
- )
- bar.addWidget(box)
- self._channel_boxes[base] = box
-
+ self.read_view.set_active(self.tabs.currentWidget() is self.read_view)
+
+ # ------------------------------------------------------------------ tasks
+ def _build_task_status(self) -> None:
+ self._active_tasks: set[Task] = set()
+ self._keyed_tasks: dict[str, Task] = {}
+ self.task_progress = QProgressBar()
+ self.task_progress.setMaximumWidth(180)
+ self.task_progress.setFormat(tr("task.running"))
+ self.task_cancel_button = QPushButton(tr("task.cancel"))
+ self.task_cancel_button.clicked.connect(self._cancel_tasks)
+ for widget in (self.task_progress, self.task_cancel_button):
+ self.statusBar().addPermanentWidget(widget)
+ widget.hide()
+
+ def _cancel_tasks(self) -> None:
+ for task in list(self._active_tasks):
+ task.cancel()
+
+ def _start_task(self, fn, on_finished, error_title: str, key: str = "") -> Task:
+ """Run ``fn`` in the background; ``on_finished(result)`` runs in the GUI
+ thread unless the task was cancelled or failed. A new task with the
+ same ``key`` cancels the previous one (last start wins)."""
+ if key and key in self._keyed_tasks:
+ self._keyed_tasks.pop(key).cancel()
+ task = run_task(fn, self)
+ if key:
+ self._keyed_tasks[key] = task
+ self._active_tasks.add(task)
+ self.task_progress.setRange(0, 0) # busy until progress arrives
+ self.task_progress.show()
+ self.task_cancel_button.show()
+
+ def release() -> None:
+ self._active_tasks.discard(task)
+ if key and self._keyed_tasks.get(key) is task:
+ del self._keyed_tasks[key]
+ if not self._active_tasks:
+ self.task_progress.hide()
+ self.task_cancel_button.hide()
+
+ def on_progress(done: int, total: int) -> None:
+ if total > 0:
+ self.task_progress.setRange(0, total)
+ self.task_progress.setValue(done)
+
+ def on_failed(message: str) -> None:
+ release()
+ QMessageBox.warning(self, error_title, message)
+
+ def on_cancelled() -> None:
+ release()
+ self.statusBar().showMessage(tr("task.cancelled"), 5000)
+
+ def on_done(result: object) -> None:
+ release()
+ on_finished(result)
+
+ task.progress.connect(on_progress)
+ task.failed.connect(on_failed)
+ task.cancelled.connect(on_cancelled)
+ task.finished.connect(on_done)
+ return task
+
+ # ------------------------------------------------------------------ menus
def _build_menus(self) -> None:
file_menu = self.menuBar().addMenu(tr("menu.file"))
self._file_menu = file_menu
open_action = QAction(tr("menu.file.open"), self)
+ bind_icon(open_action, "folder-open")
open_action.setShortcut("Ctrl+O")
- open_action.triggered.connect(self._open_dialog)
+ open_action.triggered.connect(lambda: self._in_read_tab(self.read_view.open_dialog))
file_menu.addAction(open_action)
open_folder = QAction(tr("menu.file.open_folder"), self)
open_folder.setShortcut("Ctrl+Shift+O")
- open_folder.triggered.connect(self._open_folder_dialog)
+ open_folder.triggered.connect(
+ lambda: self._in_read_tab(self.read_view.open_folder_dialog))
file_menu.addAction(open_folder)
self.recent_menu = file_menu.addMenu(tr("menu.file.recent"))
- save_session = QAction(tr("menu.file.save_session"), self)
- save_session.setShortcut("Ctrl+S")
- save_session.triggered.connect(self._save_session)
- file_menu.addAction(save_session)
- export_menu = file_menu.addMenu(tr("menu.file.export"))
- for text, slot in (
- (tr("menu.file.export_fasta"), self._export_fasta),
- (tr("menu.file.export_fastq"), self._export_fastq),
- (tr("menu.file.export_png"), self._export_png),
- (tr("menu.file.export_pdf"), self._export_pdf),
- ):
- action = QAction(text, self)
- action.triggered.connect(slot)
- export_menu.addAction(action)
- file_menu.addSeparator()
- copy_action = QAction(tr("menu.file.copy_seq"), self)
- copy_action.setShortcut("Ctrl+Shift+C")
- copy_action.triggered.connect(self._copy_sequence)
- file_menu.addAction(copy_action)
+ self.read_view.add_file_actions(file_menu)
file_menu.addSeparator()
exit_action = QAction(tr("menu.file.exit"), self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
- edit_menu = self.menuBar().addMenu(tr("menu.edit"))
- self.undo_action = QAction(tr("menu.edit.undo"), self)
- self.undo_action.setShortcut("Ctrl+Z")
- self.undo_action.triggered.connect(self.undo_read)
- self.undo_action.setEnabled(False)
- edit_menu.addAction(self.undo_action)
- self.redo_action = QAction(tr("menu.edit.redo"), self)
- self.redo_action.setShortcut("Ctrl+Y")
- self.redo_action.triggered.connect(self.redo_read)
- self.redo_action.setEnabled(False)
- edit_menu.addAction(self.redo_action)
- edit_menu.addSeparator()
- find_action = QAction(tr("menu.edit.find"), self)
- find_action.setShortcut("Ctrl+F")
- find_action.triggered.connect(self._search)
- edit_menu.addAction(find_action)
- find_next = QAction(tr("menu.edit.find_next"), self)
- find_next.setShortcut("F3")
- find_next.triggered.connect(self._find_next)
- edit_menu.addAction(find_next)
- goto_action = QAction(tr("menu.edit.goto"), self)
- goto_action.setShortcut("Ctrl+G")
- goto_action.triggered.connect(self._goto)
- edit_menu.addAction(goto_action)
- edit_menu.addSeparator()
- revcomp_action = QAction(tr("menu.edit.revcomp"), self)
- revcomp_action.setShortcut("Ctrl+R")
- revcomp_action.triggered.connect(self.reverse_complement_read)
- edit_menu.addAction(revcomp_action)
- trim_action = QAction(tr("menu.edit.trim"), self)
- trim_action.setShortcut("Ctrl+T")
- trim_action.triggered.connect(self._open_trim_dialog)
- edit_menu.addAction(trim_action)
+ self._build_project_menu(self.menuBar().addMenu(tr("menu.project")))
+ self.read_view.add_edit_menu(self.menuBar().addMenu(tr("menu.edit")))
view_menu = self.menuBar().addMenu(tr("menu.view"))
self.dark_action = QAction(tr("menu.view.dark_theme"), self)
+ bind_icon(self.dark_action, "moon")
self.dark_action.setCheckable(True)
self.dark_action.setChecked(self.config.theme == "dark")
self.dark_action.toggled.connect(
lambda on: self.set_theme("dark" if on else "light")
)
view_menu.addAction(self.dark_action)
- smooth_action = QAction(tr("menu.view.smoothing"), self)
- smooth_action.setCheckable(True)
- smooth_action.toggled.connect(self.chrom_view.set_smoothing)
- view_menu.addAction(smooth_action)
- profile_action = QAction(tr("menu.view.quality_profile"), self)
- profile_action.setCheckable(True)
- profile_action.toggled.connect(self.minimap.set_profile)
- view_menu.addAction(profile_action)
- info_action = QAction(tr("menu.view.file_info"), self)
- info_action.setCheckable(True)
- info_action.toggled.connect(self.info_dock.setVisible)
- self.info_dock.visibilityChanged.connect(info_action.setChecked)
- view_menu.addAction(info_action)
- batch_action = QAction(tr("menu.view.batch_panel"), self)
- batch_action.setCheckable(True)
- batch_action.toggled.connect(self.batch_dock.setVisible)
- self.batch_dock.visibilityChanged.connect(batch_action.setChecked)
- view_menu.addAction(batch_action)
+ self.read_view.add_view_actions(view_menu)
lang_menu = view_menu.addMenu(tr("menu.view.language"))
self._lang_actions = {}
for code, label in (("ru", tr("lang.ru")), ("en", tr("lang.en"))):
@@ -323,27 +202,7 @@ def _build_menus(self) -> None:
lang_menu.addAction(action)
self._lang_actions[code] = action
- analysis_menu = self.menuBar().addMenu(tr("menu.analysis"))
- reference_action = QAction(tr("menu.analysis.reference"), self)
- reference_action.setShortcut("Ctrl+L")
- reference_action.triggered.connect(self._reference_dialog)
- analysis_menu.addAction(reference_action)
- clear_ref = QAction(tr("menu.analysis.clear_reference"), self)
- clear_ref.triggered.connect(self._clear_reference)
- analysis_menu.addAction(clear_ref)
- analysis_menu.addSeparator()
- export_aligned = QAction(tr("menu.analysis.export_aligned"), self)
- export_aligned.triggered.connect(self._export_aligned)
- analysis_menu.addAction(export_aligned)
- analysis_menu.addSeparator()
- verify_action = QAction(tr("menu.analysis.verify"), self)
- verify_action.setShortcut("Ctrl+Shift+V")
- verify_action.triggered.connect(self._verify_dialog)
- analysis_menu.addAction(verify_action)
- analysis_menu.addSeparator()
- blast_action = QAction(tr("menu.analysis.blast"), self)
- blast_action.triggered.connect(self._blast_submit)
- analysis_menu.addAction(blast_action)
+ self.read_view.add_analysis_menu(self.menuBar().addMenu(tr("menu.analysis")))
help_menu = self.menuBar().addMenu(tr("menu.help"))
about_action = QAction(tr("menu.help.about"), self)
@@ -352,309 +211,80 @@ def _build_menus(self) -> None:
)
help_menu.addAction(about_action)
- def _build_info_dock(self) -> None:
- self.info_dock = QDockWidget(tr("info.title"), self)
- self.info_dock.setWidget(QWidget())
- self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.info_dock)
- self.info_dock.hide()
-
- def _build_batch_dock(self) -> None:
- self.batch_dock = QDockWidget(tr("batch.title"), self)
- self.batch_panel = BatchPanel()
- self.batch_dock.setWidget(self.batch_panel)
- self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.batch_dock)
- self.batch_dock.hide()
- self.batch_panel.open_requested.connect(self.open_path)
- self.batch_panel.export_button.clicked.connect(self._batch_export)
- self.batch_panel.csv_button.clicked.connect(self._batch_save_csv)
-
- def _build_analysis_dock(self) -> None:
- self.diff_dock = QDockWidget(tr("analysis.diffs.title"), self)
- self.diff_table = QTableWidget(0, 8)
- self.diff_table.setHorizontalHeaderLabels(
- [
- tr("analysis.col.pos"),
- tr("analysis.col.kind"),
- tr("analysis.col.ref"),
- tr("analysis.col.read"),
- tr("analysis.col.readpos"),
- tr("snp.col.verdict"),
- tr("snp.col.q"),
- tr("snp.col.ratio"),
- ]
- )
- self.diff_table.verticalHeader().setVisible(False)
- self.diff_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
- self.diff_table.setSortingEnabled(True)
- self.diff_dock.setWidget(self.diff_table)
- self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.diff_dock)
- self.diff_dock.hide()
-
- def _build_verify_dock(self) -> None:
- self.verify_dock = QDockWidget(tr("verify.title"), self)
- self.verify_table = QTableWidget(0, 8)
- self.verify_table.setHorizontalHeaderLabels(
- [
- tr("verify.col.name"),
- tr("verify.col.pos"),
- tr("verify.col.ref"),
- tr("verify.col.alt"),
- tr("verify.col.verdict"),
- tr("verify.col.observed"),
- tr("verify.col.q"),
- tr("verify.col.note"),
- ]
- )
- self.verify_table.verticalHeader().setVisible(False)
- self.verify_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
- self.verify_table.setSortingEnabled(True)
- buttons = QWidget()
- row = QHBoxLayout(buttons)
- csv_button = QPushButton(tr("verify.save_csv"))
- csv_button.clicked.connect(self._save_verify_csv)
- xlsx_button = QPushButton(tr("verify.save_xlsx"))
- xlsx_button.clicked.connect(self._save_verify_xlsx)
- row.addWidget(csv_button)
- row.addWidget(xlsx_button)
- row.addStretch(1)
- container = QWidget()
- layout = QVBoxLayout(container)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(self.verify_table)
- layout.addWidget(buttons)
- self.verify_dock.setWidget(container)
- self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.verify_dock)
- self.verify_dock.hide()
-
- def _connect(self) -> None:
- self.chrom_view.view_changed.connect(self._sync_from_view)
- self.chrom_view.cursor_info.connect(self.statusBar().showMessage)
- self.chrom_view.base_clicked.connect(self._on_base_clicked)
- self.minimap.request_center.connect(self.chrom_view.center_on_point)
- self.scrollbar.valueChanged.connect(
- lambda value: self.chrom_view.set_offset(float(value))
- )
+ def _build_project_menu(self, menu) -> None:
+ self.project_menu = menu
+ for key, shortcut, slot in (
+ ("menu.project.new", "", self._project_new),
+ ("menu.project.open", "Ctrl+Shift+P", self._project_open),
+ ("menu.project.save", "Ctrl+Shift+S", self._project_save),
+ ("menu.project.save_as", "", self._project_save_as),
+ (None, "", None),
+ ("menu.project.export", "Ctrl+Shift+E", self._project_export),
+ ):
+ if key is None:
+ menu.addSeparator()
+ continue
+ action = QAction(tr(key), self)
+ if shortcut:
+ action.setShortcut(shortcut)
+ action.triggered.connect(slot)
+ menu.addAction(action)
- # ---------------------------------------------------------------- opening
- def open_path(self, path: str | Path) -> bool:
- try:
- chrom = read_ab1(path)
- except Exception as exc: # user-facing: any parse failure becomes a dialog
- QMessageBox.critical(self, tr("err.open_failed"), f"{path}:\n{exc}")
- return False
- self._path = Path(path)
- model = EditableRead(chrom)
- restored = self._try_restore_session(model)
- self.read_model = model
- self._search_ranges = []
- self._search_pos = -1
- self.chrom_view.set_chromatogram(model.current())
- self.chrom_view.set_edited(model.edited)
- self.minimap.set_chromatogram(model.current())
- self.minimap.set_highlights([])
- self._push_recent(str(self._path))
- self._refresh_info(model.current())
- self._sync_actions()
- if self.reference_seq:
- self._realign()
- n = len(model.seq)
- mean_q = sum(model.quality) / max(1, n)
- message = tr("status.file", n=n, q=mean_q)
- if restored:
- message += " · " + tr("session.loaded")
- self.statusBar().showMessage(message, 8000)
- self.setWindowTitle(f"ab1kit — {self._path.name}" + ("*" if model.modified else ""))
- return True
-
- def _try_restore_session(self, model: EditableRead) -> bool:
- sidecar = sessions.session_path(self._path)
- data = sessions.load_session(sidecar)
- if not data or data.get("source") != self._path.name:
- return False
- try:
- model.restore_state(data)
- except (KeyError, TypeError, ValueError):
- return False
- return True
-
- def _open_dialog(self) -> None:
+ # --------------------------------------------------------------- projects
+ def _show_workspace(self) -> None:
+ self.tabs.setCurrentWidget(self.workspace)
+
+ def _project_new(self) -> None:
+ self.workspace.clear()
+ self._show_workspace()
+
+ def _project_open(self) -> None:
path, _ = QFileDialog.getOpenFileName(
- self,
- tr("menu.file.open"),
- str(self._path.parent) if self._path else "",
- "Sanger chromatograms (*.ab1);;All files (*)",
- )
+ self, tr("menu.project.open"), "", f"ab1kit project (*{PROJECT_SUFFIX})")
if path:
self.open_path(path)
- # --------------------------------------------------------------- analysis
- def _reference_dialog(self) -> None:
- dialog = QDialog(self)
- dialog.setWindowTitle(tr("analysis.ref.title"))
- dialog.resize(480, 240)
- layout = QVBoxLayout(dialog)
- edit = QPlainTextEdit()
- edit.setPlaceholderText(tr("analysis.ref.paste"))
- layout.addWidget(edit)
-
- def from_file() -> None:
- path, _ = QFileDialog.getOpenFileName(
- dialog, tr("analysis.ref.from_file"), "", "Sequence (*.fasta *.fa *.txt);;All files (*)"
- )
- if path:
- edit.setPlainText(Path(path).read_text(encoding="utf-8", errors="replace"))
-
- file_button = QPushButton(tr("analysis.ref.from_file"))
- file_button.clicked.connect(from_file)
- layout.addWidget(file_button)
-
- ok = QPushButton("OK")
- ok.clicked.connect(dialog.accept)
- cancel = QPushButton(tr("dialog.close"))
- cancel.clicked.connect(dialog.reject)
- row = QWidget()
- row_layout = QHBoxLayout(row)
- row_layout.addWidget(ok)
- row_layout.addWidget(cancel)
- layout.addWidget(row)
-
- if dialog.exec() != QDialog.DialogCode.Accepted:
- return
- try:
- sequence = parse_sequence(edit.toPlainText())
- except ValueError as exc:
- QMessageBox.warning(self, tr("analysis.ref.title"), str(exc))
- return
- if not sequence:
- return
- self._set_reference_seq(sequence)
-
- def _set_reference_seq(self, sequence: str) -> None:
- self.reference_seq = sequence
- if self.read_model is not None:
- self._realign()
- else:
- self.statusBar().showMessage(tr("analysis.need_read"), 5000)
+ def _project_save(self) -> None:
+ path = self.workspace.project_path()
+ if path is None:
+ self._project_save_as()
+ elif self.workspace.save_project(path):
+ self._push_recent(str(path))
- def _clear_reference(self) -> None:
- self.reference_seq = ""
- self.alignment = None
- self.chrom_view.set_reference(None)
- self.diff_table.setRowCount(0)
- self.diff_dock.hide()
-
- def _realign(self) -> None:
- if not (self.read_model and self.reference_seq):
- return
- try:
- self.alignment = align_read("".join(self.read_model.seq), self.reference_seq)
- except ValueError as exc:
- QMessageBox.warning(self, tr("analysis.ref.title"), str(exc))
- self._clear_reference()
+ def _project_save_as(self) -> None:
+ self._show_workspace()
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("menu.project.save_as"), "", f"ab1kit project (*{PROJECT_SUFFIX})")
+ if not path:
return
- self.chrom_view.set_reference(self.reference_seq, self.alignment.ref_to_read)
- chrom = self.read_model.current()
- self.snp_calls = call_snps(
- self.alignment, chrom,
- min_q=self.config.snp_min_q, het_ratio=self.config.het_ratio,
- )
- self._fill_diff_table()
- self.diff_dock.setVisible(bool(self.alignment.differences))
- pct = f"{self.alignment.identity * 100:.1f}"
- if self.alignment.differences:
- message = tr(
- "analysis.status",
- n=len(self.alignment.differences),
- pct=pct,
- rstart=self.alignment.ref_start + 1,
- rend=self.alignment.ref_end,
- )
- else:
- message = tr("analysis.no_diffs")
- if self.snp_calls:
- counts = {k: sum(1 for c in self.snp_calls if c.kind == k)
- for k in ("homozygous", "heterozygous", "low_quality")}
- message += " · " + tr(
- "snp.summary",
- homo=counts["homozygous"],
- het=counts["heterozygous"],
- low=counts["low_quality"],
- )
- if self.alignment.alternatives > 1:
- message += " · " + tr("analysis.ambiguous", n=self.alignment.alternatives)
- self.statusBar().showMessage(message, 8000)
-
- def _fill_diff_table(self) -> None:
- kind_labels = {
- "substitution": tr("analysis.kind.substitution"),
- "deletion": tr("analysis.kind.deletion"),
- "insertion": tr("analysis.kind.insertion"),
- }
- verdict_labels = {
- "homozygous": tr("snp.homozygous"),
- "heterozygous": tr("snp.heterozygous"),
- "low_quality": tr("snp.low_quality"),
- }
- calls_by_ref = {c.ref_pos: c for c in getattr(self, "snp_calls", [])}
- diffs = self.alignment.differences if self.alignment else []
- self.diff_table.setSortingEnabled(False)
- self.diff_table.setRowCount(len(diffs))
- for row, diff in enumerate(diffs):
- call = calls_by_ref.get(diff.ref_pos)
- verdict = ""
- if call is not None:
- verdict = verdict_labels.get(call.kind, call.kind)
- if call.kind == "heterozygous":
- verdict += f" ({call.iupac})"
- values = [
- diff.ref_pos + 1,
- kind_labels.get(diff.kind, diff.kind),
- diff.ref_base,
- diff.read_base,
- (call.read_pos + 1 if call is not None
- else diff.read_pos + 1 if diff.read_pos is not None else ""),
- verdict,
- call.quality if call is not None else "",
- f"{call.ratio:.2f}" if call is not None and call.ratio is not None else "",
- ]
- for col, value in enumerate(values):
- item = QTableWidgetItem()
- item.setData(Qt.ItemDataRole.DisplayRole, value)
- if call is not None and col == 5 and call.kind == "heterozygous":
- item.setForeground(QColor("#a05000"))
- self.diff_table.setItem(row, col, item)
- self.diff_table.setSortingEnabled(True)
+ if not path.lower().endswith(PROJECT_SUFFIX):
+ path += PROJECT_SUFFIX
+ if self.workspace.save_project(path):
+ self._push_recent(path)
- def _export_aligned(self) -> None:
- if not (self.alignment and self.reference_seq and self.read_model):
- return
- path = self._export_target(
- tr("menu.analysis.export_aligned"), ".aligned.fasta", "FASTA (*.fasta)"
- )
+ def _project_export(self) -> None:
+ self._show_workspace()
+ path, chosen = QFileDialog.getSaveFileName(
+ self, tr("menu.project.export"), "", "Excel (*.xlsx);;CSV (*.csv)")
if not path:
return
- ref_aligned, read_aligned = gapped_sequences(self.alignment, self.reference_seq)
- sample = self._export_id()
- strand = "reverse" if self.alignment.strand == "-" else "forward"
- content = (
- export.to_fasta(f"{sample}_reference[{self.alignment.ref_start + 1}-{self.alignment.ref_end}]", ref_aligned)
- + export.to_fasta(f"{sample}_read_{strand}", read_aligned)
- )
- export.write_text(path, content)
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
+ if Path(path).suffix.lower() not in (".xlsx", ".csv"):
+ path += ".csv" if "csv" in chosen.lower() else ".xlsx"
+ self.workspace.export_report(path)
- # ------------------------------------------------------------------ BLAST
- def _blast_submit(self) -> None:
- if not self.read_model:
- return
- seq = "".join(self.read_model.seq)
- try:
- rid = blast_api.submit(blast_api.build_request(seq))
- except (OSError, RuntimeError, ValueError) as exc:
- QMessageBox.warning(self, tr("menu.analysis.blast"), str(exc))
- return
- QDesktopServices.openUrl(QUrl(blast_api.result_url(rid)))
- self.statusBar().showMessage(tr("blast.sent", rid=rid), 10000)
+ def _in_read_tab(self, action) -> None:
+ self.tabs.setCurrentWidget(self.read_view)
+ action()
+
+ def _open_read(self, path: Path) -> None:
+ self.read_view.open_path(path) # file_opened switches to the viewer tab
+
+ def _read_edited(self, path: Path) -> None:
+ """Edits reach the check through the read's session sidecar: write it
+ now (or drop it after undoing everything), then re-run the check."""
+ if self.workspace.contains_read(path):
+ self.read_view.autosave_session()
+ self.workspace.read_edited(path)
def _switch_language(self, code: str) -> None:
self.config.language = code
@@ -663,169 +293,18 @@ def _switch_language(self, code: str) -> None:
action.setChecked(other == code)
QMessageBox.information(self, tr("menu.view.language"), tr("lang.restart"))
- # ------------------------------------------------- expected SNP verification
- def _verify_dialog(self) -> None:
- if not (self.read_model and self.alignment and self.reference_seq):
- self.statusBar().showMessage(tr("verify.need_reference"), 6000)
- return
- path, _ = QFileDialog.getOpenFileName(
- self,
- tr("menu.analysis.verify"),
- str(self._path.parent) if self._path else "",
- "Variants (*.vcf *.csv *.tsv *.txt);;All files (*)",
- )
- if not path:
- return
- try:
- text = Path(path).read_text(encoding="utf-8", errors="replace")
- except OSError as exc:
- QMessageBox.warning(self, tr("verify.title"), str(exc))
- return
- self._verify_variants(text)
-
- def _verify_variants(self, text: str) -> None:
- try:
- variants, warnings = parse_expected(text)
- except ValueError as exc:
- QMessageBox.warning(self, tr("verify.title"), str(exc))
- return
- if not variants:
- QMessageBox.warning(self, tr("verify.title"), tr("search.none"))
- return
- self.variant_verdicts = verify_expected(
- self.alignment,
- self.read_model.current(),
- self.reference_seq,
- variants,
- min_q=self.config.snp_min_q,
- het_ratio=self.config.het_ratio,
- )
- self._fill_verify_table()
- self.verify_dock.show()
- self.chrom_view.set_snp_marks(
- [(v.read_pos, v.verdict) for v in self.variant_verdicts if v.read_pos is not None]
- )
- counts = {k: sum(1 for v in self.variant_verdicts if v.verdict == k)
- for k in ("confirmed", "heterozygous", "not_found", "no_data")}
- message = tr(
- "verify.summary",
- n=len(self.variant_verdicts),
- confirmed=counts["confirmed"],
- het=counts["heterozygous"],
- notfound=counts["not_found"],
- nodata=counts["no_data"],
- )
- if warnings:
- message += f" · {len(warnings)} ⚠"
- self.statusBar().showMessage(message, 10000)
-
- def _fill_verify_table(self) -> None:
- labels = {
- "confirmed": tr("verify.confirmed"),
- "heterozygous": tr("verify.heterozygous"),
- "not_found": tr("verify.not_found"),
- "no_data": tr("verify.no_data"),
- "ref_mismatch": tr("verify.ref_mismatch"),
- "low_quality": tr("verify.low_quality"),
- "unsupported": tr("verify.unsupported"),
- }
- colors = {
- "confirmed": "#1e9e1e",
- "heterozygous": "#e0a000",
- "not_found": "#e02020",
- }
- self.verify_table.setSortingEnabled(False)
- self.verify_table.setRowCount(len(self.variant_verdicts))
- for row, v in enumerate(self.variant_verdicts):
- values = [
- v.variant.name,
- v.variant.position,
- v.variant.ref,
- v.variant.alt,
- labels.get(v.verdict, v.verdict),
- v.observed,
- v.quality if v.quality is not None else "",
- v.note,
- ]
- for col, value in enumerate(values):
- item = QTableWidgetItem()
- item.setData(Qt.ItemDataRole.DisplayRole, value)
- if col == 4 and v.verdict in colors:
- item.setForeground(QColor(colors[v.verdict]))
- self.verify_table.setItem(row, col, item)
- self.verify_table.setSortingEnabled(True)
-
- def _save_verify_csv(self) -> None:
- if not self.variant_verdicts:
- return
- path, _ = QFileDialog.getSaveFileName(
- self, tr("verify.save_csv"), "snp_report.csv", "CSV (*.csv)"
- )
- if path:
- export.write_text(path, verdicts_csv(self.variant_verdicts))
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
-
- def _save_verify_xlsx(self) -> None:
- if not self.variant_verdicts:
- return
- path, _ = QFileDialog.getSaveFileName(
- self, tr("verify.save_xlsx"), "snp_report.xlsx", "Excel (*.xlsx)"
- )
- if not path:
- return
- try:
- verdicts_xlsx(path, self.variant_verdicts, sample_id=self._export_id())
- except RuntimeError as exc:
- QMessageBox.warning(self, tr("verify.save_xlsx"), str(exc))
- return
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
-
- # ------------------------------------------------------------------ batch
- def _open_folder_dialog(self) -> None:
- start = str(self._path.parent) if self._path else ""
- folder = QFileDialog.getExistingDirectory(self, tr("menu.file.open_folder"), start)
- if folder:
- self._scan_folder(Path(folder))
-
- def _scan_folder(self, folder: Path) -> None:
- self.batch_reports = batch.scan_folder(
- folder, self.config.trim_window, self.config.trim_min_q
- )
- self.batch_panel.show_reports(self.batch_reports)
- self.batch_dock.show()
- self.statusBar().showMessage(tr("batch.scan_done", n=len(self.batch_reports)), 5000)
-
- def _batch_export(self) -> None:
- if not self.batch_reports:
- return
- out_dir = QFileDialog.getExistingDirectory(self, tr("batch.export_dir"))
- if not out_dir:
- return
- results = batch.batch_export(
- self.batch_reports,
- Path(out_dir),
- window=self.config.trim_window,
- min_q=self.config.trim_min_q,
- )
- exported = sum(1 for _, dest, error in results if dest and not error)
- errors = sum(1 for _, _, error in results if error)
- self.statusBar().showMessage(
- tr("batch.exported", n=exported, err=errors), 8000
- )
-
- def _batch_save_csv(self) -> None:
- if not self.batch_reports:
- return
- path, _ = QFileDialog.getSaveFileName(
- self, tr("batch.csv"), "batch_summary.csv", "CSV (*.csv)"
- )
- if path:
- export.write_text(path, batch.summary_csv(self.batch_reports))
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
+ def set_theme(self, name: str) -> None:
+ self.config.theme = name
+ status.set_theme(name)
+ self.read_view.apply_theme(name)
+ self.workspace.set_theme(name)
+ QApplication.instance().setStyleSheet(app_qss(name == "dark"))
+ save_config(self.config)
+ # ----------------------------------------------------------- recent, drops
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
urls = event.mimeData().urls()
- if urls and urls[0].toLocalFile().lower().endswith(".ab1"):
+ if urls and urls[0].toLocalFile().lower().endswith((".ab1", PROJECT_SUFFIX)):
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
@@ -851,301 +330,77 @@ def _fill_recent_menu(self) -> None:
)
self.recent_menu.addAction(action)
- # ------------------------------------------------------------------ view
- def _sync_from_view(self) -> None:
- total = self.chrom_view._total_points()
- visible = self.chrom_view._visible_points()
- self.scrollbar.blockSignals(True)
- self.scrollbar.setRange(0, max(0, int(total - visible)))
- self.scrollbar.setPageStep(max(1, int(visible)))
- self.scrollbar.setValue(int(self.chrom_view.offset))
- self.scrollbar.blockSignals(False)
- self.minimap.set_view(self.chrom_view.offset, visible, total)
-
- def set_theme(self, name: str) -> None:
- self.config.theme = name
- colors = palette(name)
- self.chrom_view.set_theme(colors)
- self.minimap.set_theme(colors)
- for base, box in self._channel_boxes.items():
- box.setStyleSheet(
- f"QCheckBox {{ color: {colors['bases'][base]}; font-weight: bold; padding: 0 4px; }}"
- )
- QApplication.instance().setStyleSheet(app_qss(name == "dark"))
- save_config(self.config)
-
- # --------------------------------------------------------------- editing
- def _sync_actions(self) -> None:
- model = self.read_model
- self.undo_action.setEnabled(bool(model and model.can_undo))
- self.redo_action.setEnabled(bool(model and model.can_redo))
+ # ------------------------------------------------- delegates to the read view
+ def open_path(self, path: str | Path) -> bool:
+ """A ``.ab1proj`` opens in the construct check, anything else in the viewer."""
+ if str(path).lower().endswith(PROJECT_SUFFIX):
+ self._show_workspace()
+ opened = self.workspace.load_project(path)
+ if opened:
+ self._push_recent(str(path))
+ return opened
+ return self.read_view.open_path(path) # file_opened switches to the viewer tab
def refresh_read(self) -> None:
- if self.read_model is None:
- return
- chrom = self.read_model.current()
- self.chrom_view.set_chromatogram(chrom, keep_view=True)
- self.chrom_view.set_edited(self.read_model.edited)
- self.minimap.set_chromatogram(chrom)
- self.minimap.set_highlights([])
- self._search_ranges = []
- self._search_pos = -1
- self._refresh_info(chrom)
- self._sync_actions()
- self.setWindowTitle(
- f"ab1kit — {self._path.name}" + ("*" if self.read_model.modified else "")
- )
- if self.reference_seq:
- self._realign()
+ self.read_view.refresh_read()
def apply_base_change(self, index: int, base: str) -> None:
- if self.read_model:
- self.read_model.set_base(index, base)
- self.refresh_read()
+ self.read_view.apply_base_change(index, base)
def apply_base_insert(self, index: int, base: str) -> None:
- if self.read_model:
- self.read_model.insert_base(index, base)
- self.refresh_read()
+ self.read_view.apply_base_insert(index, base)
def apply_base_delete(self, index: int) -> None:
- if self.read_model:
- self.read_model.delete_base(index)
- self.refresh_read()
+ self.read_view.apply_base_delete(index)
def apply_trim_range(self, start: int, end: int) -> None:
- if not self.read_model:
- return
- try:
- self.read_model.apply_trim(start, end)
- except ValueError as exc:
- QMessageBox.warning(self, tr("trim.title"), str(exc))
- return
- self.chrom_view.set_trim_region(None)
- self.refresh_read()
- self.statusBar().showMessage(tr("trim.status", n=len(self.read_model.seq)))
+ self.read_view.apply_trim_range(start, end)
def undo_read(self) -> None:
- if self.read_model and self.read_model.undo():
- self.refresh_read()
+ self.read_view.undo_read()
def redo_read(self) -> None:
- if self.read_model and self.read_model.redo():
- self.refresh_read()
+ self.read_view.redo_read()
def reverse_complement_read(self) -> None:
- if self.read_model:
- self.read_model.reverse_complement()
- self.refresh_read()
+ self.read_view.reverse_complement_read()
- def _on_base_clicked(self, index: int, global_pos) -> None:
- if self.read_model is None:
- return
- menu = QMenu(self)
- change = menu.addMenu(tr("edit.change"))
- for letter in IUPAC_LETTERS:
- change.addAction(letter, lambda l=letter: self.apply_base_change(index, l))
- insert_left = menu.addMenu(tr("edit.insert_left"))
- for letter in INSERT_LETTERS:
- insert_left.addAction(letter, lambda l=letter: self.apply_base_insert(index, l))
- insert_right = menu.addMenu(tr("edit.insert_right"))
- for letter in INSERT_LETTERS:
- insert_right.addAction(letter, lambda l=letter: self.apply_base_insert(index + 1, l))
- menu.addSeparator()
- menu.addAction(tr("edit.delete"), lambda: self.apply_base_delete(index))
- menu.exec(global_pos)
-
- # ------------------------------------------------------------------ trim
- def _open_trim_dialog(self) -> None:
- if self.read_model is None or self.trim_dialog is not None:
- return
- start, end = trim_window(
- self.read_model.quality, self.config.trim_window, self.config.trim_min_q
- )
- self.chrom_view.set_trim_region((start, end))
- dialog = TrimDialog(
- lambda: self.read_model.quality,
- self.config.trim_window,
- self.config.trim_min_q,
- len(self.read_model.seq),
- self,
- )
- self.trim_dialog = dialog
- dialog.region_changed.connect(
- lambda start, end: self.chrom_view.set_trim_region((start, end))
- )
- self.chrom_view.trim_range_changed.connect(dialog.set_region)
- dialog.apply_requested.connect(self._apply_trim_from_dialog)
- dialog.finished.connect(self._trim_dialog_closed)
- dialog.show()
-
- def _apply_trim_from_dialog(self, start: int, end: int) -> None:
- if self.trim_dialog is not None:
- self.config.trim_window = self.trim_dialog.window_value()
- self.config.trim_min_q = self.trim_dialog.minq_value()
- save_config(self.config)
- self.apply_trim_range(start, end)
-
- def _trim_dialog_closed(self) -> None:
- dialog = self.trim_dialog
- self.trim_dialog = None
- if dialog is not None:
- try:
- self.chrom_view.trim_range_changed.disconnect(dialog.set_region)
- except RuntimeError:
- pass
- self.chrom_view.set_trim_region(None)
-
- # ---------------------------------------------------------------- search
- def _search(self) -> None:
- if self.chrom_view.chrom is None:
- return
- dialog = SearchDialog(self)
- if dialog.exec() != QDialog.DialogCode.Accepted:
- return
- pattern = dialog.pattern()
- if not pattern:
- return
- seq = self.chrom_view.chrom.seq
- try:
- fwd = find_matches(pattern, seq)
- rev = find_matches(pattern, seq, reverse=True) if dialog.want_reverse() else []
- except ValueError as exc:
- QMessageBox.warning(self, tr("search.title"), str(exc))
- return
- ranges = [(i, i + len(pattern)) for i in fwd + rev]
- self.chrom_view.set_highlights(ranges)
- self.minimap.set_highlights(ranges)
- self._search_ranges = ranges
- self._search_pos = -1
- if ranges:
- self.statusBar().showMessage(
- tr("search.found", n=len(ranges), f=len(fwd), r=len(rev))
- )
- self._find_next()
- else:
- self.statusBar().showMessage(tr("search.none"))
+ def _set_reference_seq(self, sequence: str) -> None:
+ self.read_view._set_reference_seq(sequence)
- def _find_next(self) -> None:
- if not self._search_ranges:
- return
- self._search_pos = (self._search_pos + 1) % len(self._search_ranges)
- self.chrom_view.goto_base(self._search_ranges[self._search_pos][0] + 1)
+ def _reference_overlay(self) -> tuple[str, dict[int, int]]:
+ return self.read_view._reference_overlay()
- # ----------------------------------------------------------- goto and info
- def _goto(self) -> None:
- chrom = self.chrom_view.chrom
- if chrom is None:
- return
- number, ok = QInputDialog.getInt(
- self, tr("goto.title"), tr("goto.label"), 1, 1, len(chrom.seq)
- )
- if ok:
- self.chrom_view.goto_base(number)
-
- def _refresh_info(self, chrom: Chromatogram) -> None:
- page = QWidget()
- form = QFormLayout(page)
- form.setContentsMargins(8, 8, 8, 8)
-
- def row(label: str, value) -> None:
- widget = QLabel(str(value))
- widget.setWordWrap(True)
- form.addRow(QLabel(label), widget)
-
- row(tr("info.file"), self._path)
- row(tr("info.length"), len(chrom.seq))
- row(tr("info.mean_q"), f"{sum(chrom.quality) / max(1, len(chrom.quality)):.1f}")
- row(tr("info.q20"), sum(1 for q in chrom.quality if q >= 20))
- meta_keys = (
- ("sample", "meta.sample"),
- ("instrument", "meta.instrument"),
- ("run_date", "meta.run_date"),
- ("run_time", "meta.run_time"),
- ("project", "meta.project"),
- ("capillary", "meta.capillary"),
- ("mobility", "meta.mobility"),
- ("comment", "meta.comment"),
- )
- for key, label_key in meta_keys:
- if key in chrom.metadata:
- row(tr(label_key), chrom.metadata[key])
- self.info_dock.setWidget(page)
-
- # ------------------------------------------------------- export and sessions
- def _export_target(self, caption: str, suffix: str, pattern: str) -> str | None:
- default = (self._path.stem if self._path else "read") + suffix
- path, _ = QFileDialog.getSaveFileName(self, caption, default, pattern)
- return path or None
-
- def _export_fasta(self) -> None:
- if not self.read_model:
- return
- path = self._export_target(tr("menu.file.export_fasta"), ".fasta", "FASTA (*.fasta)")
- if path:
- export.write_text(path, export.to_fasta(self._export_id(), "".join(self.read_model.seq)))
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
+ def _clear_reference(self) -> None:
+ self.read_view._clear_reference()
- def _export_fastq(self) -> None:
- if not self.read_model:
- return
- path = self._export_target(tr("menu.file.export_fastq"), ".fastq", "FASTQ (*.fastq)")
- if path:
- content = export.to_fastq(
- self._export_id(), "".join(self.read_model.seq), self.read_model.quality
- )
- export.write_text(path, content)
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
+ def _export_aligned(self) -> None:
+ self.read_view._export_aligned()
- def _export_id(self) -> str:
- return self.read_model.current().sample_id if self.read_model else "read"
+ def _blast_submit(self) -> None:
+ self.read_view._blast_submit()
- def _export_png(self) -> None:
- path = self._export_target(tr("menu.file.export_png"), ".png", "PNG (*.png)")
- if path and self.chrom_view.grab().save(path):
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
+ def _verify_variants(self, text: str) -> None:
+ self.read_view._verify_variants(text)
- def _export_pdf(self) -> None:
- path = self._export_target(tr("menu.file.export_pdf"), ".pdf", "PDF (*.pdf)")
- if not path:
- return
- pixmap = self.chrom_view.grab()
- writer = QPdfWriter(path)
- writer.setPageSize(QPageSize(QSizeF(pixmap.width(), pixmap.height()), QPageSize.Unit.Point))
- writer.setPageMargins(QMarginsF(0, 0, 0, 0))
- painter = QPainter(writer)
- painter.drawPixmap(0, 0, writer.width(), writer.height(), pixmap)
- painter.end()
- self.statusBar().showMessage(tr("export.done", path=path), 5000)
-
- def _copy_sequence(self) -> None:
- if self.read_model:
- QApplication.clipboard().setText("".join(self.read_model.seq))
- self.statusBar().showMessage(
- tr("copy.done", n=len(self.read_model.seq)), 3000
- )
+ def _fill_verify_table(self) -> None:
+ self.read_view._fill_verify_table()
- def _save_session(self) -> None:
- if self.read_model is None or self._path is None:
- return
- sessions.save_session(
- sessions.session_path(self._path),
- {"source": self._path.name, **self.read_model.to_dict()},
- )
- self.statusBar().showMessage(tr("session.saved"), 3000)
+ def _save_verify_csv(self) -> None:
+ self.read_view._save_verify_csv()
+
+ def _scan_folder(self, folder: Path) -> None:
+ self._in_read_tab(lambda: self.read_view._scan_folder(folder))
+
+ def _batch_export(self) -> None:
+ self.batch_dock.export_trimmed()
+
+ def _open_trim_dialog(self) -> None:
+ self.read_view._open_trim_dialog()
def closeEvent(self, event) -> None: # noqa: N802
- if self.read_model is not None and self._path is not None:
- sidecar = sessions.session_path(self._path)
- try:
- if self.read_model.modified:
- sessions.save_session(
- sidecar, {"source": self._path.name, **self.read_model.to_dict()}
- )
- elif sidecar.exists():
- sidecar.unlink()
- except OSError:
- pass
+ self._cancel_tasks()
+ self.read_view.autosave_session()
super().closeEvent(event)
diff --git a/ui/minimap.py b/ui/minimap.py
index 264576f..3acc067 100644
--- a/ui/minimap.py
+++ b/ui/minimap.py
@@ -7,6 +7,7 @@
from PySide6.QtWidgets import QWidget
from chrom_core import Chromatogram
+from ui.status import quality_bucket
from ui.theme import LIGHT
STRIP_H = 18
@@ -107,7 +108,7 @@ def _paint_strip(self, painter: QPainter) -> None:
y = self._strip_y()
peaks = list(chrom.peak_indices) + [self._total]
for i, quality in enumerate(chrom.quality):
- bucket = "low_q" if quality < 10 else "mid_q" if quality < 20 else "high_q"
+ bucket = quality_bucket(quality)
painter.setBrush(QColor(self.theme[bucket]))
x0 = self._x(peaks[i])
x1 = self._x(peaks[i + 1])
diff --git a/ui/read_view/__init__.py b/ui/read_view/__init__.py
new file mode 100644
index 0000000..dfb6a74
--- /dev/null
+++ b/ui/read_view/__init__.py
@@ -0,0 +1,5 @@
+"""Single-read viewer package."""
+
+from ui.read_view.read_view import ReadView
+
+__all__ = ["ReadView"]
diff --git a/ui/read_view/analysis_dock.py b/ui/read_view/analysis_dock.py
new file mode 100644
index 0000000..7fdb71b
--- /dev/null
+++ b/ui/read_view/analysis_dock.py
@@ -0,0 +1,108 @@
+"""Reference-comparison dock: table of differences with SNP verdicts."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QDockWidget, QTableWidget, QTableWidgetItem, QWidget
+
+from chrom_core.align import ReadAlignment
+from chrom_core.seqops import reverse_complement
+from i18n import tr
+from ui.tables import configure_table, paint_status, set_full_text_tooltip
+
+
+def reference_overlay(
+ alignment: ReadAlignment, reference_seq: str, read_length: int
+) -> tuple[str, dict[int, int]]:
+ """Reference letters and ref -> index mapping in the *displayed* read's
+ orientation (the alignment indexes the reverse complement on '-')."""
+ if alignment.strand == "+":
+ return reference_seq, alignment.ref_to_read
+ n = read_length
+ return (
+ reverse_complement(reference_seq)[::-1], # complement, same ref indexing
+ {pos: n - 1 - idx for pos, idx in alignment.ref_to_read.items()},
+ )
+
+
+def displayed_diff_index(alignment: ReadAlignment, diff, read_length: int) -> int | None:
+ """0-based index of ``diff`` in the displayed read (None for deletions)."""
+ if diff.read_pos is None:
+ return None
+ if alignment.strand == "+":
+ return diff.read_pos
+ n = read_length
+ span = len(diff.read_base) if diff.kind == "insertion" else 1
+ return n - diff.read_pos - span
+
+
+DIFF_VERDICT_STATUS = {"homozygous": "ok", "heterozygous": "mixed", "low_quality": "incomplete"}
+
+
+class DiffDock(QDockWidget):
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(tr("analysis.diffs.title"), parent)
+ self.diff_table = QTableWidget(0, 8)
+ self.diff_table.setHorizontalHeaderLabels(
+ [
+ tr("analysis.col.pos"),
+ tr("analysis.col.kind"),
+ tr("analysis.col.ref"),
+ tr("analysis.col.read"),
+ tr("analysis.col.readpos"),
+ tr("snp.col.verdict"),
+ tr("snp.col.q"),
+ tr("snp.col.ratio"),
+ ]
+ )
+ configure_table(self.diff_table)
+ self.diff_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.diff_table.setSortingEnabled(True)
+ self.setWidget(self.diff_table)
+
+ def fill(self, alignment: ReadAlignment | None, snp_calls: list, read_length: int) -> None:
+ kind_labels = {
+ "substitution": tr("analysis.kind.substitution"),
+ "deletion": tr("analysis.kind.deletion"),
+ "insertion": tr("analysis.kind.insertion"),
+ }
+ verdict_labels = {
+ "homozygous": tr("snp.homozygous"),
+ "heterozygous": tr("snp.heterozygous"),
+ "low_quality": tr("snp.low_quality"),
+ }
+ calls_by_ref = {c.ref_pos: c for c in snp_calls}
+ diffs = alignment.differences if alignment else []
+ self.diff_table.setSortingEnabled(False)
+ self.diff_table.setRowCount(len(diffs))
+ for row, diff in enumerate(diffs):
+ call = calls_by_ref.get(diff.ref_pos)
+ verdict = ""
+ if call is not None:
+ verdict = verdict_labels.get(call.kind, call.kind)
+ if call.kind == "heterozygous":
+ verdict += f" ({call.iupac})"
+ values = [
+ diff.ref_pos + 1,
+ kind_labels.get(diff.kind, diff.kind),
+ diff.ref_base,
+ diff.read_base,
+ (call.read_pos + 1 if call is not None
+ else shown + 1 if (shown := displayed_diff_index(alignment, diff, read_length)) is not None
+ else ""),
+ verdict,
+ call.quality if call is not None else "",
+ f"{call.ratio:.2f}" if call is not None and call.ratio is not None else "",
+ ]
+ for col, value in enumerate(values):
+ item = QTableWidgetItem()
+ item.setData(Qt.ItemDataRole.DisplayRole, value)
+ set_full_text_tooltip(item)
+ if call is not None and col == 5:
+ paint_status(item, DIFF_VERDICT_STATUS.get(call.kind, "no_data"))
+ self.diff_table.setItem(row, col, item)
+ self.diff_table.setSortingEnabled(True)
+
+ def clear(self) -> None:
+ self.diff_table.setRowCount(0)
+ self.hide()
diff --git a/ui/batch_panel.py b/ui/read_view/batch_dock.py
similarity index 51%
rename from ui/batch_panel.py
rename to ui/read_view/batch_dock.py
index c9cd77c..8818bf7 100644
--- a/ui/batch_panel.py
+++ b/ui/read_view/batch_dock.py
@@ -1,13 +1,16 @@
-"""Batch panel: a QC table over a folder of .ab1 files."""
+"""Batch dock: a QC table over a folder of .ab1 files, with scan and export."""
from __future__ import annotations
+from collections.abc import Callable
+from pathlib import Path
+
from PySide6.QtCore import Qt, Signal
-from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QCheckBox,
+ QDockWidget,
+ QFileDialog,
QHBoxLayout,
- QHeaderView,
QPushButton,
QTableWidget,
QTableWidgetItem,
@@ -15,11 +18,14 @@
QWidget,
)
+from chrom_core import batch, export
from chrom_core.batch import FileReport
+from chrom_core.config import AppConfig
from i18n import tr
+from ui.tables import configure_table, paint_status, set_full_text_tooltip
STATUS_TEXT = {"ok": "✓", "warn": "⚠", "bad": "✗", "error": "✗"}
-STATUS_COLOR = {"ok": "#1e9e1e", "warn": "#e0a000", "bad": "#e02020", "error": "#e02020"}
+STATUS_VERDICT = {"ok": "ok", "warn": "incomplete", "bad": "errors", "error": "errors"}
class BatchPanel(QWidget):
@@ -44,10 +50,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
tr("batch.col.status"),
]
)
- self.table.horizontalHeader().setSectionResizeMode(
- 0, QHeaderView.ResizeMode.Stretch
- )
- self.table.verticalHeader().setVisible(False)
+ configure_table(self.table, stretch_column=0)
self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
@@ -73,11 +76,15 @@ def show_reports(self, reports: list[FileReport]) -> None:
for row, report in enumerate(reports):
self._set_item(row, 0, report.path.name, data=str(report.path))
self._set_item(row, 1, report.length if not report.error else "")
- self._set_item(row, 2, report.mean_q if not report.error else "")
+ self._set_item(
+ row, 2,
+ "" if report.error else "—" if report.mean_q is None else report.mean_q,
+ )
self._set_item(row, 3, report.q20 if not report.error else "")
self._set_item(row, 4, report.trimmed_length if not report.error else "")
status = QTableWidgetItem(STATUS_TEXT[report.status])
- status.setForeground(QColor(STATUS_COLOR[report.status]))
+ status.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ paint_status(status, STATUS_VERDICT[report.status])
if report.error:
status.setToolTip(report.error)
self.table.setItem(row, 5, status)
@@ -89,6 +96,7 @@ def _set_item(self, row: int, col: int, value, data=None) -> None:
item.setData(Qt.ItemDataRole.DisplayRole, value)
if data is not None:
item.setData(Qt.ItemDataRole.UserRole, data)
+ set_full_text_tooltip(item)
self.table.setItem(row, col, item)
def _apply_filter(self) -> None:
@@ -104,3 +112,69 @@ def _double_clicked(self, item: QTableWidgetItem) -> None:
path = path_item.data(Qt.ItemDataRole.UserRole)
if path:
self.open_requested.emit(str(path))
+
+
+class BatchDock(QDockWidget):
+ """Dock around :class:`BatchPanel`; owns folder scanning and batch export.
+
+ ``start_task`` is the shell's background-task runner; ``message`` is
+ emitted for status-bar text (text, timeout in ms).
+ """
+
+ open_requested = Signal(str)
+ message = Signal(str, int)
+
+ def __init__(self, config: AppConfig, start_task: Callable, parent: QWidget | None = None) -> None:
+ super().__init__(tr("batch.title"), parent)
+ self.config = config
+ self._start_task = start_task
+ self.reports: list[batch.FileReport] = []
+ self.batch_panel = BatchPanel()
+ self.setWidget(self.batch_panel)
+ self.batch_panel.open_requested.connect(self.open_requested)
+ self.batch_panel.export_button.clicked.connect(self.export_trimmed)
+ self.batch_panel.csv_button.clicked.connect(self.save_csv)
+
+ def scan_folder(self, folder: Path) -> None:
+ window, min_q = self.config.trim_window, self.config.trim_min_q
+
+ def done(reports: object) -> None:
+ self.reports = reports # type: ignore[assignment]
+ self.batch_panel.show_reports(self.reports)
+ self.show()
+ self.message.emit(tr("batch.scan_done", n=len(self.reports)), 5000)
+
+ # scan_folder reports no progress and is not interruptible: on cancel
+ # its late result is dropped and the table stays as it was.
+ self._start_task(
+ lambda progress, is_cancelled: batch.scan_folder(folder, window, min_q),
+ done,
+ tr("menu.file.open_folder"),
+ key="scan",
+ )
+
+ def export_trimmed(self) -> None:
+ if not self.reports:
+ return
+ out_dir = QFileDialog.getExistingDirectory(self, tr("batch.export_dir"))
+ if not out_dir:
+ return
+ results = batch.batch_export(
+ self.reports,
+ Path(out_dir),
+ window=self.config.trim_window,
+ min_q=self.config.trim_min_q,
+ )
+ exported = sum(1 for _, dest, error in results if dest and not error)
+ errors = sum(1 for _, _, error in results if error)
+ self.message.emit(tr("batch.exported", n=exported, err=errors), 8000)
+
+ def save_csv(self) -> None:
+ if not self.reports:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("batch.csv"), "batch_summary.csv", "CSV (*.csv)"
+ )
+ if path:
+ export.write_text(path, batch.summary_csv(self.reports))
+ self.message.emit(tr("export.done", path=path), 5000)
diff --git a/ui/read_view/dialogs.py b/ui/read_view/dialogs.py
new file mode 100644
index 0000000..4d0524f
--- /dev/null
+++ b/ui/read_view/dialogs.py
@@ -0,0 +1,149 @@
+"""Dialogs used by the single-read view."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QDialog,
+ QFileDialog,
+ QFormLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPlainTextEdit,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from chrom_core import trim_window
+from i18n import tr
+
+
+class SearchDialog(QDialog):
+ def __init__(self, parent=None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(tr("search.title"))
+ layout = QVBoxLayout(self)
+ self.edit = QLineEdit()
+ self.edit.setPlaceholderText(tr("search.pattern"))
+ self.reverse_box = QCheckBox(tr("search.reverse"))
+ button = QPushButton(tr("search.button"))
+ button.setDefault(True)
+ button.clicked.connect(self.accept)
+ layout.addWidget(self.edit)
+ layout.addWidget(self.reverse_box)
+ layout.addWidget(button)
+
+ def pattern(self) -> str:
+ return self.edit.text().strip().upper()
+
+ def want_reverse(self) -> bool:
+ return self.reverse_box.isChecked()
+
+
+class TrimDialog(QDialog):
+ """Non-modal quality-trim dialog; preview streams into the chromatogram."""
+
+ region_changed = Signal(int, int)
+ apply_requested = Signal(int, int)
+
+ def __init__(self, quality_fn, window: int, min_q: int, total: int, parent=None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(tr("trim.title"))
+ self.quality_fn = quality_fn
+ self.total = total
+ self._region = (0, total)
+
+ layout = QVBoxLayout(self)
+ form = QFormLayout()
+ self.window_spin = QSpinBox()
+ self.window_spin.setRange(2, 100)
+ self.window_spin.setValue(window)
+ self.minq_spin = QSpinBox()
+ self.minq_spin.setRange(0, 60)
+ self.minq_spin.setValue(min_q)
+ form.addRow(QLabel(tr("trim.window")), self.window_spin)
+ form.addRow(QLabel(tr("trim.minq")), self.minq_spin)
+ layout.addLayout(form)
+ self.preview = QLabel()
+ layout.addWidget(self.preview)
+ buttons = QWidget()
+ row = QVBoxLayout(buttons)
+ apply_button = QPushButton(tr("trim.apply"))
+ apply_button.setDefault(True)
+ apply_button.clicked.connect(self._apply)
+ close_button = QPushButton(tr("dialog.close"))
+ close_button.clicked.connect(self.close)
+ row.addWidget(apply_button)
+ row.addWidget(close_button)
+ layout.addWidget(buttons)
+
+ self.window_spin.valueChanged.connect(self._recompute)
+ self.minq_spin.valueChanged.connect(self._recompute)
+ self._recompute()
+
+ def window_value(self) -> int:
+ return self.window_spin.value()
+
+ def minq_value(self) -> int:
+ return self.minq_spin.value()
+
+ def _recompute(self) -> None:
+ start, end = trim_window(self.quality_fn(), self.window_spin.value(), self.minq_spin.value())
+ self._set_region(start, end)
+ self.region_changed.emit(start, end)
+
+ def _set_region(self, start: int, end: int) -> None:
+ self._region = (start, end)
+ self.preview.setText(tr("trim.preview", n=end - start, total=self.total))
+
+ def set_region(self, start: int, end: int) -> None:
+ """Sync preview when the user drags trim handles in the view."""
+ self._set_region(start, end)
+
+ def _apply(self) -> None:
+ start, end = self._region
+ self.apply_requested.emit(start, end)
+ self.close()
+
+
+def ask_reference_text(parent: QWidget) -> str | None:
+ """Modal dialog for pasting/loading a reference; returns the raw text
+ when accepted, ``None`` otherwise."""
+ dialog = QDialog(parent)
+ dialog.setWindowTitle(tr("analysis.ref.title"))
+ dialog.resize(480, 240)
+ layout = QVBoxLayout(dialog)
+ edit = QPlainTextEdit()
+ edit.setPlaceholderText(tr("analysis.ref.paste"))
+ layout.addWidget(edit)
+
+ def from_file() -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ dialog, tr("analysis.ref.from_file"), "", "Sequence (*.fasta *.fa *.txt);;All files (*)"
+ )
+ if path:
+ edit.setPlainText(Path(path).read_text(encoding="utf-8", errors="replace"))
+
+ file_button = QPushButton(tr("analysis.ref.from_file"))
+ file_button.clicked.connect(from_file)
+ layout.addWidget(file_button)
+
+ ok = QPushButton("OK")
+ ok.clicked.connect(dialog.accept)
+ cancel = QPushButton(tr("dialog.close"))
+ cancel.clicked.connect(dialog.reject)
+ row = QWidget()
+ row_layout = QHBoxLayout(row)
+ row_layout.addWidget(ok)
+ row_layout.addWidget(cancel)
+ layout.addWidget(row)
+
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return None
+ return edit.toPlainText()
diff --git a/ui/read_view/menus.py b/ui/read_view/menus.py
new file mode 100644
index 0000000..ba432e9
--- /dev/null
+++ b/ui/read_view/menus.py
@@ -0,0 +1,117 @@
+"""Read-specific menu items, appended by the shell into its own menus.
+
+Shortcuts, order and enabled-state handling are the ones the single window
+used to have; the actions are parented to the view so they live with it.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtGui import QAction
+from PySide6.QtWidgets import QMenu
+
+from i18n import tr
+from ui.status import bind_icon
+
+
+def add_file_actions(view, file_menu: QMenu) -> None:
+ """Save session, export submenu, copy sequence (between 'Recent' and 'Exit')."""
+ save_session = QAction(tr("menu.file.save_session"), view)
+ bind_icon(save_session, "save")
+ save_session.setShortcut("Ctrl+S")
+ save_session.triggered.connect(view._save_session)
+ file_menu.addAction(save_session)
+ export_menu = file_menu.addMenu(tr("menu.file.export"))
+ for text, slot in (
+ (tr("menu.file.export_fasta"), view._export_fasta),
+ (tr("menu.file.export_fastq"), view._export_fastq),
+ (tr("menu.file.export_png"), view._export_png),
+ (tr("menu.file.export_pdf"), view._export_pdf),
+ ):
+ action = QAction(text, view)
+ action.triggered.connect(slot)
+ export_menu.addAction(action)
+ file_menu.addSeparator()
+ copy_action = QAction(tr("menu.file.copy_seq"), view)
+ bind_icon(copy_action, "copy")
+ copy_action.setShortcut("Ctrl+Shift+C")
+ copy_action.triggered.connect(view._copy_sequence)
+ file_menu.addAction(copy_action)
+
+
+def add_edit_actions(view, edit_menu: QMenu) -> None:
+ edit_menu.addAction(view.undo_action)
+ edit_menu.addAction(view.redo_action)
+ edit_menu.addSeparator()
+ find_action = QAction(tr("menu.edit.find"), view)
+ bind_icon(find_action, "search")
+ find_action.setShortcut("Ctrl+F")
+ find_action.triggered.connect(view._search)
+ edit_menu.addAction(find_action)
+ find_next = QAction(tr("menu.edit.find_next"), view)
+ find_next.setShortcut("F3")
+ find_next.triggered.connect(view._find_next)
+ edit_menu.addAction(find_next)
+ goto_action = QAction(tr("menu.edit.goto"), view)
+ goto_action.setShortcut("Ctrl+G")
+ goto_action.triggered.connect(view._goto)
+ edit_menu.addAction(goto_action)
+ edit_menu.addSeparator()
+ revcomp_action = QAction(tr("menu.edit.revcomp"), view)
+ revcomp_action.setShortcut("Ctrl+R")
+ revcomp_action.triggered.connect(view.reverse_complement_read)
+ edit_menu.addAction(revcomp_action)
+ trim_action = QAction(tr("menu.edit.trim"), view)
+ bind_icon(trim_action, "scissors")
+ trim_action.setShortcut("Ctrl+T")
+ trim_action.triggered.connect(view._open_trim_dialog)
+ edit_menu.addAction(trim_action)
+
+
+def add_view_actions(view, view_menu: QMenu) -> None:
+ """Smoothing, quality profile, info and batch docks (after 'Dark theme')."""
+ smooth_action = QAction(tr("menu.view.smoothing"), view)
+ smooth_action.setCheckable(True)
+ smooth_action.toggled.connect(view.chrom_view.set_smoothing)
+ view_menu.addAction(smooth_action)
+ profile_action = QAction(tr("menu.view.quality_profile"), view)
+ profile_action.setCheckable(True)
+ profile_action.toggled.connect(view.minimap.set_profile)
+ view_menu.addAction(profile_action)
+ info_action = QAction(tr("menu.view.file_info"), view)
+ bind_icon(info_action, "info")
+ info_action.setCheckable(True)
+ info_action.toggled.connect(view.info_dock.setVisible)
+ view.info_dock.visibilityChanged.connect(info_action.setChecked)
+ view_menu.addAction(info_action)
+ batch_action = QAction(tr("menu.view.batch_panel"), view)
+ bind_icon(batch_action, "table-2")
+ batch_action.setCheckable(True)
+ batch_action.toggled.connect(view.batch_dock.setVisible)
+ view.batch_dock.visibilityChanged.connect(batch_action.setChecked)
+ view_menu.addAction(batch_action)
+
+
+def add_analysis_actions(view, analysis_menu: QMenu) -> None:
+ reference_action = QAction(tr("menu.analysis.reference"), view)
+ bind_icon(reference_action, "dna")
+ reference_action.setShortcut("Ctrl+L")
+ reference_action.triggered.connect(view._reference_dialog)
+ analysis_menu.addAction(reference_action)
+ clear_ref = QAction(tr("menu.analysis.clear_reference"), view)
+ clear_ref.triggered.connect(view._clear_reference)
+ analysis_menu.addAction(clear_ref)
+ analysis_menu.addSeparator()
+ export_aligned = QAction(tr("menu.analysis.export_aligned"), view)
+ export_aligned.triggered.connect(view._export_aligned)
+ analysis_menu.addAction(export_aligned)
+ analysis_menu.addSeparator()
+ verify_action = QAction(tr("menu.analysis.verify"), view)
+ bind_icon(verify_action, "flask-conical")
+ verify_action.setShortcut("Ctrl+Shift+V")
+ verify_action.triggered.connect(view._verify_dialog)
+ analysis_menu.addAction(verify_action)
+ analysis_menu.addSeparator()
+ blast_action = QAction(tr("menu.analysis.blast"), view)
+ bind_icon(blast_action, "globe")
+ blast_action.triggered.connect(view._blast_submit)
+ analysis_menu.addAction(blast_action)
diff --git a/ui/read_view/read_view.py b/ui/read_view/read_view.py
new file mode 100644
index 0000000..3479429
--- /dev/null
+++ b/ui/read_view/read_view.py
@@ -0,0 +1,791 @@
+"""Single-read viewer: chromatogram, minimap, editing, trim, search, analysis docks."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from pathlib import Path
+
+from PySide6.QtCore import QMarginsF, QSizeF, Qt, QUrl, Signal
+from PySide6.QtGui import QAction, QDesktopServices, QPageSize, QPainter, QPdfWriter
+from PySide6.QtWidgets import (
+ QApplication,
+ QCheckBox,
+ QDialog,
+ QDockWidget,
+ QFileDialog,
+ QFormLayout,
+ QInputDialog,
+ QLabel,
+ QMainWindow,
+ QMenu,
+ QMessageBox,
+ QScrollBar,
+ QSizePolicy,
+ QToolBar,
+ QVBoxLayout,
+ QWidget,
+)
+
+from chrom_core import Chromatogram, EditableRead, read_ab1, trim_window
+from chrom_core import blast as blast_api
+from chrom_core import export, sessions
+from chrom_core.align import ReadAlignment, align_read, gapped_sequences, parse_sequence
+from chrom_core.config import AppConfig, save_config
+from chrom_core.editing import INSERT_LETTERS, IUPAC_LETTERS
+from chrom_core.snps import call_snps
+from chrom_core.verify import parse_expected, verify_expected, verdicts_csv, verdicts_xlsx
+from chrom_core.seqops import find_matches
+from i18n import tr
+from ui import status
+from ui.chrom_view import ChromatogramWidget
+from ui.minimap import MiniMap
+from ui.read_view import menus
+from ui.read_view.analysis_dock import DiffDock, displayed_diff_index, reference_overlay
+from ui.read_view.batch_dock import BatchDock
+from ui.read_view.dialogs import SearchDialog, TrimDialog, ask_reference_text
+from ui.read_view.verify_dock import VerifyDock
+from ui.status import bind_icon, quality_bucket
+from ui.tables import repaint_statuses
+from ui.theme import image_rule, theme as palette, tick_image
+
+
+class ReadView(QMainWindow):
+ """Everything that belongs to one open read. Embedded by the shell window.
+
+ The shell supplies ``start_task`` (background-task runner, see
+ ``MainWindow._start_task``) and listens to the signals below; the view
+ never touches its parent.
+ """
+
+ read_edited = Signal(Path) # after a base edit, undo/redo, trim, revcomp, session save
+ file_opened = Signal(str)
+ status_message = Signal(str, int) # text, timeout in ms (0 = until replaced)
+ title_changed = Signal(str)
+
+ def __init__(self, config: AppConfig, start_task: Callable, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.config = config
+ self._start_task = start_task
+ self._path: Path | None = None
+ self._active = True # False while the shell shows another tab
+ self.read_model: EditableRead | None = None
+ self.trim_dialog: TrimDialog | None = None
+ self.reference_seq: str = ""
+ self.alignment: ReadAlignment | None = None
+ self.snp_calls: list = []
+ self.variant_verdicts: list = []
+ self._search_ranges: list[tuple[int, int]] = []
+ self._search_pos = -1
+
+ self.chrom_view = ChromatogramWidget()
+ self.minimap = MiniMap()
+ self.scrollbar = QScrollBar(Qt.Orientation.Horizontal)
+
+ central = QWidget()
+ layout = QVBoxLayout(central)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self.minimap)
+ layout.addWidget(self.chrom_view, 1)
+ layout.addWidget(self.scrollbar)
+ self.setCentralWidget(central)
+
+ self.undo_action = QAction(tr("menu.edit.undo"), self)
+ self.undo_action.setShortcut("Ctrl+Z")
+ self.undo_action.triggered.connect(self.undo_read)
+ self.undo_action.setEnabled(False)
+ self.redo_action = QAction(tr("menu.edit.redo"), self)
+ self.redo_action.setShortcut("Ctrl+Y")
+ self.redo_action.triggered.connect(self.redo_read)
+ self.redo_action.setEnabled(False)
+
+ self._channel_boxes: dict[str, QCheckBox] = {}
+ self._build_toolbar()
+ self._build_docks()
+ self._connect()
+
+ def _say(self, text: str, timeout: int = 0) -> None:
+ self.status_message.emit(text, timeout)
+
+ # ------------------------------------------------------------------ setup
+ def _build_toolbar(self) -> None:
+ bar = QToolBar()
+ bar.setMovable(False)
+ bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.addToolBar(bar)
+
+ def add(text: str, icon_name: str, slot, shortcut: str | None = None) -> None:
+ action = QAction(text, self)
+ bind_icon(action, icon_name)
+ if shortcut:
+ action.setShortcut(shortcut)
+ action.triggered.connect(slot)
+ bar.addAction(action)
+
+ add(tr("tb.open"), "folder-open", self.open_dialog)
+ bar.addSeparator()
+ add(tr("tb.zoom_in"), "zoom-in", self.chrom_view.zoom_in, "Ctrl++")
+ add(tr("tb.zoom_out"), "zoom-out", self.chrom_view.zoom_out, "Ctrl+-")
+ add(tr("tb.fit"), "maximize", self.chrom_view.zoom_fit)
+ add(tr("tb.reset"), "rotate-ccw", self.chrom_view.zoom_reset)
+ bar.addSeparator()
+ bind_icon(self.undo_action, "undo-2")
+ bind_icon(self.redo_action, "redo-2")
+ bar.addAction(self.undo_action)
+ bar.addAction(self.redo_action)
+ bar.addSeparator()
+ for base in "ACGT":
+ box = QCheckBox(base)
+ box.setChecked(True)
+ box.toggled.connect(
+ lambda on, b=base: self.chrom_view.set_visible_channel(b, on)
+ )
+ bar.addWidget(box)
+ self._channel_boxes[base] = box
+ spacer = QWidget()
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ bar.addWidget(spacer)
+ self.quality_legend = QLabel()
+ self.quality_legend.setToolTip(tr("tb.quality_legend.tip"))
+ bar.addWidget(self.quality_legend)
+ self._refresh_legend("light")
+
+ def _build_docks(self) -> None:
+ self.info_dock = QDockWidget(tr("info.title"), self)
+ self.info_dock.setWidget(QWidget())
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.info_dock)
+ self.info_dock.hide()
+
+ self.batch_dock = BatchDock(self.config, self._start_task)
+ self.batch_panel = self.batch_dock.batch_panel
+ self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.batch_dock)
+ self.batch_dock.hide()
+ self.batch_dock.open_requested.connect(self.open_path)
+ self.batch_dock.message.connect(self._say)
+
+ self.diff_dock = DiffDock()
+ self.diff_table = self.diff_dock.diff_table
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.diff_dock)
+ self.diff_dock.hide()
+
+ self.verify_dock = VerifyDock()
+ self.verify_table = self.verify_dock.verify_table
+ self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.verify_dock)
+ self.verify_dock.hide()
+ self.verify_dock.csv_button.clicked.connect(self._save_verify_csv)
+ self.verify_dock.xlsx_button.clicked.connect(self._save_verify_xlsx)
+
+ @property
+ def batch_reports(self) -> list:
+ return self.batch_dock.reports
+
+ def current_model(self) -> EditableRead | None:
+ return self.read_model
+
+ # ------------------------------------------------------------------ menus
+ # The shell owns the menu bar and its order; it asks the view to append
+ # the read-specific items (see ``menus.py``) at the right places.
+ def add_file_actions(self, menu: QMenu) -> None:
+ menus.add_file_actions(self, menu)
+
+ def add_edit_menu(self, menu: QMenu) -> None:
+ menus.add_edit_actions(self, menu)
+
+ def add_view_actions(self, menu: QMenu) -> None:
+ menus.add_view_actions(self, menu)
+
+ def add_analysis_menu(self, menu: QMenu) -> None:
+ menus.add_analysis_actions(self, menu)
+
+ def _connect(self) -> None:
+ self.chrom_view.view_changed.connect(self._sync_from_view)
+ self.chrom_view.cursor_info.connect(self._say)
+ self.chrom_view.base_clicked.connect(self._on_base_clicked)
+ self.minimap.request_center.connect(self.chrom_view.center_on_point)
+ self.scrollbar.valueChanged.connect(
+ lambda value: self.chrom_view.set_offset(float(value))
+ )
+
+ def open_path(self, path: str | Path) -> bool:
+ try:
+ chrom = read_ab1(path)
+ except Exception as exc: # user-facing: any parse failure becomes a dialog
+ QMessageBox.critical(self, tr("err.open_failed"), f"{path}:\n{exc}")
+ return False
+ self._path = Path(path)
+ model = EditableRead(chrom)
+ restored = self._try_restore_session(model)
+ self.read_model = model
+ self._search_ranges = []
+ self._search_pos = -1
+ self.chrom_view.set_chromatogram(model.current())
+ self.chrom_view.set_edited(model.edited)
+ self.minimap.set_chromatogram(model.current())
+ self.minimap.set_highlights([])
+ self.file_opened.emit(str(self._path))
+ self._refresh_info(model.current())
+ self._sync_actions()
+ if self.reference_seq:
+ self._realign()
+ n = len(model.seq)
+ mean_q = sum(model.quality) / max(1, n)
+ message = tr("status.file", n=n, q=mean_q)
+ if restored:
+ message += " · " + tr("session.loaded")
+ self._say(message, 8000)
+ self.title_changed.emit(f"ab1kit — {self._path.name}" + ("*" if model.modified else ""))
+ return True
+
+ def _try_restore_session(self, model: EditableRead) -> bool:
+ sidecar = sessions.session_path(self._path)
+ data = sessions.load_session(sidecar)
+ if not data or data.get("source") != self._path.name:
+ return False
+ try:
+ model.restore_state(data)
+ except (KeyError, TypeError, ValueError):
+ return False
+ return True
+
+ def open_dialog(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ tr("menu.file.open"),
+ str(self._path.parent) if self._path else "",
+ "Sanger chromatograms (*.ab1);;All files (*)",
+ )
+ if path:
+ self.open_path(path)
+
+ # --------------------------------------------------------------- analysis
+
+ # --------------------------------------------------------------- analysis
+ def _reference_dialog(self) -> None:
+ text = ask_reference_text(self)
+ if text is None:
+ return
+ try:
+ sequence = parse_sequence(text)
+ except ValueError as exc:
+ QMessageBox.warning(self, tr("analysis.ref.title"), str(exc))
+ return
+ if not sequence:
+ return
+ self._set_reference_seq(sequence)
+
+ def _set_reference_seq(self, sequence: str) -> None:
+ self.reference_seq = sequence
+ if self.read_model is not None:
+ self._realign()
+ else:
+ self._say(tr("analysis.need_read"), 5000)
+
+ def _reference_overlay(self) -> tuple[str, dict[int, int]]:
+ return reference_overlay(self.alignment, self.reference_seq, len(self.read_model.seq))
+
+ def _displayed_diff_index(self, diff) -> int | None:
+ return displayed_diff_index(self.alignment, diff, len(self.read_model.seq))
+
+ def _clear_reference(self) -> None:
+ self.reference_seq = ""
+ self.alignment = None
+ self.chrom_view.set_reference(None)
+ self.diff_dock.clear()
+
+ def _realign(self) -> None:
+ if not (self.read_model and self.reference_seq):
+ return
+ try:
+ self.alignment = align_read("".join(self.read_model.seq), self.reference_seq)
+ except ValueError as exc:
+ QMessageBox.warning(self, tr("analysis.ref.title"), str(exc))
+ self._clear_reference()
+ return
+ letters, ref_to_read = self._reference_overlay()
+ self.chrom_view.set_reference(letters, ref_to_read)
+ chrom = self.read_model.current()
+ self.snp_calls = call_snps(
+ self.alignment, chrom,
+ min_q=self.config.snp_min_q, het_ratio=self.config.het_ratio,
+ )
+ self.diff_dock.fill(self.alignment, self.snp_calls, len(self.read_model.seq))
+ self.diff_dock.setVisible(bool(self.alignment.differences))
+ pct = f"{self.alignment.identity * 100:.1f}"
+ if self.alignment.differences:
+ message = tr(
+ "analysis.status",
+ n=len(self.alignment.differences),
+ pct=pct,
+ rstart=self.alignment.ref_start + 1,
+ rend=self.alignment.ref_end,
+ )
+ else:
+ message = tr("analysis.no_diffs")
+ if self.snp_calls:
+ counts = {k: sum(1 for c in self.snp_calls if c.kind == k)
+ for k in ("homozygous", "heterozygous", "low_quality")}
+ message += " · " + tr(
+ "snp.summary",
+ homo=counts["homozygous"],
+ het=counts["heterozygous"],
+ low=counts["low_quality"],
+ )
+ if self.alignment.alternatives > 1:
+ message += " · " + tr("analysis.ambiguous", n=self.alignment.alternatives)
+ self._say(message, 8000)
+
+ def _export_aligned(self) -> None:
+ if not (self.alignment and self.reference_seq and self.read_model):
+ return
+ path = self._export_target(
+ tr("menu.analysis.export_aligned"), ".aligned.fasta", "FASTA (*.fasta)"
+ )
+ if not path:
+ return
+ ref_aligned, read_aligned = gapped_sequences(self.alignment, self.reference_seq)
+ sample = self._export_id()
+ strand = "reverse" if self.alignment.strand == "-" else "forward"
+ content = (
+ export.to_fasta(f"{sample}_reference[{self.alignment.ref_start + 1}-{self.alignment.ref_end}]", ref_aligned)
+ + export.to_fasta(f"{sample}_read_{strand}", read_aligned)
+ )
+ export.write_text(path, content)
+ self._say(tr("export.done", path=path), 5000)
+
+ def _blast_submit(self) -> None:
+ if not self.read_model:
+ return
+ seq = "".join(self.read_model.seq)
+ try:
+ params = blast_api.build_request(seq)
+ except (OSError, RuntimeError, ValueError) as exc:
+ QMessageBox.warning(self, tr("menu.analysis.blast"), str(exc))
+ return
+
+ def done(rid: object) -> None:
+ QDesktopServices.openUrl(QUrl(blast_api.result_url(str(rid))))
+ self._say(tr("blast.sent", rid=rid), 10000)
+
+ # urllib cannot be interrupted: on cancel the late RID is dropped.
+ self._start_task(
+ lambda progress, is_cancelled: blast_api.submit(params, timeout=20),
+ done,
+ tr("menu.analysis.blast"),
+ key="blast",
+ )
+
+ def _verify_dialog(self) -> None:
+ if not (self.read_model and self.alignment and self.reference_seq):
+ self._say(tr("verify.need_reference"), 6000)
+ return
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ tr("menu.analysis.verify"),
+ str(self._path.parent) if self._path else "",
+ "Variants (*.vcf *.csv *.tsv *.txt);;All files (*)",
+ )
+ if not path:
+ return
+ try:
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ QMessageBox.warning(self, tr("verify.title"), str(exc))
+ return
+ self._verify_variants(text)
+
+ def _verify_variants(self, text: str) -> None:
+ try:
+ variants, warnings = parse_expected(text)
+ except ValueError as exc:
+ QMessageBox.warning(self, tr("verify.title"), str(exc))
+ return
+ if not variants:
+ QMessageBox.warning(self, tr("verify.title"), tr("search.none"))
+ return
+ self.variant_verdicts = verify_expected(
+ self.alignment,
+ self.read_model.current(),
+ self.reference_seq,
+ variants,
+ min_q=self.config.snp_min_q,
+ het_ratio=self.config.het_ratio,
+ )
+ self._fill_verify_table()
+ self.verify_dock.show()
+ self.chrom_view.set_snp_marks(
+ [(v.read_pos, v.verdict) for v in self.variant_verdicts if v.read_pos is not None]
+ )
+ counts = {k: sum(1 for v in self.variant_verdicts if v.verdict == k)
+ for k in ("confirmed", "heterozygous", "not_found", "no_data")}
+ message = tr(
+ "verify.summary",
+ n=len(self.variant_verdicts),
+ confirmed=counts["confirmed"],
+ het=counts["heterozygous"],
+ notfound=counts["not_found"],
+ nodata=counts["no_data"],
+ )
+ if warnings:
+ message += f" · {len(warnings)} ⚠"
+ self._say(message, 10000)
+
+ def _fill_verify_table(self) -> None:
+ self.verify_dock.fill(self.variant_verdicts)
+
+ def _save_verify_csv(self) -> None:
+ if not self.variant_verdicts:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("verify.save_csv"), "snp_report.csv", "CSV (*.csv)"
+ )
+ if path:
+ export.write_text(path, verdicts_csv(self.variant_verdicts))
+ self._say(tr("export.done", path=path), 5000)
+
+ def _save_verify_xlsx(self) -> None:
+ if not self.variant_verdicts:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("verify.save_xlsx"), "snp_report.xlsx", "Excel (*.xlsx)"
+ )
+ if not path:
+ return
+ try:
+ verdicts_xlsx(path, self.variant_verdicts, sample_id=self._export_id())
+ except RuntimeError as exc:
+ QMessageBox.warning(self, tr("verify.save_xlsx"), str(exc))
+ return
+ self._say(tr("export.done", path=path), 5000)
+
+ # ------------------------------------------------------------------ batch
+ def open_folder_dialog(self) -> None:
+ start = str(self._path.parent) if self._path else ""
+ folder = QFileDialog.getExistingDirectory(self, tr("menu.file.open_folder"), start)
+ if folder:
+ self._scan_folder(Path(folder))
+
+ def _scan_folder(self, folder: Path) -> None:
+ self.batch_dock.scan_folder(folder)
+
+ # ------------------------------------------------------------------ view
+ def _sync_from_view(self) -> None:
+ total = self.chrom_view._total_points()
+ visible = self.chrom_view._visible_points()
+ self.scrollbar.blockSignals(True)
+ self.scrollbar.setRange(0, max(0, int(total - visible)))
+ self.scrollbar.setPageStep(max(1, int(visible)))
+ self.scrollbar.setValue(int(self.chrom_view.offset))
+ self.scrollbar.blockSignals(False)
+ self.minimap.set_view(self.chrom_view.offset, visible, total)
+
+ def _refresh_legend(self, name: str) -> None:
+ """Small colour key for the quality bars (same thresholds everywhere)."""
+ colors = palette(name)
+ parts = [tr("tb.quality_legend")]
+ for q, text in ((0, "<20"), (20, "20–29"), (30, "≥30")):
+ parts.append(f'■ {text}')
+ self.quality_legend.setText(" ".join(parts))
+
+ def apply_theme(self, name: str) -> None:
+ status.set_theme(name)
+ colors = palette(name)
+ self._refresh_legend(name)
+ self.chrom_view.set_theme(colors)
+ self.minimap.set_theme(colors)
+ for base, box in self._channel_boxes.items():
+ c = colors["bases"][base]
+ box.setStyleSheet(
+ f"QCheckBox {{ color: {c}; font-weight: bold; padding: 0 4px; }}"
+ f"QCheckBox::indicator {{ border: 1px solid {c}; }}"
+ f"QCheckBox::indicator:checked {{ background-color: {c}; border-color: {c};"
+ f" {image_rule(tick_image(c))} }}"
+ )
+ for table in (self.diff_table, self.verify_table, self.batch_panel.table):
+ repaint_statuses(table)
+
+ # --------------------------------------------------------------- editing
+ def set_active(self, active: bool) -> None:
+ """Enable or disable all of the view's own actions (menus, toolbar,
+ shortcuts), so a hidden view cannot be edited by a stray shortcut."""
+ self._active = bool(active)
+ for action in self.findChildren(QAction, options=Qt.FindChildOption.FindDirectChildrenOnly):
+ action.setEnabled(self._active)
+ self._sync_actions()
+
+ def _sync_actions(self) -> None:
+ model = self.read_model
+ self.undo_action.setEnabled(self._active and bool(model and model.can_undo))
+ self.redo_action.setEnabled(self._active and bool(model and model.can_redo))
+
+ def refresh_read(self) -> None:
+ if self.read_model is None:
+ return
+ chrom = self.read_model.current()
+ self.chrom_view.set_chromatogram(chrom, keep_view=True)
+ self.chrom_view.set_edited(self.read_model.edited)
+ self.minimap.set_chromatogram(chrom)
+ self.minimap.set_highlights([])
+ self._search_ranges = []
+ self._search_pos = -1
+ self._refresh_info(chrom)
+ self._sync_actions()
+ self.title_changed.emit(
+ f"ab1kit — {self._path.name}" + ("*" if self.read_model.modified else "")
+ )
+ if self.reference_seq:
+ self._realign()
+
+ def _after_edit(self) -> None:
+ self.refresh_read()
+ self.read_edited.emit(self._path)
+
+ def apply_base_change(self, index: int, base: str) -> None:
+ if self.read_model:
+ self.read_model.set_base(index, base)
+ self._after_edit()
+
+ def apply_base_insert(self, index: int, base: str) -> None:
+ if self.read_model:
+ self.read_model.insert_base(index, base)
+ self._after_edit()
+
+ def apply_base_delete(self, index: int) -> None:
+ if self.read_model:
+ self.read_model.delete_base(index)
+ self._after_edit()
+
+ def apply_trim_range(self, start: int, end: int) -> None:
+ if not self.read_model:
+ return
+ try:
+ self.read_model.apply_trim(start, end)
+ except ValueError as exc:
+ QMessageBox.warning(self, tr("trim.title"), str(exc))
+ return
+ self.chrom_view.set_trim_region(None)
+ self._after_edit()
+ self._say(tr("trim.status", n=len(self.read_model.seq)))
+
+ def undo_read(self) -> None:
+ if self.read_model and self.read_model.undo():
+ self._after_edit()
+
+ def redo_read(self) -> None:
+ if self.read_model and self.read_model.redo():
+ self._after_edit()
+
+ def reverse_complement_read(self) -> None:
+ if self.read_model:
+ self.read_model.reverse_complement()
+ self._after_edit()
+
+ def _on_base_clicked(self, index: int, global_pos) -> None:
+ if self.read_model is None:
+ return
+ menu = QMenu(self)
+ change = menu.addMenu(tr("edit.change"))
+ for letter in IUPAC_LETTERS:
+ change.addAction(letter, lambda l=letter: self.apply_base_change(index, l))
+ insert_left = menu.addMenu(tr("edit.insert_left"))
+ for letter in INSERT_LETTERS:
+ insert_left.addAction(letter, lambda l=letter: self.apply_base_insert(index, l))
+ insert_right = menu.addMenu(tr("edit.insert_right"))
+ for letter in INSERT_LETTERS:
+ insert_right.addAction(letter, lambda l=letter: self.apply_base_insert(index + 1, l))
+ menu.addSeparator()
+ menu.addAction(tr("edit.delete"), lambda: self.apply_base_delete(index))
+ menu.exec(global_pos)
+
+ # ------------------------------------------------------------------ trim
+ def _open_trim_dialog(self) -> None:
+ if self.read_model is None or self.trim_dialog is not None:
+ return
+ start, end = trim_window(
+ self.read_model.quality, self.config.trim_window, self.config.trim_min_q
+ )
+ self.chrom_view.set_trim_region((start, end))
+ dialog = TrimDialog(
+ lambda: self.read_model.quality,
+ self.config.trim_window,
+ self.config.trim_min_q,
+ len(self.read_model.seq),
+ self,
+ )
+ self.trim_dialog = dialog
+ dialog.region_changed.connect(
+ lambda start, end: self.chrom_view.set_trim_region((start, end))
+ )
+ self.chrom_view.trim_range_changed.connect(dialog.set_region)
+ dialog.apply_requested.connect(self._apply_trim_from_dialog)
+ dialog.finished.connect(self._trim_dialog_closed)
+ dialog.show()
+
+ def _apply_trim_from_dialog(self, start: int, end: int) -> None:
+ if self.trim_dialog is not None:
+ self.config.trim_window = self.trim_dialog.window_value()
+ self.config.trim_min_q = self.trim_dialog.minq_value()
+ save_config(self.config)
+ self.apply_trim_range(start, end)
+
+ def _trim_dialog_closed(self) -> None:
+ dialog = self.trim_dialog
+ self.trim_dialog = None
+ if dialog is not None:
+ try:
+ self.chrom_view.trim_range_changed.disconnect(dialog.set_region)
+ except RuntimeError:
+ pass
+ self.chrom_view.set_trim_region(None)
+
+ # ---------------------------------------------------------------- search
+ def _search(self) -> None:
+ if self.chrom_view.chrom is None:
+ return
+ dialog = SearchDialog(self)
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return
+ pattern = dialog.pattern()
+ if not pattern:
+ return
+ seq = self.chrom_view.chrom.seq
+ try:
+ fwd = find_matches(pattern, seq)
+ rev = find_matches(pattern, seq, reverse=True) if dialog.want_reverse() else []
+ except ValueError as exc:
+ QMessageBox.warning(self, tr("search.title"), str(exc))
+ return
+ ranges = [(i, i + len(pattern)) for i in fwd + rev]
+ self.chrom_view.set_highlights(ranges)
+ self.minimap.set_highlights(ranges)
+ self._search_ranges = ranges
+ self._search_pos = -1
+ if ranges:
+ self._say(
+ tr("search.found", n=len(ranges), f=len(fwd), r=len(rev))
+ )
+ self._find_next()
+ else:
+ self._say(tr("search.none"))
+
+ def _find_next(self) -> None:
+ if not self._search_ranges:
+ return
+ self._search_pos = (self._search_pos + 1) % len(self._search_ranges)
+ self.chrom_view.goto_base(self._search_ranges[self._search_pos][0] + 1)
+
+ # ----------------------------------------------------------- goto and info
+ def _goto(self) -> None:
+ chrom = self.chrom_view.chrom
+ if chrom is None:
+ return
+ number, ok = QInputDialog.getInt(
+ self, tr("goto.title"), tr("goto.label"), 1, 1, len(chrom.seq)
+ )
+ if ok:
+ self.chrom_view.goto_base(number)
+
+ def _refresh_info(self, chrom: Chromatogram) -> None:
+ page = QWidget()
+ form = QFormLayout(page)
+ form.setContentsMargins(8, 8, 8, 8)
+
+ def row(label: str, value) -> None:
+ widget = QLabel(str(value))
+ widget.setWordWrap(True)
+ form.addRow(QLabel(label), widget)
+
+ row(tr("info.file"), self._path)
+ row(tr("info.length"), len(chrom.seq))
+ row(tr("info.mean_q"), f"{sum(chrom.quality) / max(1, len(chrom.quality)):.1f}")
+ row(tr("info.q20"), sum(1 for q in chrom.quality if q >= 20))
+ meta_keys = (
+ ("sample", "meta.sample"),
+ ("instrument", "meta.instrument"),
+ ("run_date", "meta.run_date"),
+ ("run_time", "meta.run_time"),
+ ("project", "meta.project"),
+ ("capillary", "meta.capillary"),
+ ("mobility", "meta.mobility"),
+ ("comment", "meta.comment"),
+ )
+ for key, label_key in meta_keys:
+ if key in chrom.metadata:
+ row(tr(label_key), chrom.metadata[key])
+ self.info_dock.setWidget(page)
+
+ # ------------------------------------------------------- export and sessions
+ def _export_target(self, caption: str, suffix: str, pattern: str) -> str | None:
+ default = (self._path.stem if self._path else "read") + suffix
+ path, _ = QFileDialog.getSaveFileName(self, caption, default, pattern)
+ return path or None
+
+ def _export_fasta(self) -> None:
+ if not self.read_model:
+ return
+ path = self._export_target(tr("menu.file.export_fasta"), ".fasta", "FASTA (*.fasta)")
+ if path:
+ export.write_text(path, export.to_fasta(self._export_id(), "".join(self.read_model.seq)))
+ self._say(tr("export.done", path=path), 5000)
+
+ def _export_fastq(self) -> None:
+ if not self.read_model:
+ return
+ path = self._export_target(tr("menu.file.export_fastq"), ".fastq", "FASTQ (*.fastq)")
+ if path:
+ content = export.to_fastq(
+ self._export_id(), "".join(self.read_model.seq), self.read_model.quality
+ )
+ export.write_text(path, content)
+ self._say(tr("export.done", path=path), 5000)
+
+ def _export_id(self) -> str:
+ return self.read_model.current().sample_id if self.read_model else "read"
+
+ def _export_png(self) -> None:
+ path = self._export_target(tr("menu.file.export_png"), ".png", "PNG (*.png)")
+ if path and self.chrom_view.grab().save(path):
+ self._say(tr("export.done", path=path), 5000)
+
+ def _export_pdf(self) -> None:
+ path = self._export_target(tr("menu.file.export_pdf"), ".pdf", "PDF (*.pdf)")
+ if not path:
+ return
+ pixmap = self.chrom_view.grab()
+ writer = QPdfWriter(path)
+ writer.setPageSize(QPageSize(QSizeF(pixmap.width(), pixmap.height()), QPageSize.Unit.Point))
+ writer.setPageMargins(QMarginsF(0, 0, 0, 0))
+ painter = QPainter(writer)
+ painter.drawPixmap(0, 0, writer.width(), writer.height(), pixmap)
+ painter.end()
+ self._say(tr("export.done", path=path), 5000)
+
+ def _copy_sequence(self) -> None:
+ if self.read_model:
+ QApplication.clipboard().setText("".join(self.read_model.seq))
+ self._say(
+ tr("copy.done", n=len(self.read_model.seq)), 3000
+ )
+
+ def _save_session(self) -> None:
+ if self.read_model is None or self._path is None:
+ return
+ sessions.save_session(
+ sessions.session_path(self._path),
+ {"source": self._path.name, **self.read_model.to_dict()},
+ )
+ self._say(tr("session.saved"), 3000)
+ self.read_edited.emit(self._path)
+
+ def autosave_session(self) -> None:
+ """Called by the shell on close: save the sidecar if modified, else delete a stale one."""
+ if self.read_model is not None and self._path is not None:
+ sidecar = sessions.session_path(self._path)
+ try:
+ if self.read_model.modified:
+ sessions.save_session(
+ sidecar, {"source": self._path.name, **self.read_model.to_dict()}
+ )
+ elif sidecar.exists():
+ sidecar.unlink()
+ except OSError:
+ pass
diff --git a/ui/read_view/verify_dock.py b/ui/read_view/verify_dock.py
new file mode 100644
index 0000000..584b57c
--- /dev/null
+++ b/ui/read_view/verify_dock.py
@@ -0,0 +1,94 @@
+"""Expected-SNP verification dock: verdict table with CSV/XLSX buttons."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QDockWidget,
+ QHBoxLayout,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from i18n import tr
+from ui.tables import configure_table, paint_status, set_full_text_tooltip
+
+
+VERIFY_VERDICT_STATUS = {
+ "confirmed": "ok",
+ "heterozygous": "mixed",
+ "not_found": "errors",
+ "ref_mismatch": "errors",
+ "low_quality": "incomplete",
+ "no_data": "no_data",
+ "unsupported": "no_data",
+}
+
+
+class VerifyDock(QDockWidget):
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(tr("verify.title"), parent)
+ self.verify_table = QTableWidget(0, 8)
+ self.verify_table.setHorizontalHeaderLabels(
+ [
+ tr("verify.col.name"),
+ tr("verify.col.pos"),
+ tr("verify.col.ref"),
+ tr("verify.col.alt"),
+ tr("verify.col.verdict"),
+ tr("verify.col.observed"),
+ tr("verify.col.q"),
+ tr("verify.col.note"),
+ ]
+ )
+ configure_table(self.verify_table)
+ self.verify_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.verify_table.setSortingEnabled(True)
+ buttons = QWidget()
+ row = QHBoxLayout(buttons)
+ self.csv_button = QPushButton(tr("verify.save_csv"))
+ self.xlsx_button = QPushButton(tr("verify.save_xlsx"))
+ row.addWidget(self.csv_button)
+ row.addWidget(self.xlsx_button)
+ row.addStretch(1)
+ container = QWidget()
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.verify_table)
+ layout.addWidget(buttons)
+ self.setWidget(container)
+
+ def fill(self, verdicts: list) -> None:
+ labels = {
+ "confirmed": tr("verify.confirmed"),
+ "heterozygous": tr("verify.heterozygous"),
+ "not_found": tr("verify.not_found"),
+ "no_data": tr("verify.no_data"),
+ "ref_mismatch": tr("verify.ref_mismatch"),
+ "low_quality": tr("verify.low_quality"),
+ "unsupported": tr("verify.unsupported"),
+ }
+ self.verify_table.setSortingEnabled(False)
+ self.verify_table.setRowCount(len(verdicts))
+ for row, v in enumerate(verdicts):
+ values = [
+ v.variant.name,
+ v.variant.position,
+ v.variant.ref,
+ v.variant.alt,
+ labels.get(v.verdict, v.verdict),
+ v.observed,
+ v.quality if v.quality is not None else "",
+ tr(f"verify.note.{v.note_code}", **v.note_args) if v.note_code else v.note,
+ ]
+ for col, value in enumerate(values):
+ item = QTableWidgetItem()
+ item.setData(Qt.ItemDataRole.DisplayRole, value)
+ set_full_text_tooltip(item)
+ if col == 4:
+ paint_status(item, VERIFY_VERDICT_STATUS.get(v.verdict, "no_data"))
+ self.verify_table.setItem(row, col, item)
+ self.verify_table.setSortingEnabled(True)
diff --git a/ui/status.py b/ui/status.py
new file mode 100644
index 0000000..53a43aa
--- /dev/null
+++ b/ui/status.py
@@ -0,0 +1,109 @@
+"""Shared visual language: status palette, quality colours, chips and icons.
+
+Status colours come from ``chrom_core.clone_report.STATUS_HEX`` (single source of
+truth, also used by the XLSX report). Quality colours come from the theme dicts;
+``quality_bucket`` is the one place that defines the Q thresholds.
+"""
+
+from __future__ import annotations
+
+import weakref
+
+from PySide6.QtCore import QRectF, Qt
+from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
+from PySide6.QtSvg import QSvgRenderer
+from PySide6.QtWidgets import QLabel
+
+from chrom_core.clone_report import STATUS_HEX
+from ui.theme import resource_dir, theme as palette
+
+Q_LOW = 20 # Q below this is red
+Q_HIGH = 30 # Q at or above this is green; in between is yellow
+
+_ICON_SIZES = (16, 20, 24, 32, 48)
+_current_theme = "light"
+_icon_cache: dict[tuple[str, str], QIcon] = {}
+_bound: list[tuple[weakref.ref, str]] = []
+
+
+def quality_bucket(q: int) -> str:
+ """Theme key for a phred score: ``low_q`` (<20), ``mid_q`` (20-29), ``high_q`` (>=30)."""
+ return "low_q" if q < Q_LOW else "mid_q" if q < Q_HIGH else "high_q"
+
+
+def quality_color(q: int, theme: str) -> QColor:
+ return QColor(palette(theme)[quality_bucket(q)])
+
+
+def status_color(verdict: str, theme: str) -> QColor:
+ """Fill colour of a verdict (``ok/errors/incomplete/mixed/no_data``); unknown -> no_data."""
+ light, dark = STATUS_HEX.get(verdict, STATUS_HEX["no_data"])
+ return QColor(dark if theme == "dark" else light)
+
+
+def status_text_color(verdict: str, theme: str) -> QColor:
+ """Readable text colour on top of ``status_color``."""
+ bg = status_color(verdict, theme)
+ luminance = 0.299 * bg.red() + 0.587 * bg.green() + 0.114 * bg.blue()
+ return QColor("#1a1a1a" if luminance > 150 else "#ffffff")
+
+
+def current_theme() -> str:
+ return _current_theme
+
+
+def status_chip(verdict: str, text: str) -> QLabel:
+ """Rounded pill in the current theme's status colours."""
+ chip = QLabel(text)
+ bg = status_color(verdict, _current_theme).name()
+ fg = status_text_color(verdict, _current_theme).name()
+ chip.setStyleSheet(
+ f"QLabel {{ background-color: {bg}; color: {fg}; border-radius: 9px;"
+ " padding: 1px 10px; font-weight: 600; }"
+ )
+ chip.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ return chip
+
+
+def icon(name: str) -> QIcon:
+ """Lucide icon ``ui/icons/.svg`` tinted with the current theme's text colour."""
+ key = (name, _current_theme)
+ cached = _icon_cache.get(key)
+ if cached is not None:
+ return cached
+ svg = (resource_dir() / "icons" / f"{name}.svg").read_text(encoding="utf-8")
+ svg = svg.replace("currentColor", palette(_current_theme)["text"])
+ renderer = QSvgRenderer(svg.encode("utf-8"))
+ result = QIcon()
+ for size in _ICON_SIZES:
+ pixmap = QPixmap(size, size)
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ renderer.render(painter, QRectF(0, 0, size, size))
+ painter.end()
+ result.addPixmap(pixmap)
+ _icon_cache[key] = result
+ return result
+
+
+def bind_icon(action: QAction, name: str) -> None:
+ """Give ``action`` the icon and keep it tinted when the theme changes."""
+ action.setIcon(icon(name))
+ _bound.append((weakref.ref(action), name))
+
+
+def set_theme(name: str) -> None:
+ """Switch the icon tint and re-icon every bound action."""
+ global _current_theme
+ _current_theme = "dark" if name == "dark" else "light"
+ alive = []
+ for ref, icon_name in _bound:
+ action = ref()
+ if action is None:
+ continue
+ try:
+ action.setIcon(icon(icon_name))
+ except RuntimeError: # underlying QAction already deleted
+ continue
+ alive.append((ref, icon_name))
+ _bound[:] = alive
diff --git a/ui/style.qss b/ui/style.qss
new file mode 100644
index 0000000..de122dd
--- /dev/null
+++ b/ui/style.qss
@@ -0,0 +1,66 @@
+/* ab1kit light theme. Explicit colours so the chrome never follows the OS mode. */
+QWidget { background-color: #f6f7f9; color: #1a1a1a; font-size: 13px; }
+QMainWindow, QDialog { background-color: #f6f7f9; }
+QToolTip { background-color: #ffffff; color: #1a1a1a; border: 1px solid #d0d5dd; padding: 4px 6px; }
+
+QMenuBar { background-color: #eceef2; border-bottom: 1px solid #d0d5dd; }
+QMenuBar::item { padding: 5px 10px; background: transparent; }
+QMenuBar::item:selected { background-color: #e1e5eb; border-radius: 4px; }
+QMenu { background-color: #ffffff; border: 1px solid #d0d5dd; padding: 4px; }
+QMenu::item { padding: 5px 24px 5px 24px; border-radius: 4px; }
+QMenu::item:selected { background-color: #2f6fed; color: #ffffff; }
+QMenu::item:disabled { color: #8a8f98; }
+QMenu::separator { height: 1px; background: #d0d5dd; margin: 4px 6px; }
+
+QToolBar { background-color: #eceef2; border: none; border-bottom: 1px solid #d0d5dd; padding: 3px; spacing: 2px; }
+QToolBar > QWidget { background: transparent; }
+QToolBar::separator { background-color: #d0d5dd; width: 1px; margin: 4px 6px; }
+QToolButton { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 6px; }
+QToolButton:hover { background-color: #e1e5eb; border-color: #d0d5dd; }
+QToolButton:pressed { background-color: #cfd5de; }
+QToolButton:disabled { color: #8a8f98; }
+
+QPushButton { background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 5px; padding: 5px 14px; }
+QPushButton:hover { background-color: #e1e5eb; }
+QPushButton:pressed { background-color: #cfd5de; }
+QPushButton:default { border-color: #2f6fed; }
+QPushButton:disabled { color: #8a8f98; }
+
+QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit {
+ background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 4px; padding: 3px 6px;
+ selection-background-color: #2f6fed; selection-color: #ffffff; }
+QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { border-color: #2f6fed; }
+QSpinBox, QDoubleSpinBox { padding-right: 18px; }
+QSpinBox::up-button, QDoubleSpinBox::up-button { subcontrol-origin: border; subcontrol-position: top right;
+ width: 16px; border: none; border-left: 1px solid #d0d5dd; border-top-right-radius: 4px; }
+QSpinBox::down-button, QDoubleSpinBox::down-button { subcontrol-origin: border; subcontrol-position: bottom right;
+ width: 16px; border: none; border-left: 1px solid #d0d5dd; border-bottom-right-radius: 4px; }
+QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
+QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background-color: #e1e5eb; }
+QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url(@UP@); width: 9px; height: 9px; }
+QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url(@DOWN@); width: 9px; height: 9px; }
+
+QDockWidget { titlebar-close-icon: none; }
+QDockWidget::title { background-color: #eceef2; border-bottom: 1px solid #d0d5dd; padding: 5px 8px; font-weight: 600; }
+
+QTableView, QTableWidget { background-color: #ffffff; alternate-background-color: #f6f7f9;
+ gridline-color: #d0d5dd; border: 1px solid #d0d5dd; selection-background-color: #2f6fed; selection-color: #ffffff; }
+QHeaderView::section { background-color: #eceef2; color: #1a1a1a; padding: 4px 8px; border: none;
+ border-right: 1px solid #d0d5dd; border-bottom: 1px solid #d0d5dd; font-weight: 600; }
+
+QScrollBar:vertical { background: transparent; width: 12px; margin: 0; }
+QScrollBar:horizontal { background: transparent; height: 12px; margin: 0; }
+QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #c3c8d1; border-radius: 5px; min-height: 24px; min-width: 24px; margin: 2px; }
+QScrollBar::handle:hover { background: #8a8f98; }
+QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; }
+QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
+
+QProgressBar { background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 4px; text-align: center; }
+QProgressBar::chunk { background-color: #2f6fed; border-radius: 3px; }
+QStatusBar { background-color: #eceef2; border-top: 1px solid #d0d5dd; }
+QStatusBar::item { border: none; }
+QCheckBox { spacing: 6px; background: transparent; }
+QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #8a8f98; border-radius: 3px; background-color: #ffffff; }
+QCheckBox::indicator:checked { background-color: #2f6fed; border-color: #2f6fed; image: url(@TICK@); }
+QCheckBox::indicator:disabled { border-color: #d0d5dd; }
+QLabel { background: transparent; }
diff --git a/ui/style_dark.qss b/ui/style_dark.qss
new file mode 100644
index 0000000..bdf680a
--- /dev/null
+++ b/ui/style_dark.qss
@@ -0,0 +1,66 @@
+/* ab1kit dark theme. Explicit colours so the chrome never follows the OS mode. */
+QWidget { background-color: #252526; color: #d4d4d4; font-size: 13px; }
+QMainWindow, QDialog { background-color: #252526; }
+QToolTip { background-color: #2d2d30; color: #d4d4d4; border: 1px solid #3f3f46; padding: 4px 6px; }
+
+QMenuBar { background-color: #2d2d30; border-bottom: 1px solid #3f3f46; }
+QMenuBar::item { padding: 5px 10px; background: transparent; }
+QMenuBar::item:selected { background-color: #3a3d41; border-radius: 4px; }
+QMenu { background-color: #2d2d30; border: 1px solid #3f3f46; padding: 4px; }
+QMenu::item { padding: 5px 24px 5px 24px; border-radius: 4px; }
+QMenu::item:selected { background-color: #2f6fed; color: #ffffff; }
+QMenu::item:disabled { color: #8a8a8a; }
+QMenu::separator { height: 1px; background: #3f3f46; margin: 4px 6px; }
+
+QToolBar { background-color: #2d2d30; border: none; border-bottom: 1px solid #3f3f46; padding: 3px; spacing: 2px; }
+QToolBar > QWidget { background: transparent; }
+QToolBar::separator { background-color: #3f3f46; width: 1px; margin: 4px 6px; }
+QToolButton { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 6px; }
+QToolButton:hover { background-color: #3a3d41; border-color: #3f3f46; }
+QToolButton:pressed { background-color: #4a4d52; }
+QToolButton:disabled { color: #8a8a8a; }
+
+QPushButton { background-color: #2d2d30; border: 1px solid #3f3f46; border-radius: 5px; padding: 5px 14px; }
+QPushButton:hover { background-color: #3a3d41; }
+QPushButton:pressed { background-color: #4a4d52; }
+QPushButton:default { border-color: #2f6fed; }
+QPushButton:disabled { color: #8a8a8a; }
+
+QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit {
+ background-color: #1e1e1e; border: 1px solid #3f3f46; border-radius: 4px; padding: 3px 6px;
+ selection-background-color: #2f6fed; selection-color: #ffffff; }
+QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { border-color: #2f6fed; }
+QSpinBox, QDoubleSpinBox { padding-right: 18px; }
+QSpinBox::up-button, QDoubleSpinBox::up-button { subcontrol-origin: border; subcontrol-position: top right;
+ width: 16px; border: none; border-left: 1px solid #3f3f46; border-top-right-radius: 4px; }
+QSpinBox::down-button, QDoubleSpinBox::down-button { subcontrol-origin: border; subcontrol-position: bottom right;
+ width: 16px; border: none; border-left: 1px solid #3f3f46; border-bottom-right-radius: 4px; }
+QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
+QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background-color: #3a3d41; }
+QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url(@UP@); width: 9px; height: 9px; }
+QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url(@DOWN@); width: 9px; height: 9px; }
+
+QDockWidget { titlebar-close-icon: none; }
+QDockWidget::title { background-color: #2d2d30; border-bottom: 1px solid #3f3f46; padding: 5px 8px; font-weight: 600; }
+
+QTableView, QTableWidget { background-color: #1e1e1e; alternate-background-color: #232324;
+ gridline-color: #3f3f46; border: 1px solid #3f3f46; selection-background-color: #2f6fed; selection-color: #ffffff; }
+QHeaderView::section { background-color: #2d2d30; color: #d4d4d4; padding: 4px 8px; border: none;
+ border-right: 1px solid #3f3f46; border-bottom: 1px solid #3f3f46; font-weight: 600; }
+
+QScrollBar:vertical { background: transparent; width: 12px; margin: 0; }
+QScrollBar:horizontal { background: transparent; height: 12px; margin: 0; }
+QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #4a4a50; border-radius: 5px; min-height: 24px; min-width: 24px; margin: 2px; }
+QScrollBar::handle:hover { background: #8a8a8a; }
+QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; }
+QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
+
+QProgressBar { background-color: #1e1e1e; border: 1px solid #3f3f46; border-radius: 4px; text-align: center; }
+QProgressBar::chunk { background-color: #2f6fed; border-radius: 3px; }
+QStatusBar { background-color: #2d2d30; border-top: 1px solid #3f3f46; }
+QStatusBar::item { border: none; }
+QCheckBox { spacing: 6px; background: transparent; }
+QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #8a8f98; border-radius: 3px; background-color: #1e1e1e; }
+QCheckBox::indicator:checked { background-color: #2f6fed; border-color: #2f6fed; image: url(@TICK@); }
+QCheckBox::indicator:disabled { border-color: #3f3f46; }
+QLabel { background: transparent; }
diff --git a/ui/tables.py b/ui/tables.py
new file mode 100644
index 0000000..9c891a0
--- /dev/null
+++ b/ui/tables.py
@@ -0,0 +1,52 @@
+"""Shared table behaviour: content-sized columns, stretching last column, tooltips."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem
+
+from ui.status import current_theme, status_color, status_text_color
+
+
+STATUS_ROLE = Qt.ItemDataRole.UserRole + 1
+TOOLTIP_MIN_CHARS = 40
+
+
+def configure_table(table: QTableWidget, stretch_column: int | None = None) -> None:
+ """Columns fit their content; ``stretch_column`` (default: the last) takes the rest."""
+ header = table.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
+ if stretch_column is None:
+ header.setStretchLastSection(True)
+ else:
+ header.setStretchLastSection(False)
+ header.setSectionResizeMode(stretch_column, QHeaderView.ResizeMode.Stretch)
+ header.setMinimumSectionSize(40)
+ table.verticalHeader().setVisible(False)
+ table.setAlternatingRowColors(True)
+ table.setWordWrap(False)
+
+
+def set_full_text_tooltip(item: QTableWidgetItem) -> None:
+ """Show the whole text on hover for long cells (a narrow column may elide them)."""
+ text = item.text()
+ if len(text) > TOOLTIP_MIN_CHARS:
+ item.setToolTip(text)
+
+
+def paint_status(item: QTableWidgetItem, verdict: str) -> None:
+ """Colour a cell with the shared status palette (current theme)."""
+ item.setData(STATUS_ROLE, verdict)
+ theme = current_theme()
+ item.setBackground(status_color(verdict, theme))
+ item.setForeground(status_text_color(verdict, theme))
+
+
+def repaint_statuses(table: QTableWidget) -> None:
+ """Re-apply the status palette (after a theme switch) to every status cell."""
+ for row in range(table.rowCount()):
+ for col in range(table.columnCount()):
+ item = table.item(row, col)
+ verdict = item.data(STATUS_ROLE) if item is not None else None
+ if verdict:
+ paint_status(item, verdict)
diff --git a/ui/tasks.py b/ui/tasks.py
new file mode 100644
index 0000000..78630fb
--- /dev/null
+++ b/ui/tasks.py
@@ -0,0 +1,113 @@
+"""Background tasks: run a function in the global thread pool with progress,
+cancellation and GUI-thread signal delivery."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from concurrent.futures import CancelledError
+import threading
+
+from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal, Slot
+
+ProgressFn = Callable[[int, int], None]
+CancelFn = Callable[[], bool]
+TaskFn = Callable[[ProgressFn, CancelFn], object]
+
+# Strong references to running tasks (and, through them, their runnables) so
+# they are not garbage-collected mid-run.
+_running: set[Task] = set()
+
+
+class Task(QObject):
+ """Handle of a background task. Public signals are emitted in the GUI thread.
+
+ Exactly one of ``finished`` / ``failed`` / ``cancelled`` is emitted. After
+ ``cancel()`` the task always ends with ``cancelled``: a result that arrives
+ late (an uninterruptible call such as urllib) is dropped.
+ """
+
+ progress = Signal(int, int)
+ finished = Signal(object)
+ failed = Signal(str)
+ cancelled = Signal()
+
+ # Worker -> GUI hand-off. Connected to a slot of this QObject (which lives
+ # in the GUI thread), so emission from the worker is queued.
+ _event = Signal(str, object)
+
+ def __init__(self, fn: TaskFn, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._fn = fn
+ self._cancel_event = threading.Event()
+ self._done = False
+ self._event.connect(self._dispatch)
+ self._runnable = _Runnable(self)
+
+ def cancel(self) -> None:
+ """Request cancellation (call from the GUI thread). ``cancelled`` is
+ emitted at once; whatever the worker produces later is ignored."""
+ self._cancel_event.set()
+ if not self._done:
+ self._done = True
+ _running.discard(self)
+ self.cancelled.emit()
+
+ def is_cancelled(self) -> bool:
+ return self._cancel_event.is_set()
+
+ def _start(self) -> None:
+ _running.add(self)
+ QThreadPool.globalInstance().start(self._runnable)
+
+ @Slot(str, object)
+ def _dispatch(self, kind: str, payload: object) -> None:
+ if self._done:
+ return
+ if kind == "progress":
+ if not self.is_cancelled():
+ self.progress.emit(*payload) # type: ignore[misc]
+ return
+ self._done = True
+ _running.discard(self)
+ if kind == "cancelled":
+ self.cancelled.emit()
+ elif kind == "failed":
+ self.failed.emit(str(payload))
+ else:
+ self.finished.emit(payload)
+
+
+class _Runnable(QRunnable):
+ def __init__(self, task: Task) -> None:
+ super().__init__()
+ self.setAutoDelete(False)
+ self._task = task
+
+ def run(self) -> None:
+ task = self._task
+ def emit(kind: str, payload: object) -> None:
+ try:
+ task._event.emit(kind, payload)
+ except RuntimeError: # Task was destroyed with its parent
+ _running.discard(task)
+
+ try:
+ result = task._fn(
+ lambda done, total: emit("progress", (int(done), int(total))),
+ task.is_cancelled,
+ )
+ except CancelledError:
+ emit("cancelled", None)
+ except BaseException as exc: # noqa: BLE001 - reported through ``failed``
+ emit("failed", str(exc) or type(exc).__name__)
+ else:
+ emit("finished", result)
+ finally:
+ _running.discard(task)
+
+
+def run_task(fn: TaskFn, parent: QObject) -> Task:
+ """Run ``fn(progress, is_cancelled)`` in the global thread pool."""
+ task = Task(fn, parent)
+ task._start()
+ return task
diff --git a/ui/theme.py b/ui/theme.py
index 0cb7602..6977b86 100644
--- a/ui/theme.py
+++ b/ui/theme.py
@@ -2,6 +2,13 @@
from __future__ import annotations
+import sys
+import tempfile
+from functools import lru_cache
+from pathlib import Path
+
+from PySide6.QtGui import QIcon
+
LIGHT = {
"bg": "#ffffff",
"panel": "#f3f3f3",
@@ -15,6 +22,14 @@
"low_q": "#e02020",
"mid_q": "#e0a000",
"high_q": "#1e9e1e",
+ # construct matrix: coverage on both strands / one strand, empty track,
+ # region frame, features (CDS/gene and others)
+ "cov_both": "#4caf50",
+ "cov_one": "#b5e0b7",
+ "track": "#e8e8e8",
+ "region": "#2563eb",
+ "feature_cds": "#2e8b57",
+ "feature": "#7a8290",
}
DARK = {
@@ -30,6 +45,12 @@
"low_q": "#ff5252",
"mid_q": "#e0a000",
"high_q": "#37d437",
+ "cov_both": "#43a047",
+ "cov_one": "#3d6e41",
+ "track": "#333333",
+ "region": "#5b8def",
+ "feature_cds": "#2e7d4f",
+ "feature": "#5c6370",
}
@@ -37,20 +58,60 @@ def theme(name: str) -> dict:
return DARK if name == "dark" else LIGHT
+def resource_dir() -> Path:
+ """Folder holding style*.qss and icons/: next to this module, or under
+ ``sys._MEIPASS/ui`` in a PyInstaller bundle (see ab1kit.spec datas)."""
+ frozen = getattr(sys, "_MEIPASS", None)
+ return Path(frozen) / "ui" if frozen else Path(__file__).resolve().parent
+
+
+def app_icon() -> QIcon:
+ """The application icon (``ui/icons/app/ab1kit.png``), frozen-safe."""
+ return QIcon(str(resource_dir() / "icons" / "app" / "ab1kit.png"))
+
+
+@lru_cache(maxsize=2)
+def _read_qss(dark: bool) -> str:
+ name = "style_dark.qss" if dark else "style.qss"
+ return (resource_dir() / name).read_text(encoding="utf-8")
+
+
+def tinted_icon(name: str, color: str, stroke: str = "2") -> str:
+ """Path of icon ``name`` (``ui/icons/.svg``) drawn in ``color``, for QSS
+ ``image: url(...)``. Written once per icon and colour to the temp folder;
+ ``""`` when that folder cannot be written (see ``image_rule``)."""
+ folder = Path(tempfile.gettempdir()) / "ab1kit_icons"
+ target = folder / f"{name}_{color.lstrip('#')}_{stroke}.svg"
+ try:
+ folder.mkdir(exist_ok=True)
+ if not target.exists():
+ svg = (resource_dir() / "icons" / f"{name}.svg").read_text(encoding="utf-8")
+ target.write_text(svg.replace("currentColor", color)
+ .replace('stroke-width="2"', f'stroke-width="{stroke}"'),
+ encoding="utf-8")
+ except OSError:
+ return ""
+ return target.as_posix()
+
+
+def image_rule(path: str) -> str:
+ """QSS ``image: url(path);``, or nothing for a missing (``""``) image."""
+ return f"image: url({path});" if path else ""
+
+
+def tick_image(background: str) -> str:
+ """Path of a tick (Lucide ``check``) SVG readable on ``background``."""
+ r, g, b = (int(background.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
+ tick = "#1a1a1a" if 0.299 * r + 0.587 * g + 0.114 * b > 150 else "#ffffff"
+ return tinted_icon("check", tick, "3")
+
+
def app_qss(dark: bool) -> str:
- """Application stylesheet. Both themes set explicit colors so the chrome
- never depends on the OS light/dark mode (native dark Windows makes our
- fixed light-theme text colors unreadable)."""
- if dark:
- return (
- "QWidget { background-color: #252526; color: #d4d4d4; }"
- "QMenuBar, QMenu, QToolBar { background-color: #2d2d30; }"
- "QLineEdit, QSpinBox { background-color: #1e1e1e; }"
- "QDockWidget::title { background-color: #2d2d30; }"
- )
- return (
- "QWidget { background-color: #f3f3f3; color: #1a1a1a; }"
- "QMenuBar, QMenu, QToolBar { background-color: #e9e9e9; }"
- "QLineEdit, QSpinBox { background-color: #ffffff; }"
- "QDockWidget::title { background-color: #e9e9e9; }"
- )
+ """Application stylesheet (ui/style.qss or ui/style_dark.qss). Both themes set
+ explicit colours so the chrome never depends on the OS light/dark mode."""
+ arrow = theme("dark" if dark else "light")["text"]
+ return (_read_qss(dark)
+ .replace("image: url(@TICK@);", image_rule(tick_image("#2f6fed")))
+ .replace("image: url(@UP@);", image_rule(tinted_icon("chevron-up", arrow, "3")))
+ .replace("image: url(@DOWN@);",
+ image_rule(tinted_icon("chevron-down", arrow, "3"))))
]