trmnl_preview 0.28.0 → 0.30.0
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/db/data/framework_versions.yml +5 -1
- data/lib/trmnlp/browser_pool.rb +8 -6
- data/lib/trmnlp/testing/ink_count.rb +43 -0
- data/lib/trmnlp/testing/overlapping_text.js +59 -0
- data/lib/trmnlp/testing/publishable_recipe.rb +24 -2
- data/lib/trmnlp/testing/rspec.rb +66 -0
- data/lib/trmnlp/testing/screen.rb +61 -0
- data/lib/trmnlp/testing/text_ink.rb +105 -0
- data/lib/trmnlp/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20431d997e72a0c534f9945c0c8e23cf97dcfff3be5119cbbf2d7973b2c3af73
|
|
4
|
+
data.tar.gz: 4a96842d311255ad1cd618e36dab4ef4eaa0504c58efe394a609ee5ddc043dba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6dcab087ef567886345b3ef14e90931af73c236680afb0555667357f277fd650eaed3c473fa7bb514196ca701cccbd3a68ee6f19e302e0be3d37a23bb5abcdb1
|
|
7
|
+
data.tar.gz: '070453995dac9d20fb77914c1a2adcfde9d01f2ccb34899f00b94cf238278b15a3afae3abfe33da9aae17dbacad5f842e34bd080c169ef256c58d144c449b9b5'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
|
|
2
2
|
# Changelog
|
|
3
3
|
|
|
4
|
+
## 0.30.0
|
|
5
|
+
|
|
6
|
+
- `trmnlp test` quits a Firefox that stopped answering before it starts a new one. The old geckodriver kept running until something killed it.
|
|
7
|
+
- `trmnlp test`'s dark mode check also fails a view where an image, SVG or canvas that shows something in light mode shows nothing in dark mode, such as a checkbox icon drawn in a fixed black, and names it. Before, a view lost to dark mode only when most of its content went, so one lost icon passed.
|
|
8
|
+
- `trmnlp test`'s check for text drawn over other text reads the pixels Firefox draws for each text, in place of glyph outlines estimated from font measurements. It now also fails a glyph that only touches the line next to it, such as the bottom of a quote's last line touching the name under it, which the estimates placed a few pixels off. Text that sits close without touching still passes.
|
|
9
|
+
|
|
10
|
+
## 0.29.0
|
|
11
|
+
|
|
12
|
+
- `trmnlp test` fails a screen that draws text over other text, or content text under the title bar, and names both texts, such as a name printed across the last line of a quote. Only glyphs that share inked pixels count, so a unit tucked against a number passes.
|
|
13
|
+
- `trmnlp test`'s publishable recipe group fails "has a mock for every request it makes" when the polling or the transform asks for a url no mock answers, and names each url without its query. Such a request is answered with status 599, so the screens drew the error state as if the API had failed. The matcher is `have_a_mock_for_every_request`.
|
|
14
|
+
- `trmnlp test` checks each view in dark mode on the TRMNL X: a view fails when its layout keeps less than half its content, as when text, lines or icons are drawn in a fixed black and stay black on the black screen. Pass `dark_mode_screen:` to the shared group to check another screen.
|
|
15
|
+
- `trmnlp test` fails a screen where the Framework clamped text whose full text fits on one line in the width its parent gives it, such as a chart title cut to `Egg Pric...`. It names the cut text and the full text.
|
|
16
|
+
|
|
4
17
|
## 0.28.0
|
|
5
18
|
|
|
6
19
|
- `trmnlp test`, `serve` and `build --png` close their headless Firefox when they exit. Each run left one running, with nothing left to stop it, and a loop of runs could fill memory and swap.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Mirrored from the TRMNL design-system source.
|
|
2
2
|
# Refresh with `rake framework:sync` — do not edit manually.
|
|
3
|
-
latest: 3.4.
|
|
3
|
+
latest: 3.4.2
|
|
4
4
|
versions:
|
|
5
5
|
- number: 0.0.1
|
|
6
6
|
released_at: '2024-06-22'
|
|
@@ -94,3 +94,7 @@ versions:
|
|
|
94
94
|
released_at: '2026-09-18'
|
|
95
95
|
- number: 3.4.0
|
|
96
96
|
released_at: '2026-09-28'
|
|
97
|
+
- number: 3.4.1
|
|
98
|
+
released_at: '2026-10-14'
|
|
99
|
+
- number: 3.4.2
|
|
100
|
+
released_at: '2026-10-14'
|
data/lib/trmnlp/browser_pool.rb
CHANGED
|
@@ -23,11 +23,7 @@ module TRMNLP
|
|
|
23
23
|
return if @shutdown
|
|
24
24
|
|
|
25
25
|
@shutdown = true
|
|
26
|
-
@drivers.each
|
|
27
|
-
driver.quit
|
|
28
|
-
rescue StandardError
|
|
29
|
-
nil
|
|
30
|
-
end
|
|
26
|
+
@drivers.each { quit(it) }
|
|
31
27
|
@drivers.clear
|
|
32
28
|
end
|
|
33
29
|
end
|
|
@@ -72,9 +68,15 @@ module TRMNLP
|
|
|
72
68
|
|
|
73
69
|
def recycle(driver)
|
|
74
70
|
@mutex.synchronize do
|
|
75
|
-
@drivers.delete(driver)
|
|
71
|
+
quit(@drivers.delete(driver))
|
|
76
72
|
@driver_factory.call.tap { |d| @drivers << d }
|
|
77
73
|
end
|
|
78
74
|
end
|
|
75
|
+
|
|
76
|
+
def quit(driver)
|
|
77
|
+
driver&.quit
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
79
81
|
end
|
|
80
82
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mini_magick'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module Testing
|
|
7
|
+
# How many pixels of a PNG stand out from its background, the gray level most pixels have; area: a box
|
|
8
|
+
# counts only inside it.
|
|
9
|
+
class InkCount
|
|
10
|
+
# Half the gray scale: text and lines pass; a faint tint or frame does not, on white or on black.
|
|
11
|
+
MIN_DIFFERENCE = 128
|
|
12
|
+
|
|
13
|
+
def initialize(path, area: nil)
|
|
14
|
+
@path = path
|
|
15
|
+
@area = area
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pixels
|
|
19
|
+
levels = histogram
|
|
20
|
+
return 0 if levels.empty?
|
|
21
|
+
|
|
22
|
+
background = levels.max_by(&:last).first
|
|
23
|
+
levels.sum { |level, count| (level - background).abs > MIN_DIFFERENCE ? count : 0 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# [gray level 0-255, pixel count] for each level the picture uses.
|
|
29
|
+
def histogram
|
|
30
|
+
output = MiniMagick.convert do |convert|
|
|
31
|
+
convert << @path
|
|
32
|
+
convert << '-crop' << crop_geometry << '+repage' if @area
|
|
33
|
+
convert << '-colorspace' << 'gray' << '-depth' << '8' << '-format' << '%c' << 'histogram:info:-'
|
|
34
|
+
end
|
|
35
|
+
output.lines.filter_map do |line|
|
|
36
|
+
(match = line.match(/\A\s*(\d+):\s*\(\s*(\d+)/)) && [match[2].to_i, match[1].to_i]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def crop_geometry = "#{@area.width.round}x#{@area.height.round}+#{@area.left.round}+#{@area.top.round}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const shown = (el) => el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
|
|
2
|
+
const intersect = (a, b) => ({ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top),
|
|
3
|
+
right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) });
|
|
4
|
+
const area = (r) => Math.max(0, r.right - r.left) * Math.max(0, r.bottom - r.top);
|
|
5
|
+
const covers = (a, b, share) => area(intersect(a, b)) > Math.min(area(a), area(b)) * share;
|
|
6
|
+
const visiblePart = (rect, el) => {
|
|
7
|
+
let part = rect;
|
|
8
|
+
for (let parent = el; parent; parent = parent.parentElement) {
|
|
9
|
+
if (getComputedStyle(parent).overflow !== 'visible') part = intersect(part, parent.getBoundingClientRect());
|
|
10
|
+
}
|
|
11
|
+
return part;
|
|
12
|
+
};
|
|
13
|
+
// A glyph can reach past its line box, above it or below it, by up to about half its font size.
|
|
14
|
+
const widen = (rect, by) => ({ left: rect.left - by, top: rect.top - by, right: rect.right + by, bottom: rect.bottom + by });
|
|
15
|
+
const texts = [];
|
|
16
|
+
const range = document.createRange();
|
|
17
|
+
const walker = document.createTreeWalker(document.querySelector('.view') || document.body, NodeFilter.SHOW_TEXT);
|
|
18
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
19
|
+
const owner = node.parentElement;
|
|
20
|
+
if (!node.data.trim() || owner instanceof SVGElement || owner.closest('script, style, svg') || !shown(owner)) continue;
|
|
21
|
+
range.selectNodeContents(node);
|
|
22
|
+
const fontSize = parseFloat(getComputedStyle(owner).fontSize) || 16;
|
|
23
|
+
[...range.getClientRects()].map((line) => ({ line: visiblePart(line, owner), rect: visiblePart(widen(line, fontSize / 2), owner) }))
|
|
24
|
+
.filter(({ line }) => area(line) > 4)
|
|
25
|
+
.forEach(({ line, rect }) => texts.push({ owner, line, rect, text: node.data.trim().slice(0, 40) }));
|
|
26
|
+
}
|
|
27
|
+
const related = (a, b) => a === b || a.contains(b) || b.contains(a);
|
|
28
|
+
const pairs = [];
|
|
29
|
+
const neighbors = new Map(texts.map((t) => [t.owner, new Set()]));
|
|
30
|
+
texts.forEach((a, i) => texts.slice(i + 1).forEach((b) => {
|
|
31
|
+
if (a.owner === b.owner || !covers(a.rect, b.rect, 0)) return;
|
|
32
|
+
neighbors.get(a.owner).add(b.owner);
|
|
33
|
+
neighbors.get(b.owner).add(a.owner);
|
|
34
|
+
if (!related(a.owner, b.owner)) pairs.push([a, b]);
|
|
35
|
+
}));
|
|
36
|
+
// Texts whose widened boxes do not meet share a group, and each group one screenshot.
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
pairs.flat().map((t) => t.owner).forEach((owner) => {
|
|
39
|
+
if (groups.has(owner)) return;
|
|
40
|
+
const taken = new Set([...neighbors.get(owner)].map((other) => groups.get(other)));
|
|
41
|
+
let group = 0;
|
|
42
|
+
while (taken.has(group)) group += 1;
|
|
43
|
+
groups.set(owner, group);
|
|
44
|
+
});
|
|
45
|
+
groups.forEach((group, owner) => owner.setAttribute('data-trmnlp-text', group));
|
|
46
|
+
const candidates = pairs.map(([a, b]) => {
|
|
47
|
+
const region = intersect(a.rect, b.rect);
|
|
48
|
+
return { texts: [groups.get(a.owner), groups.get(b.owner)], names: `"${a.text}" and "${b.text}"`,
|
|
49
|
+
region: { left: region.left, top: region.top, width: region.right - region.left,
|
|
50
|
+
height: region.bottom - region.top } };
|
|
51
|
+
});
|
|
52
|
+
const underTitleBar = [];
|
|
53
|
+
const titleBar = document.querySelector('.view .title_bar');
|
|
54
|
+
if (titleBar) {
|
|
55
|
+
const bar = titleBar.getBoundingClientRect();
|
|
56
|
+
texts.filter((t) => !titleBar.contains(t.owner) && covers(t.line, bar, 1 / 3))
|
|
57
|
+
.forEach((t) => underTitleBar.push(`"${t.text}" and the title bar`));
|
|
58
|
+
}
|
|
59
|
+
return { candidates, under_title_bar: [...new Set(underTitleBar)] };
|
|
@@ -18,6 +18,12 @@ module TRMNLP
|
|
|
18
18
|
}.freeze
|
|
19
19
|
# A field with more options than this is drawn with its first and last.
|
|
20
20
|
PUBLISHABLE_RECIPE_MAX_OPTIONS = 20
|
|
21
|
+
# Share of a layout's ink its dark mode render must keep. Of 17 recipes measured on 10-13, those that draw
|
|
22
|
+
# well in dark mode kept 85% or more; those whose text turned black on black kept 0% to 15%.
|
|
23
|
+
DARK_MODE_MIN_INK_KEPT = 0.5
|
|
24
|
+
DARK_MODE_MIN_LIGHT_IMAGE_INK_PIXELS = 16
|
|
25
|
+
# Share of an image's ink its dark mode render must keep: lost icons kept 0%, the lowest one shown 17% (10-14).
|
|
26
|
+
DARK_MODE_MIN_IMAGE_INK_KEPT = 0.05
|
|
21
27
|
|
|
22
28
|
# Each select field's keyname and the values its options save, from the plugin's settings.yml.
|
|
23
29
|
def self.select_field_values
|
|
@@ -32,14 +38,18 @@ module TRMNLP
|
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
# rubocop:disable-next Metrics/BlockLength -- one shared group, one check per example
|
|
35
|
-
RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS
|
|
41
|
+
RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLISHABLE_RECIPE_SCREENS,
|
|
42
|
+
dark_mode_screen: { device: 'v2' }|
|
|
36
43
|
let(:recipe_inputs) do
|
|
37
44
|
{ mocks: respond_to?(:mocks) ? mocks : {}, custom_fields: respond_to?(:custom_fields) ? custom_fields : {},
|
|
38
45
|
variables: respond_to?(:variables) ? variables : {}, now: respond_to?(:now) ? now : nil,
|
|
39
46
|
data: respond_to?(:data) ? data : nil }
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
def draw_cleanly
|
|
49
|
+
def draw_cleanly
|
|
50
|
+
have_no_problems.and(have_no_leaked_text)
|
|
51
|
+
.and(have_no_text_clamped_with_room_to_spare).and(have_no_overlapping_text)
|
|
52
|
+
end
|
|
43
53
|
|
|
44
54
|
TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.product(screens).each do |view, screen|
|
|
45
55
|
device = screen[:device].is_a?(Hash) ? screen[:device][:model] : screen[:device]
|
|
@@ -51,6 +61,14 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
|
|
|
51
61
|
end
|
|
52
62
|
end
|
|
53
63
|
|
|
64
|
+
TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.each do |view|
|
|
65
|
+
it "keeps the #{view} view's content in dark mode" do
|
|
66
|
+
light = trmnl.render(view:, **dark_mode_screen, **recipe_inputs)
|
|
67
|
+
expect(trmnl.render(view:, **dark_mode_screen, **recipe_inputs, dark_mode: true))
|
|
68
|
+
.to keep_the_content_of(light).and keep_each_image_of(light)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
TRMNLP::Testing::PUBLISHABLE_RECIPE_API_FAILURES.each do |failure, answer|
|
|
55
73
|
it "draws the full view when the API #{failure}" do
|
|
56
74
|
screen = trmnl.render(**screens.first, **recipe_inputs, mocks: { '*' => answer })
|
|
@@ -67,6 +85,10 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
|
|
|
67
85
|
end
|
|
68
86
|
end
|
|
69
87
|
|
|
88
|
+
it 'has a mock for every request it makes' do
|
|
89
|
+
expect(trmnl.transform(device: screens.first[:device], **recipe_inputs)).to have_a_mock_for_every_request
|
|
90
|
+
end
|
|
91
|
+
|
|
70
92
|
it 'runs its transform without error' do
|
|
71
93
|
expect(trmnl.transform(device: screens.first[:device], **recipe_inputs).error).to be_nil
|
|
72
94
|
end
|
data/lib/trmnlp/testing/rspec.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative '../browser_pool'
|
|
|
10
10
|
require_relative '../firefox_driver'
|
|
11
11
|
require_relative 'browser'
|
|
12
12
|
require_relative 'certificate_authority'
|
|
13
|
+
require_relative 'ink_count'
|
|
13
14
|
require_relative 'page_server'
|
|
14
15
|
require_relative 'plugin'
|
|
15
16
|
require_relative 'report'
|
|
@@ -75,6 +76,71 @@ RSpec::Matchers.define :have_no_transform_error do
|
|
|
75
76
|
failure_message { |screen| "expected the transform to run without error, but: #{screen.result.error}" }
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
# A request no mock answers gets status 599, which draws the plugin's error screen as if its API failed.
|
|
80
|
+
RSpec::Matchers.define :have_a_mock_for_every_request do
|
|
81
|
+
def unmocked(run) = run.requests.reject { it[:mocked] }.map { "#{it[:method]} #{it[:url].split('?', 2).first}" }.uniq
|
|
82
|
+
|
|
83
|
+
match { |run| unmocked(run).empty? }
|
|
84
|
+
failure_message do |run|
|
|
85
|
+
"expected a mock for every request, but none answers: #{unmocked(run).join(', ')}. " \
|
|
86
|
+
'Add them to tests/mocks.json; without one the screen shows the error state.'
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Dark mode recolors the Framework's text and lines; anything drawn in a fixed black stays black on the black screen.
|
|
91
|
+
RSpec::Matchers.define :keep_the_content_of do |light|
|
|
92
|
+
match do |dark|
|
|
93
|
+
@light, @dark = [light, dark].map { TRMNLP::Testing::InkCount.new(it.png_path, area: content_box(it)).pixels }
|
|
94
|
+
@light.zero? || @dark >= @light * TRMNLP::Testing::DARK_MODE_MIN_INK_KEPT
|
|
95
|
+
end
|
|
96
|
+
# The layout only: a mashup slot's frame is too faint to count on white but stands out on black.
|
|
97
|
+
def content_box(screen) = screen.has_css?('.layout', wait: false) ? screen.box('.layout') : nil
|
|
98
|
+
|
|
99
|
+
failure_message do |dark|
|
|
100
|
+
"expected #{dark.inspect} in dark mode to keep its content, but it keeps #{(100 * @dark / @light).round}% of " \
|
|
101
|
+
"it (#{@dark} of #{@light} pixels that stand out): text, lines or icons drawn in a fixed black (#000, black) " \
|
|
102
|
+
'stay black on the black screen. Use currentColor or Framework classes.'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Each image, SVG and canvas that stands out in light mode must still show in dark mode.
|
|
107
|
+
RSpec::Matchers.define :keep_each_image_of do |light|
|
|
108
|
+
def ink(screen, box) = TRMNLP::Testing::InkCount.new(screen.png_path, area: box).pixels
|
|
109
|
+
|
|
110
|
+
def lost_images(light, dark)
|
|
111
|
+
light.drawn_images.zip(dark.drawn_images).filter_map do |(name, light_box), (dark_name, dark_box)|
|
|
112
|
+
next unless name == dark_name
|
|
113
|
+
|
|
114
|
+
light_ink = ink(light, light_box)
|
|
115
|
+
lost = light_ink >= TRMNLP::Testing::DARK_MODE_MIN_LIGHT_IMAGE_INK_PIXELS &&
|
|
116
|
+
ink(dark, dark_box) < light_ink * TRMNLP::Testing::DARK_MODE_MIN_IMAGE_INK_KEPT
|
|
117
|
+
"#{name} at #{light_box.left.round},#{light_box.top.round}" if lost
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
match { |dark| (@lost = lost_images(light, dark)).empty? }
|
|
122
|
+
failure_message do |dark|
|
|
123
|
+
"expected #{dark.inspect} in dark mode to keep each image, but #{@lost.join(', ')} shows nothing in dark " \
|
|
124
|
+
'mode: it is drawn in a fixed black (#000, black). Use currentColor, or image--adaptive on an <img>.'
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
RSpec::Matchers.define :have_no_text_clamped_with_room_to_spare do
|
|
129
|
+
match { |screen| screen.clamped_with_room.empty? }
|
|
130
|
+
failure_message do |screen|
|
|
131
|
+
"expected no text cut short with room to spare on #{screen.inspect}, but it cuts: " \
|
|
132
|
+
"#{screen.clamped_with_room.join('; ')}."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
RSpec::Matchers.define :have_no_overlapping_text do
|
|
137
|
+
match { |screen| screen.overlapping_text.empty? }
|
|
138
|
+
failure_message do |screen|
|
|
139
|
+
"expected no text drawn over other text on #{screen.inspect}, but it draws #{screen.overlapping_text.join('; ')} " \
|
|
140
|
+
'over each other.'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
78
144
|
RSpec::Matchers.define :have_no_problems do
|
|
79
145
|
match { |screen| screen.problems.empty? }
|
|
80
146
|
failure_message { |screen| "expected no problems on #{screen.inspect}, but: #{screen.problems.join('; ')}" }
|
|
@@ -5,6 +5,7 @@ require 'delegate'
|
|
|
5
5
|
require 'nokogiri'
|
|
6
6
|
|
|
7
7
|
require_relative 'qr_scanner'
|
|
8
|
+
require_relative 'text_ink'
|
|
8
9
|
|
|
9
10
|
module TRMNLP
|
|
10
11
|
module Testing
|
|
@@ -87,6 +88,51 @@ module TRMNLP
|
|
|
87
88
|
}).map(describe);
|
|
88
89
|
JS
|
|
89
90
|
|
|
91
|
+
# Each element the Framework clamped although its full text fits on one line of the width its parent gives it.
|
|
92
|
+
CLAMPED_WITH_ROOM_SCRIPT = <<~JS
|
|
93
|
+
const textMeasuringContext = document.createElement('canvas').getContext('2d');
|
|
94
|
+
const px = (value) => parseFloat(value) || 0;
|
|
95
|
+
const sharesItsRow = (style) => style.display.includes('grid') ||
|
|
96
|
+
(style.display.includes('flex') && !style.flexDirection.startsWith('column'));
|
|
97
|
+
const fullTextWidth = (el, text) => {
|
|
98
|
+
const style = getComputedStyle(el);
|
|
99
|
+
textMeasuringContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
100
|
+
const shown = style.textTransform === 'uppercase' ? text.toUpperCase() : text;
|
|
101
|
+
return textMeasuringContext.measureText(shown).width + px(style.letterSpacing) * shown.length +
|
|
102
|
+
px(style.paddingLeft) + px(style.paddingRight) + px(style.marginLeft) + px(style.marginRight);
|
|
103
|
+
};
|
|
104
|
+
return [...document.querySelectorAll('.view [data-clamp-original]')].filter((el) => {
|
|
105
|
+
const original = el.getAttribute('data-clamp-original').trim();
|
|
106
|
+
const parent = el.parentElement;
|
|
107
|
+
if (!original || el.innerText.trim() === original || !el.getClientRects().length || !parent) return false;
|
|
108
|
+
const parentStyle = getComputedStyle(parent);
|
|
109
|
+
if (sharesItsRow(parentStyle)) return false;
|
|
110
|
+
const room = parent.clientWidth - px(parentStyle.paddingLeft) - px(parentStyle.paddingRight);
|
|
111
|
+
return fullTextWidth(el, original) <= room - 2;
|
|
112
|
+
}).map((el) => `"${el.innerText.trim()}" (all of "${el.getAttribute('data-clamp-original').trim()}" fits)`);
|
|
113
|
+
JS
|
|
114
|
+
|
|
115
|
+
# Pairs of text near enough to touch, and text drawn under the title bar. TextInk decides which pairs touch.
|
|
116
|
+
OVERLAPPING_TEXT_SCRIPT = File.read(File.join(__dir__, 'overlapping_text.js')).freeze
|
|
117
|
+
|
|
118
|
+
# Each drawn image, SVG and canvas in the view but the Framework's adaptive icon, its box cut to the screen.
|
|
119
|
+
DRAWN_IMAGES_SCRIPT = <<~JS
|
|
120
|
+
const screen = document.documentElement.getBoundingClientRect();
|
|
121
|
+
const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
|
|
122
|
+
(el.classList.length ? '.' + [...el.classList].join('.') : '');
|
|
123
|
+
return [...document.querySelectorAll('.view img, .view svg, .view canvas')]
|
|
124
|
+
.filter((el) => !el.parentElement.closest('svg') && !el.matches('img.image--adaptive[data-adaptive]') &&
|
|
125
|
+
el.checkVisibility({ opacityProperty: true }))
|
|
126
|
+
.map((el) => {
|
|
127
|
+
const box = el.getBoundingClientRect();
|
|
128
|
+
const left = Math.max(box.left, 0);
|
|
129
|
+
const top = Math.max(box.top, 0);
|
|
130
|
+
const right = Math.min(box.right, screen.right);
|
|
131
|
+
const bottom = Math.min(box.bottom, screen.bottom);
|
|
132
|
+
return { name: describe(el), left, top, right, bottom, width: right - left, height: bottom - top };
|
|
133
|
+
}).filter((image) => image.width >= 1 && image.height >= 1);
|
|
134
|
+
JS
|
|
135
|
+
|
|
90
136
|
HIDDEN_MARK = 'data-trmnlp-hidden'
|
|
91
137
|
# The DOM with every element Firefox does not draw marked, so Capybara's visible text is what is on screen.
|
|
92
138
|
DRAWN_DOM_SCRIPT = <<~JS.freeze
|
|
@@ -126,6 +172,21 @@ module TRMNLP
|
|
|
126
172
|
|
|
127
173
|
def overflowing(except: nil) = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT, except) }
|
|
128
174
|
|
|
175
|
+
def drawn_images
|
|
176
|
+
@browser.on(self) { it.execute_script(DRAWN_IMAGES_SCRIPT) }.map do |image|
|
|
177
|
+
[image['name'], Box.new(**image.slice(*Box.members.map(&:to_s)).transform_keys(&:to_sym))]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def clamped_with_room = @browser.on(self) { it.execute_script(CLAMPED_WITH_ROOM_SCRIPT) }
|
|
182
|
+
|
|
183
|
+
def overlapping_text
|
|
184
|
+
@browser.on(self) do |driver|
|
|
185
|
+
found = driver.execute_script(OVERLAPPING_TEXT_SCRIPT)
|
|
186
|
+
TextInk.new(driver).overlapping(found['candidates']) + found['under_title_bar']
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
129
190
|
# Script errors, unhandled rejections, console.error lines and files that failed to load.
|
|
130
191
|
def problems = evaluate('window.__trmnlpProblems || []')
|
|
131
192
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mini_magick'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
module Testing
|
|
8
|
+
# Which candidate pairs of text share inked pixels, as Firefox draws them.
|
|
9
|
+
class TextInk
|
|
10
|
+
MIN_SHARED_INK_PIXELS = 4
|
|
11
|
+
TEXT_GROUP_ATTRIBUTE = 'data-trmnlp-text'
|
|
12
|
+
INK_ATTRIBUTE = 'data-trmnlp-ink'
|
|
13
|
+
HIDE_TEXT_STYLE = <<~CSS.freeze
|
|
14
|
+
.view, .view * { color: transparent !important; -webkit-text-fill-color: transparent !important;
|
|
15
|
+
-webkit-text-stroke-width: 0 !important; text-shadow: none !important; }
|
|
16
|
+
.view [#{INK_ATTRIBUTE}="black"] { color: #000 !important; -webkit-text-fill-color: #000 !important; }
|
|
17
|
+
.view [#{INK_ATTRIBUTE}="white"] { color: #fff !important; -webkit-text-fill-color: #fff !important; }
|
|
18
|
+
CSS
|
|
19
|
+
|
|
20
|
+
def initialize(driver)
|
|
21
|
+
@driver = driver
|
|
22
|
+
@masks = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# candidates: { 'texts' => [group, group], 'names' => String, 'region' => { left, top, width, height } }.
|
|
26
|
+
def overlapping(candidates)
|
|
27
|
+
return [] if candidates.empty?
|
|
28
|
+
|
|
29
|
+
hide_text
|
|
30
|
+
candidates.select { shared_pixels(it) >= MIN_SHARED_INK_PIXELS }.map { it['names'] }.uniq
|
|
31
|
+
ensure
|
|
32
|
+
show_text
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def shared_pixels(candidate)
|
|
38
|
+
one, other = candidate['texts'].map { mask(it) }
|
|
39
|
+
one.shared_with(other, candidate['region'])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One byte per pixel of the screen, above zero where the text inks.
|
|
43
|
+
Mask = Data.define(:width, :height, :pixels) do
|
|
44
|
+
def shared_with(other, region)
|
|
45
|
+
left, top = region.values_at('left', 'top').map { it.floor.clamp(0..) }
|
|
46
|
+
right = (region['left'] + region['width']).ceil.clamp(..width)
|
|
47
|
+
bottom = (region['top'] + region['height']).ceil.clamp(..height)
|
|
48
|
+
(top...bottom).sum { |row| (left...right).count { inked?(it, row) && other.inked?(it, row) } }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inked?(column, row) = pixels.getbyte((row * width) + column).positive?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Where the group inks more than half a pixel: the difference of a black and a white screenshot.
|
|
55
|
+
def mask(group)
|
|
56
|
+
@masks[group] ||= begin
|
|
57
|
+
black, white = %w[black white].map { screenshot_with(group, it) }
|
|
58
|
+
Tempfile.create(['text-ink', '.pgm']) do |pgm|
|
|
59
|
+
MiniMagick.convert do |convert|
|
|
60
|
+
convert << black.path << white.path << '-alpha' << 'off' << '-compose' << 'difference' << '-composite'
|
|
61
|
+
convert << '-separate' << '-evaluate-sequence' << 'max' << '-threshold' << '50%' << '-depth' << '8'
|
|
62
|
+
convert << pgm.path
|
|
63
|
+
end
|
|
64
|
+
parse_pgm(File.binread(pgm.path))
|
|
65
|
+
end
|
|
66
|
+
ensure
|
|
67
|
+
[black, white].compact.each(&:close!)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A binary PGM: "P5", width, height and the largest value, then one byte per pixel.
|
|
72
|
+
def parse_pgm(data)
|
|
73
|
+
header = data.match(/\AP5\s+(\d+)\s+(\d+)\s+\d+\s/)
|
|
74
|
+
Mask.new(width: header[1].to_i, height: header[2].to_i, pixels: data.byteslice(header.end(0)..))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def screenshot_with(group, color)
|
|
78
|
+
run_on_group_elements(group, "el.setAttribute('#{INK_ATTRIBUTE}', '#{color}')")
|
|
79
|
+
Tempfile.new(['text-ink', '.png']).tap { @driver.save_screenshot(it.path) }
|
|
80
|
+
ensure
|
|
81
|
+
run_on_group_elements(group, "el.removeAttribute('#{INK_ATTRIBUTE}')")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_on_group_elements(group, statement)
|
|
85
|
+
selector = "[#{TEXT_GROUP_ATTRIBUTE}=\"#{group}\"]"
|
|
86
|
+
@driver.execute_script("document.querySelectorAll('#{selector}').forEach((el) => #{statement})")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def hide_text
|
|
90
|
+
@driver.execute_script(<<~JS, HIDE_TEXT_STYLE)
|
|
91
|
+
const style = document.createElement('style');
|
|
92
|
+
style.id = 'trmnlp-text-ink';
|
|
93
|
+
style.textContent = arguments[0];
|
|
94
|
+
document.head.appendChild(style);
|
|
95
|
+
JS
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def show_text
|
|
99
|
+
@driver.execute_script("document.getElementById('trmnlp-text-ink')?.remove(); " \
|
|
100
|
+
"document.querySelectorAll('[#{TEXT_GROUP_ATTRIBUTE}]')" \
|
|
101
|
+
".forEach((el) => el.removeAttribute('#{TEXT_GROUP_ATTRIBUTE}'))")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/trmnlp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trmnl_preview
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rockwell Schrock
|
|
@@ -480,10 +480,12 @@ files:
|
|
|
480
480
|
- lib/trmnlp/testing/certificate_authority.rb
|
|
481
481
|
- lib/trmnlp/testing/device_models.rb
|
|
482
482
|
- lib/trmnlp/testing/frozen_clock.rb
|
|
483
|
+
- lib/trmnlp/testing/ink_count.rb
|
|
483
484
|
- lib/trmnlp/testing/memory_sampler.rb
|
|
484
485
|
- lib/trmnlp/testing/mock_proxy.rb
|
|
485
486
|
- lib/trmnlp/testing/mock_table.rb
|
|
486
487
|
- lib/trmnlp/testing/node_version.rb
|
|
488
|
+
- lib/trmnlp/testing/overlapping_text.js
|
|
487
489
|
- lib/trmnlp/testing/page_server.rb
|
|
488
490
|
- lib/trmnlp/testing/plugin.rb
|
|
489
491
|
- lib/trmnlp/testing/publishable_recipe.rb
|
|
@@ -494,6 +496,7 @@ files:
|
|
|
494
496
|
- lib/trmnlp/testing/run.rb
|
|
495
497
|
- lib/trmnlp/testing/screen.rb
|
|
496
498
|
- lib/trmnlp/testing/snapshot.rb
|
|
499
|
+
- lib/trmnlp/testing/text_ink.rb
|
|
497
500
|
- lib/trmnlp/transform_backend/http.rb
|
|
498
501
|
- lib/trmnlp/transform_backend/subprocess.rb
|
|
499
502
|
- lib/trmnlp/transform_backend/which.rb
|