mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
Previously, important image-processing behavior lacked precise regression coverage, making image-library upgrades difficult to assess safely. This change adds coverage without changing production behavior: - Exercises public upload and image-model outcomes for SVG sanitization, format detection and conversion, transparency, animation metadata, EXIF orientation normalization, malformed input rejection, crop positioning, tiny-image resizing, dominant-color caching, and image-quality selection. - Keeps normal LetterAvatar RSpec coverage portable by generating one representative PNG and checking its format and requested dimensions. - Adds a separate Linux-only `script/letter_avatar_pixel_diff` workflow that compares A–Z renders with committed baselines and generates an HTML expected/actual/highlighted-diff report. Use `--update-baselines` when intentionally accepting rendering changes. - Uses deterministic fixtures with regeneration guidance beside the relevant specs.
234 lines
8.1 KiB
Ruby
Executable file
Vendored
234 lines
8.1 KiB
Ruby
Executable file
Vendored
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
|
|
ENV["RAILS_ENV"] ||= "test"
|
|
|
|
require_relative "../config/environment"
|
|
|
|
require "base64"
|
|
require "chunky_png"
|
|
require "fileutils"
|
|
require "letter_avatar"
|
|
require "optparse"
|
|
|
|
# Compare representative A-Z renders with Linux baselines and highlight pixel drift.
|
|
module LetterAvatarPixelDiff
|
|
LETTERS = ("A".."Z").to_a.freeze
|
|
AVATAR_SIZE = 45
|
|
BASELINE_DIRECTORY = Rails.root.join("spec/fixtures/images/letter_avatar_visual/linux")
|
|
REPORT_DIRECTORY = Rails.root.join("tmp/letter-avatar-pixel-diff")
|
|
|
|
class << self
|
|
def run(arguments)
|
|
options = parse_options(arguments)
|
|
generated_paths = {}
|
|
|
|
if RbConfig::CONFIG["host_os"].match?(/darwin/i)
|
|
warn "LetterAvatar pixel baselines are Linux-only."
|
|
return 2
|
|
end
|
|
|
|
LETTERS.each do |letter|
|
|
generated_paths[letter] = LetterAvatar.generate(letter, AVATAR_SIZE, cache: false)
|
|
end
|
|
|
|
if options[:update_baselines]
|
|
update_baselines(generated_paths)
|
|
puts "Updated Linux A-Z baselines in #{BASELINE_DIRECTORY}."
|
|
return 0
|
|
end
|
|
|
|
missing_baselines =
|
|
LETTERS.reject { |letter| BASELINE_DIRECTORY.join("#{letter}.png").exist? }
|
|
if missing_baselines.any?
|
|
warn(
|
|
"Missing Linux baselines for #{missing_baselines.join(", ")}. " \
|
|
"Run `script/letter_avatar_pixel_diff --update-baselines`.",
|
|
)
|
|
return 2
|
|
end
|
|
|
|
comparisons = compare(generated_paths)
|
|
write_report(comparisons)
|
|
comparisons.each do |comparison|
|
|
changed_pixel_count = comparison[:changed_pixel_count]
|
|
pixel_count = comparison[:pixel_count]
|
|
changed_percentage = pixel_count.zero? ? 0 : (changed_pixel_count.fdiv(pixel_count) * 100)
|
|
puts(
|
|
"#{comparison[:letter]}: #{changed_pixel_count}/#{pixel_count} pixels differ " \
|
|
"(#{format("%.2f", changed_percentage)}%)",
|
|
)
|
|
end
|
|
|
|
if comparisons.any? { |comparison| comparison[:changed_pixel_count].positive? }
|
|
warn "LetterAvatar pixels changed. Open #{REPORT_DIRECTORY.join("index.html")}."
|
|
1
|
|
else
|
|
puts "All Linux A-Z pixel baselines match. Report: #{REPORT_DIRECTORY.join("index.html")}"
|
|
0
|
|
end
|
|
rescue OptionParser::ParseError => error
|
|
warn error.message
|
|
2
|
|
ensure
|
|
remove_generated_avatars(generated_paths)
|
|
end
|
|
|
|
private
|
|
|
|
def parse_options(arguments)
|
|
options = { update_baselines: false }
|
|
OptionParser
|
|
.new do |parser|
|
|
parser.banner = "Usage: script/letter_avatar_pixel_diff [--update-baselines]"
|
|
parser.on("--update-baselines", "Regenerate every committed Linux A-Z baseline.") do
|
|
options[:update_baselines] = true
|
|
end
|
|
end
|
|
.parse!(arguments)
|
|
options
|
|
end
|
|
|
|
def update_baselines(generated_paths)
|
|
FileUtils.mkdir_p(BASELINE_DIRECTORY)
|
|
generated_paths.each do |letter, generated_path|
|
|
FileUtils.cp(generated_path, BASELINE_DIRECTORY.join("#{letter}.png"))
|
|
end
|
|
end
|
|
|
|
def compare(generated_paths)
|
|
generated_paths.map do |letter, generated_path|
|
|
expected = ChunkyPNG::Image.from_file(BASELINE_DIRECTORY.join("#{letter}.png"))
|
|
actual = ChunkyPNG::Image.from_file(generated_path)
|
|
changed_pixel_count, pixel_count = changed_pixel_count(expected, actual)
|
|
|
|
{
|
|
letter: letter,
|
|
expected: expected,
|
|
actual: actual,
|
|
changed_pixel_count: changed_pixel_count,
|
|
pixel_count: pixel_count,
|
|
}
|
|
end
|
|
end
|
|
|
|
def changed_pixel_count(expected, actual)
|
|
width = [expected.width, actual.width].max
|
|
height = [expected.height, actual.height].max
|
|
count =
|
|
height.times.sum do |y_position|
|
|
width.times.count do |x_position|
|
|
expected_pixel = expected[x_position, y_position] if x_position < expected.width &&
|
|
y_position < expected.height
|
|
actual_pixel = actual[x_position, y_position] if x_position < actual.width &&
|
|
y_position < actual.height
|
|
|
|
expected_pixel != actual_pixel
|
|
end
|
|
end
|
|
|
|
[count, width * height]
|
|
end
|
|
|
|
def write_report(comparisons)
|
|
FileUtils.mkdir_p(REPORT_DIRECTORY)
|
|
sections = comparisons.map { |comparison| write_comparison(comparison) }
|
|
report = <<~HTML
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>LetterAvatar pixel diff</title>
|
|
<style>
|
|
body { color: #222; font: 16px/1.5 sans-serif; margin: 2rem; }
|
|
section { border-top: 1px solid #ccc; margin-top: 2rem; }
|
|
.images { display: flex; flex-wrap: wrap; gap: 2rem; }
|
|
figure { margin: 0; }
|
|
img { image-rendering: pixelated; width: 180px; height: 180px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>LetterAvatar pixel diff</h1>
|
|
<p>Baseline: Linux</p>
|
|
#{sections.join}
|
|
</body>
|
|
</html>
|
|
HTML
|
|
File.write(REPORT_DIRECTORY.join("index.html"), report)
|
|
end
|
|
|
|
def write_comparison(comparison)
|
|
letter = comparison[:letter]
|
|
expected = comparison[:expected]
|
|
actual = comparison[:actual]
|
|
diff = build_diff(expected, actual)
|
|
expected_path = REPORT_DIRECTORY.join("#{letter}-expected.png")
|
|
actual_path = REPORT_DIRECTORY.join("#{letter}-actual.png")
|
|
diff_path = REPORT_DIRECTORY.join("#{letter}-diff.png")
|
|
expected.save(expected_path)
|
|
actual.save(actual_path)
|
|
diff.save(diff_path)
|
|
expected_data = Base64.strict_encode64(File.binread(expected_path))
|
|
actual_data = Base64.strict_encode64(File.binread(actual_path))
|
|
diff_data = Base64.strict_encode64(File.binread(diff_path))
|
|
changed_pixel_count = comparison[:changed_pixel_count]
|
|
pixel_count = comparison[:pixel_count]
|
|
changed_percentage = pixel_count.zero? ? 0 : (changed_pixel_count.fdiv(pixel_count) * 100)
|
|
|
|
<<~HTML
|
|
<section>
|
|
<h2>#{letter}: #{changed_pixel_count}/#{pixel_count} pixels differ (#{format("%.2f", changed_percentage)}%)</h2>
|
|
<div class="images">
|
|
<figure><figcaption>Expected</figcaption><img src="data:image/png;base64,#{expected_data}" alt="Expected #{letter}"></figure>
|
|
<figure><figcaption>Actual</figcaption><img src="data:image/png;base64,#{actual_data}" alt="Actual #{letter}"></figure>
|
|
<figure><figcaption>Diff (changed pixels are magenta)</figcaption><img src="data:image/png;base64,#{diff_data}" alt="Pixel diff for #{letter}"></figure>
|
|
</div>
|
|
</section>
|
|
HTML
|
|
end
|
|
|
|
def build_diff(expected, actual)
|
|
width = [expected.width, actual.width].max
|
|
height = [expected.height, actual.height].max
|
|
diff = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
|
|
|
|
height.times do |y_position|
|
|
width.times do |x_position|
|
|
expected_pixel = expected[x_position, y_position] if x_position < expected.width &&
|
|
y_position < expected.height
|
|
actual_pixel = actual[x_position, y_position] if x_position < actual.width &&
|
|
y_position < actual.height
|
|
|
|
diff[x_position, y_position] = if expected_pixel.nil? && actual_pixel.nil?
|
|
ChunkyPNG::Color::TRANSPARENT
|
|
elsif expected_pixel == actual_pixel
|
|
dim(actual_pixel)
|
|
else
|
|
ChunkyPNG::Color.rgba(255, 0, 255, 255)
|
|
end
|
|
end
|
|
end
|
|
|
|
diff
|
|
end
|
|
|
|
def dim(pixel)
|
|
ChunkyPNG::Color.rgba(
|
|
ChunkyPNG::Color.r(pixel) / 4,
|
|
ChunkyPNG::Color.g(pixel) / 4,
|
|
ChunkyPNG::Color.b(pixel) / 4,
|
|
ChunkyPNG::Color.a(pixel),
|
|
)
|
|
end
|
|
|
|
def remove_generated_avatars(generated_paths)
|
|
generated_paths&.each do |letter, generated_path|
|
|
FileUtils.rm_f(generated_path)
|
|
FileUtils.rm_f(LetterAvatar.fullsize_path(LetterAvatar::Identity.from_username(letter)))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
exit LetterAvatarPixelDiff.run(ARGV)
|