twinshot 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61129562a7595873fe7d577f08d2d98b5a7d608a4c021c1b76eb3b66533deca8
4
+ data.tar.gz: 1c78a3f84ad0edcffe46144084a959d7db5eadd0886baa9d348f72d8d89fb2d7
5
+ SHA512:
6
+ metadata.gz: f71390ff6a0309a3d7f2008cb44cb96a902494275bbfc0993f0f3ff2cc492cf3f75fddffb6eecaf7b11fdc6ea23a892a892ed0324a364d2bd2f412bc96a21d07
7
+ data.tar.gz: 3984e3f511e04341b7ddaaf3089c9281c415988fa73590a248ca2d12a38ef080ffd934439b0a3d63a835eadb8965851833c379aeba88c77c5d9aaa4bc9e8c4ce
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Oleg Orlov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/PLAN.md ADDED
@@ -0,0 +1,107 @@
1
+ # Twinshot — план реализации
2
+
3
+ Гем для 1:1-парити двух деревьев артефактов: оракул (verified) vs текущее
4
+ состояние. Структура папок свободная, но должна совпасть. Любой файл без
5
+ адаптера — фейл. Отчёт — самодостаточный HTML с 1:1-галереей (как текущий
6
+ `artifacts/compare/report.html` в capybara-tests) + поиск, фильтры, режимы
7
+ сравнения картинок. В хорошем случае команды генерации не нужны — compare
8
+ берёт две готовые папки.
9
+
10
+ ## Конфиг (`twinshot.yml`, минимальный)
11
+
12
+ ```yaml
13
+ oracle:
14
+ path: artifacts/rails
15
+ generate: bin/record # опционально
16
+ current:
17
+ path: artifacts/crails
18
+ generate: bin/record --crails # опционально
19
+ png:
20
+ max_diff_ratio: 0.002 # опционально, дефолт 0.002
21
+ ```
22
+
23
+ Отчёт всегда пишется в `<dirname(config)>/twinshot-report/`
24
+ (`report_for_human.html` + `report_for_agent.md` + `summary.json` +
25
+ `diffs/*`). Не настраивается.
26
+
27
+ ## Структура кода
28
+
29
+ ```
30
+ lib/twinshot.rb — точка входа, requires
31
+ lib/twinshot/cli.rb — OptionParser: help / generate / compare
32
+ lib/twinshot/config.rb — загрузка + валидация twinshot.yml
33
+ lib/twinshot/tree.rb — скан дерева, сверка структуры
34
+ lib/twinshot/adapters.rb — реестр адаптеров по расширению
35
+ lib/twinshot/adapters/binary.rb — sha256, побайтово
36
+ lib/twinshot/adapters/txt.rb — построчный diff (diff-lcs)
37
+ lib/twinshot/adapters/html.rb — по структуре: nokogiri-канонизация → построчный diff
38
+ lib/twinshot/adapters/png.rb — байты fast-path → попиксельно (chunky_png) + diff-картинка
39
+ lib/twinshot/compare.rb — оркестратор: структура → файлы в пуле форк-воркеров
40
+ lib/twinshot/report.rb — HTML-отчёт (инлайн CSS/JS, без зависимостей)
41
+ exe/twinshot
42
+ ```
43
+
44
+ ## Правила сравнения
45
+
46
+ 1. **Структура**: дерево файлов `current` обязано совпадать с `oracle` 1:1.
47
+ Лишний файл / недостающий / пустая папка в стороне — фейл-запись в отчёт.
48
+ 2. **Файлы**: адаптер по расширению. Нет адаптера → фейл («no adapter for .xyz»).
49
+ 3. PNG: байты равны → pass; иначе попиксельно, pass если diff-доля ≤
50
+ `max_diff_ratio`; при несовпадении размеров — фейл с указанием размеров.
51
+ Для провала генерится `diffs/<rel>.diff.png` (изменённые пиксели красным).
52
+ 4. HTML: канонизация DOM (nokogiri: убрать комментарии, схлопнуть пробелы в
53
+ текст-нодах, отсортировать атрибуты, сериализовать построчно) → построчный
54
+ diff. Pass = 0 расхождений.
55
+ 5. TXT: построчный diff (diff-lcs). Pass = 0 расхождений.
56
+ 6. Бинарные (зарегистрированные как binary): sha256.
57
+
58
+ ## Параллелизм
59
+
60
+ Структура — синхронно (дёшево). Файлы — пул форк-воркеров (`--jobs N`,
61
+ дефолт = число ядер, кап 8): round-robin срезы, результаты пишутся воркерами
62
+ в tmpdir как JSON, родитель мёржит и сортирует. `--jobs 1` — последовательно.
63
+ Форки вместо Ractors: совместимо с 3.2+ и без GVL-проблем chunky_png.
64
+
65
+ ## CLI
66
+
67
+ ```
68
+ twinshot help [COMMAND] — справка
69
+ twinshot generate oracle|current — запускает generate-команду из конфига (chdir = dirname конфига)
70
+ twinshot compare — сравнивает две папки, пишет отчёт
71
+ -c, --config PATH (default: twinshot.yml)
72
+ -j, --jobs N
73
+ --json — machine-итог в stdout
74
+ -V, --version
75
+ ```
76
+
77
+ Exit-коды: 0 — всё сошлось; 1 — есть расхождения; 2 — ошибка конфига/usage.
78
+
79
+ ## Отчёт
80
+
81
+ - Группировка по папкам (заголовок группы + счётчик), внутри провалы первыми.
82
+ - Sticky-шапка: итоги, поиск по подстроке имени, фильтры все/провалы/прошедшие,
83
+ режимы картинок: рядом / слайдер / мигание.
84
+ - Sticky-имя файла над каждой парой (как в текущем отчёте capybara-tests).
85
+ - Клавиши n/p — прыжок к следующему/предыдущему видимому шоту.
86
+ - Текстовые диффы инлайном (`+/-` строки, кап 400 строк).
87
+ - Всё инлайн (CSS/JS), внешних ассетов нет; картинки — относительные пути.
88
+
89
+ ## Тесты (minitest)
90
+
91
+ - config: загрузка, дефолты, ошибки валидации
92
+ - tree: совпадение/лишний/недостающий файл
93
+ - adapters: binary/txt/png (мини-PNG генерим chunky_png)/html-канонизация/unknown ext
94
+ - compare: e2e на tmp-деревьях, включая fork-пул (jobs 1 и 2)
95
+ - cli: help, unknown command, exit-коды
96
+ - report: генерится, содержит маркеры групп/шотов
97
+
98
+ ## Интеграция
99
+
100
+ `capybara-tests/twinshot.yml` → `oracle: artifacts/rails`, `current: artifacts/crails`,
101
+ запуск `twinshot compare`, проверка exit-кода, отчёта и summary.json на живых данных
102
+ (127 файлов rails, 129 crails — есть реальный лишний файл для проверки структуры).
103
+
104
+ ## Готовность
105
+
106
+ rake test зелёный, rake build собирается, на capybara-артефактах compare
107
+ возвращает ненулевой exit (лишний файл), отчёт открывается и работает.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="Twinshot logo" width="256">
3
+ </p>
4
+
5
+ # Twinshot — Make "clean room" implementation of every program in the earth!
6
+
7
+ Save and compare screenshots of the functionality of other applications you
8
+ want to copy end to end: a proven **oracle** tree against the **current**
9
+ state, 1:1 parity between two folders of snapshots (screenshots, HTML, text).
10
+
11
+ ## Use cases
12
+
13
+ - **Pixel-perfect design from mockups** — export every screen from Figma or a
14
+ sketch into `oracle/`, screenshot your implementation into `current/`, and
15
+ twinshot verifies it pixel-by-pixel: a tolerance (`png.max_diff_ratio`) for
16
+ antialiasing, a red diff image and a heat-zone overlay showing exactly where
17
+ you drifted. "Matches the design" stops being an opinion.
18
+ - **Clean-room reimplementation** — rebuilding an app on a new stack
19
+ (the gem's origin: a Rails app rewritten in crails): snapshot both versions
20
+ and drive to 1:1 parity, screen by screen.
21
+ - **Upgrade/refactor safety net** — before a framework or Ruby major upgrade,
22
+ snapshot pages before and after; zero diff proves the UI didn't move.
23
+ - **AI agent feedback loop** — an agent implements, `twinshot compare` writes
24
+ `report_for_agent.md` designed to be fed back to the model; exit codes
25
+ fit CI and `git bisect run`.
26
+ - **Golden-master testing** — not just pixels: rendered HTML compared by
27
+ canonicalized DOM structure, JSON/CSV/logs by line diff — catch structural
28
+ regressions with no flaky selectors.
29
+ - **Cross-browser parity** — the same pages screenshotted in different
30
+ browsers/OSes, compared as trees.
31
+
32
+ ## How it works
33
+
34
+ 1. **Structure**: the `current` file tree must match `oracle` 1:1. An extra
35
+ or missing file is a failure.
36
+ 2. **Files**: every file is compared by its extension's adapter. No adapter —
37
+ failure (no fallbacks).
38
+ 3. **Report**: a self-contained HTML gallery (`report_for_human.html`) +
39
+ a markdown report for LLM agents (`report_for_agent.md`) + `summary.json`.
40
+
41
+ Adapters:
42
+
43
+ | Type | How it compares |
44
+ |-------|-----------------------------------------------------------------------|
45
+ | `png` | bytes fast-path → pixel-by-pixel; threshold `png.max_diff_ratio`; diff image (red) + purple heat-zone overlay |
46
+ | `html`| by DOM structure: canonicalization (nokogiri: no comments, whitespace collapsed, attributes sorted) → line diff |
47
+ | `xml` | by structure like html, via the XML parser (case-sensitive names): canonicalization → line diff (`xml`, `svg`) |
48
+ | `txt` | line diff (`txt`, `md`, `log`, `json`, `csv`) |
49
+ | `bin` | byte-wise SHA-256 (`bin`, `dat`) |
50
+
51
+ ## Config — `twinshot.yml`
52
+
53
+ ```yaml
54
+ oracle:
55
+ path: artifacts/rails
56
+ generate: bin/record # optional
57
+
58
+ current:
59
+ path: artifacts/crails
60
+ generate: bin/record --crails # optional
61
+
62
+ png:
63
+ max_diff_ratio: 0.002 # optional, default 0.002 (applies to all groups)
64
+
65
+ soft_check: # optional: exact tree-relative paths or globs
66
+ - dashboard/header.png # diffs tolerated (clocks, dates…); missing/
67
+ - code/capybara-tests/* # extra files on one side — all warnings,
68
+ # exit code stays 0
69
+
70
+ # optional: named groups — additional tree pairs with the same fields
71
+ # (path + generate per side). Any names work.
72
+ groups:
73
+ dogs:
74
+ oracle:
75
+ path: artifacts/dogs-rails
76
+ generate: bin/record --dogs
77
+ current:
78
+ path: artifacts/dogs-crails
79
+ ```
80
+
81
+ The top-level `oracle`/`current` pair is the unnamed **default group**;
82
+ `compare` runs every group in order (or just one — `compare dogs`), and
83
+ `groups` in the yaml adds named ones (the name `default` is reserved).
84
+ There is nothing special about an "API-style" group: if you need to compare
85
+ not just the output but the input too, simply put the input artifacts into
86
+ the group's trees — they are compared by the same adapters.
87
+
88
+ Every group writes its report into its own folder:
89
+ `<config dir>/twinshot-report/<group>/` — the default group reports into
90
+ `twinshot-report/default/`. Each folder holds the same layout as before:
91
+ `report_for_human.html`, `report_for_agent.md`, `summary.json`, `diffs/`.
92
+ Not configurable.
93
+
94
+ Entries in `soft_check` (the same list for every group) are exact
95
+ tree-relative paths or glob patterns. They are snapshots whose content is
96
+ allowed to differ — typically screenshots with a clock or other dynamic
97
+ content — and files that may legitimately exist on only one side of the
98
+ trees. Globs match nested files and dotfiles (`*` crosses `/`):
99
+ `code/capybara-tests/*` covers the entire subtree. Everything a listed path
100
+ produces is a **warning** (`!` in yellow in the run output): a content diff
101
+ when it exists on both sides, or a missing/extra structure result when it
102
+ exists on only one. Warnings are fully visible in both reports and counted
103
+ separately, but they never change the exit code.
104
+
105
+ ## Commands
106
+
107
+ ```
108
+ twinshot compare [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open] # compare, write reports
109
+ twinshot generate [GROUP|all|SIDE][:oracle|current] # run generate commands
110
+ twinshot review [GROUP|all] [compare flags] # regenerate, then compare
111
+ twinshot help [COMMAND] # help
112
+ ```
113
+
114
+ - `compare` in the happy case needs no generation — it takes the folders as
115
+ they are. `GROUP` (a group name, `all` by default) selects what runs:
116
+ `compare dogs` compares only that group and writes only its report;
117
+ `compare all` — every configured group.
118
+ - `-j N` — worker pool (forks, default: core count, capped at 8): the
119
+ structure is checked synchronously, files in parallel.
120
+ - After `compare`, the first compared group's report opens in a browser: on
121
+ a tty — automatically, `--open` — force it (even with `--json`),
122
+ `--no-open` — disable. Cross-platform "open" (`Twinshot::Opener`): darwin
123
+ — `open`, windows — `start` via `cmd /c`, linux/BSD — `xdg-open`; a failed
124
+ launch does not affect the exit code.
125
+ - `generate` takes a `GROUP:SIDE` selector: `generate dogs:oracle` — one
126
+ tree of a named group; a bare side (`generate oracle`, same as
127
+ `default:oracle`) — the default group's tree (no namespace means
128
+ default); `generate dogs` — both trees of one group; `generate all` /
129
+ `all:oracle` / no selector — every configured tree of every group (one
130
+ side or both), announced as a `Generate:` list first. A failing command
131
+ doesn't stop the rest, but the exit code reports it. The words `all`,
132
+ `oracle` and `current` are reserved and cannot be group names.
133
+ - `review` is generate + compare in one run: both sides of the selected
134
+ groups (`review dogs`, `all` by default) regenerate first — a tree
135
+ without a generate command is taken as it is — then the compare runs and
136
+ writes the same reports, with the same options as `compare`. A failed
137
+ generate command fails the run (exit 1) without comparing.
138
+ - Exit codes: `0` — everything matched, `1` — differences found (fits
139
+ `git bisect run` and CI), `2` — config/usage error. `soft_check` warnings
140
+ never affect the exit code.
141
+
142
+ ## Report
143
+
144
+ Two files per group folder:
145
+
146
+ - `report_for_human.html` — the visual report (opened in a browser after
147
+ `compare`):
148
+ - flat gallery ordered by folder (one card per file, no folder headings);
149
+ - substring search, filters all/failed/warned/passed;
150
+ - image modes: **side-by-side** (50/50), **slider** (overlay with a handle),
151
+ **blink** (oracle/current alternating);
152
+ - **zones** — a purple overlay over images: the picture is split into 32×32 px
153
+ squares, a square's opacity grows with the share of differing pixels (`heat`
154
+ metadata in `summary.json`, a toolbar toggle);
155
+ - inline text diffs (`+/-` lines);
156
+ - `n`/`p` keys — navigate shots, `Esc` — reset search.
157
+ - `report_for_agent.md` — the same results as markdown to feed to a model:
158
+ every failed file gets a section with its metadata (absolute oracle/current
159
+ paths, kind, detail, ratio, diff-image path) plus the divergence snippet as a
160
+ unified-style `diff` fence — the same line diffs the human report renders
161
+ inline (for `html` that is the canonicalized DOM line diff, so the snippet
162
+ shows what structurally differs, not raw source noise). Passed files are a
163
+ plain list at the bottom.
164
+
165
+ ## Install and development
166
+
167
+ ```sh
168
+ bundle exec rake test && bundle exec rake build # pkg/twinshot-X.Y.Z.gem
169
+ ```
170
+
171
+ Ruby >= 3.2 (developed on 4.0). Dependencies: `ruby-vips` (native `libvips`),
172
+ `colorize`, `diff-lcs`, `nokogiri`.
173
+
174
+ A run prints live progress: a banner, a colored per-file verdict (✓ pass /
175
+ `!` warn / ✗ fail) in worker arrival order, and a final stats block by type.
176
+ When anything failed, a copyable `FAILS:` list of every failed file follows
177
+ the stats block; on an otherwise green run with warnings, a `WARNS:` list of
178
+ the `soft_check` files instead. Colors on a tty only; `--json` disables
179
+ progress.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/exe/twinshot ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "twinshot"
5
+
6
+ exit(Twinshot::CLI.new(ARGV).call)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Twinshot
6
+ # Adapter implementations (the contract lives in adapters.rb).
7
+ module Adapters
8
+ # Byte-for-byte comparison via SHA-256.
9
+ module Binary
10
+ module_function
11
+
12
+ def call(_rel, oracle_file, current_file, _ctx)
13
+ same = Digest::SHA256.file(oracle_file).hexdigest == Digest::SHA256.file(current_file).hexdigest
14
+ { "kind" => "binary", "status" => same ? "pass" : "fail",
15
+ "detail" => same ? "bytes equal" : "bytes differ" }
16
+ end
17
+ end
18
+
19
+ register(Binary, "bin", "dat")
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Twinshot
6
+ # Adapter implementations (the contract lives in adapters.rb).
7
+ module Adapters
8
+ # HTML compares by DOM structure, not raw text: canonical serialization
9
+ # (comments dropped, whitespace-only text nodes dropped, runs of whitespace
10
+ # in text squashed to one space, attributes sorted by name) followed by a
11
+ # line diff over that canonical form. Pass when structures are identical.
12
+ module Html
13
+ module_function
14
+
15
+ def call(_rel, oracle_file, current_file, _ctx)
16
+ Adapters.structural_result("html", canonical_lines(oracle_file), canonical_lines(current_file))
17
+ end
18
+
19
+ def canonical_lines(path)
20
+ doc = Nokogiri::HTML4(File.read(path, encoding: "UTF-8"))
21
+ Adapters.canonical_dom_lines(doc, downcase_names: true)
22
+ end
23
+ end
24
+
25
+ register(Html, "html", "htm")
26
+ end
27
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "vips"
6
+
7
+ # twinshot parallelizes across files with its own worker pool, so keep each
8
+ # libvips call single-threaded — otherwise every forked worker would spin up
9
+ # its own nproc-sized libvips threadpool and oversubscribe the machine.
10
+ Vips.vips_concurrency_set(1)
11
+
12
+ module Twinshot
13
+ # Adapter implementations (the contract lives in adapters.rb).
14
+ module Adapters
15
+ # PNG: bytes fast-path, then pixel-by-pixel via libvips (decode, diff mask
16
+ # and diff image are all native C — a pure-ruby pixel walk was the old
17
+ # bottleneck). Pass when the share of differing pixels is within
18
+ # ctx.max_diff_ratio. On fail writes a diff image (differing pixels painted
19
+ # red over the current snapshot) into the report dir. Whenever any pixel
20
+ # differs, also emits "heat" — a grid metadata for the report's purple
21
+ # overlay (see #heat_grid).
22
+ module Png
23
+ module_function
24
+
25
+ HEAT_CELL = 32
26
+ RED = [255, 0, 0, 255].freeze
27
+
28
+ def call(rel, oracle_file, current_file, ctx)
29
+ if Digest::SHA256.file(oracle_file).hexdigest == Digest::SHA256.file(current_file).hexdigest
30
+ return { "kind" => "png", "image" => true, "status" => "pass", "detail" => "bytes equal" }
31
+ end
32
+
33
+ a = decode(oracle_file)
34
+ b = decode(current_file)
35
+ return a if a.is_a?(Hash)
36
+ return b if b.is_a?(Hash)
37
+ if a.width != b.width || a.height != b.height
38
+ return { "kind" => "png", "image" => true, "status" => "fail",
39
+ "detail" => "size mismatch #{a.width}x#{a.height} vs #{b.width}x#{b.height}" }
40
+ end
41
+
42
+ mask = diff_mask(a, b).copy_memory
43
+ total = a.width * a.height
44
+ diff_count = (mask.avg * total / 255.0).round
45
+ ratio = diff_count.fdiv(total)
46
+ result = { "kind" => "png", "image" => true,
47
+ "status" => ratio <= ctx.max_diff_ratio ? "pass" : "fail",
48
+ "detail" => format("%.3f%% px differ (%d/%d)", ratio * 100, diff_count, total),
49
+ "ratio" => ratio }
50
+ if diff_count.positive?
51
+ result["heat"] = heat_grid(a, mask)
52
+ result["diff_image"] = write_diff_image(rel, b, mask, ctx) if result["status"] == "fail"
53
+ end
54
+ result
55
+ end
56
+
57
+ def decode(path)
58
+ Vips::Image.new_from_file(path, access: :random)
59
+ rescue Vips::Error => e
60
+ { "kind" => "png", "image" => true, "status" => "fail", "detail" => "decode error: #{e.message}" }
61
+ end
62
+
63
+ # Single-band 0/255 mask: 255 where any band of the two images differs.
64
+ # Both sides are normalized to rgba first so an rgb/rgba (or grey/rgba)
65
+ # pair still compares pixel-wise.
66
+ def diff_mask(oracle, current)
67
+ (rgba(oracle) != rgba(current)).bandor
68
+ end
69
+
70
+ def rgba(img)
71
+ case img.bands
72
+ when 1 then g = img.extract_band(0)
73
+ g.bandjoin([g, g]).bandjoin_const([255])
74
+ when 2 then g = img.extract_band(0)
75
+ g.bandjoin([g, g, img.extract_band(1)])
76
+ when 3 then img.bandjoin_const([255])
77
+ else img
78
+ end
79
+ end
80
+
81
+ # Splits the image into HEAT_CELL squares and returns, for every cell
82
+ # with at least one differing pixel, its share of differing pixels:
83
+ #
84
+ # { "cell" => 32, "w" => 1280, "h" => 800,
85
+ # "cells" => [[col, row, intensity], ...] } # intensity in 0..1
86
+ #
87
+ # Per-cell differing-pixel counts come from a libvips shrink (average
88
+ # over each cell) so the counting stays native. Edge cells on the
89
+ # right/bottom cover the remainder, so their area (the intensity
90
+ # denominator) is smaller than HEAT_CELL^2.
91
+ def heat_grid(image, mask)
92
+ width = image.width
93
+ height = image.height
94
+ cols = (width + HEAT_CELL - 1) / HEAT_CELL
95
+ rows = (height + HEAT_CELL - 1) / HEAT_CELL
96
+ # pad to a HEAT_CELL multiple so shrink keeps whole cells (and works
97
+ # at all when the image is smaller than one cell); the zero padding
98
+ # leaves each cell's differing-pixel count untouched.
99
+ padded = mask.embed(0, 0, cols * HEAT_CELL, rows * HEAT_CELL)
100
+ avgs = padded.cast(:float).shrink(HEAT_CELL, HEAT_CELL).write_to_memory.unpack("e*")
101
+ cells = avgs.each_with_index.filter_map do |avg, idx|
102
+ col = idx % cols
103
+ row = idx / cols
104
+ cell_w = [width - col * HEAT_CELL, HEAT_CELL].min
105
+ cell_h = [height - row * HEAT_CELL, HEAT_CELL].min
106
+ # avg is over the padded HEAT_CELL^2 cell, so the count denominator
107
+ # is the padded area, not the cell's actual (edge-trimmed) area.
108
+ count = (avg * HEAT_CELL * HEAT_CELL / 255.0).round
109
+ next if count.zero?
110
+
111
+ [col, row, (count.fdiv(cell_w * cell_h) * 100).round / 100.0]
112
+ end
113
+ { "cell" => HEAT_CELL, "w" => width, "h" => height, "cells" => cells }
114
+ end
115
+
116
+ def write_diff_image(rel, base, mask, ctx)
117
+ diff = mask.ifthenelse(RED, rgba(base))
118
+ rel_path = File.join("diffs", "#{rel}.diff.png")
119
+ abs = File.join(ctx.report_dir, rel_path)
120
+ FileUtils.mkdir_p(File.dirname(abs))
121
+ diff.pngsave(abs)
122
+ rel_path
123
+ end
124
+ end
125
+
126
+ register(Png, "png")
127
+ end
128
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twinshot
4
+ # Adapter implementations (the contract lives in adapters.rb).
5
+ module Adapters
6
+ # Plain text: line-by-line diff. Pass when every line matches.
7
+ module Txt
8
+ module_function
9
+
10
+ def call(_rel, oracle_file, current_file, _ctx)
11
+ a_lines = read_lines(oracle_file)
12
+ b_lines = read_lines(current_file)
13
+ return { "kind" => "txt", "status" => "pass", "detail" => "lines identical" } if a_lines == b_lines
14
+
15
+ changed, entries = Adapters.line_diff(a_lines, b_lines)
16
+ { "kind" => "txt", "status" => "fail", "detail" => "#{changed} line(s) differ",
17
+ "diff_lines" => changed, "diff" => entries }
18
+ end
19
+
20
+ def read_lines(path)
21
+ File.read(path, encoding: "UTF-8").gsub(/\r\n?/, "\n").lines.map(&:chomp)
22
+ end
23
+ end
24
+
25
+ register(Txt, "txt", "md", "log", "json", "csv")
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Twinshot
6
+ # Adapter implementations (the contract lives in adapters.rb).
7
+ module Adapters
8
+ # XML compares by structure like html, but through the XML parser:
9
+ # element names stay case-sensitive and no html/body structure is
10
+ # implied. Canonical serialization → line diff; pass when structures are
11
+ # identical. svg is XML, so it lands here too (compared as markup, not
12
+ # rendered pixels).
13
+ module Xml
14
+ module_function
15
+
16
+ def call(_rel, oracle_file, current_file, _ctx)
17
+ Adapters.structural_result("xml", canonical_lines(oracle_file), canonical_lines(current_file))
18
+ end
19
+
20
+ def canonical_lines(path)
21
+ doc = Nokogiri::XML(File.read(path, encoding: "UTF-8"))
22
+ Adapters.canonical_dom_lines(doc, downcase_names: false)
23
+ end
24
+ end
25
+
26
+ register(Xml, "xml", "svg")
27
+ end
28
+ end