Initial parametric Feather case generator
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "test_helper"
|
||||
|
||||
class ConfigTest < Minitest::Test
|
||||
def config
|
||||
FeatherCaseWizard::Config.create(name: "Bench GPS", slug: "bench-gps")
|
||||
end
|
||||
|
||||
def test_default_case_has_expected_parts_and_dimensions
|
||||
assert_equal %w[body lid], config.printable_parts
|
||||
assert_in_delta 60.8, config.dimensions["length"], 0.01
|
||||
assert_in_delta 41.66, config.dimensions["width"], 0.01
|
||||
assert_equal ["3mf"], config.outputs["formats"]
|
||||
end
|
||||
|
||||
def test_oled_buttons_and_selected_coupons_are_printable
|
||||
subject = config
|
||||
subject.parameters["top_profile"] = "oled128x64"
|
||||
subject.parameters["button_a"] = false
|
||||
subject.parameters["button_b"] = true
|
||||
subject.outputs["coupons"] = %w[fit_coupon]
|
||||
|
||||
assert_equal %w[body lid buttons fit_coupon], subject.printable_parts
|
||||
end
|
||||
|
||||
def test_wall_local_cutouts_map_to_model_coordinates
|
||||
subject = config
|
||||
length = subject.dimensions["length"]
|
||||
width = subject.dimensions["width"]
|
||||
subject.data["cutouts"] = [
|
||||
cutout("front", 12.0),
|
||||
cutout("rear", 13.0),
|
||||
cutout("left", 14.0),
|
||||
cutout("right", 15.0)
|
||||
]
|
||||
result = subject.scad_cutouts
|
||||
|
||||
assert_in_delta width - 12.0, result[0][2], 0.001
|
||||
assert_in_delta 13.0, result[1][2], 0.001
|
||||
assert_in_delta 14.0, result[2][2], 0.001
|
||||
assert_in_delta length - 15.0, result[3][2], 0.001
|
||||
end
|
||||
|
||||
def test_cutout_cannot_intersect_floor
|
||||
subject = config
|
||||
subject.data["cutouts"] = [cutout("rear", 12.0).merge("z" => 2.0)]
|
||||
|
||||
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
|
||||
assert_match(/floor/, error.message)
|
||||
end
|
||||
|
||||
def test_yaml_round_trip_preserves_resolved_cutouts
|
||||
subject = config
|
||||
subject.data["cutouts"] = [cutout("left", 20.0)]
|
||||
|
||||
Dir.mktmpdir do |directory|
|
||||
path = File.join(directory, "case.yml")
|
||||
File.write(path, subject.to_yaml)
|
||||
loaded = FeatherCaseWizard::Config.load(path)
|
||||
assert_equal subject.data["cutouts"], loaded.data["cutouts"]
|
||||
assert_equal subject.scad_cutouts, loaded.data.dig("resolved", "extra_cutouts")
|
||||
end
|
||||
end
|
||||
|
||||
def test_unsafe_yaml_is_rejected
|
||||
Dir.mktmpdir do |directory|
|
||||
path = File.join(directory, "case.yml")
|
||||
File.write(path, "--- !ruby/object:Object {}\n")
|
||||
assert_raises(FeatherCaseWizard::ConfigError) do
|
||||
FeatherCaseWizard::Config.load(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cutout(face, horizontal)
|
||||
{
|
||||
"name" => "#{face}-cable",
|
||||
"face" => face,
|
||||
"shape" => "roundrect",
|
||||
"horizontal" => horizontal,
|
||||
"z" => 12.0,
|
||||
"width" => 6.0,
|
||||
"height" => 4.0,
|
||||
"radius" => 1.0
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "test_helper"
|
||||
|
||||
class PackageBuilderTest < Minitest::Test
|
||||
def test_builds_self_contained_package_and_preserves_history
|
||||
Dir.mktmpdir do |directory|
|
||||
root = Pathname(directory)
|
||||
tools = create_fake_tools(root)
|
||||
model = root.join("model.scad")
|
||||
renderer = root.join("renderer.py")
|
||||
File.write(model, "cube([1,1,1]);\n")
|
||||
File.write(renderer, "# test renderer\n")
|
||||
config = FeatherCaseWizard::Config.create(name: "OLED Demo", slug: "oled-demo")
|
||||
config.parameters["top_profile"] = "oled128x64"
|
||||
config.outputs["formats"] = %w[3mf stl]
|
||||
config.outputs["coupons"] = %w[fit_coupon]
|
||||
|
||||
destination = build(config, root.join("cases"), tools, model, renderer)
|
||||
|
||||
assert destination.join("case.yml").file?
|
||||
assert destination.join("parameters.json").file?
|
||||
assert destination.join("build.rb").executable?
|
||||
%w[body lid buttons fit_coupon].each do |part|
|
||||
assert destination.join("exports/stl/#{part}.stl").file?
|
||||
assert destination.join("exports/3mf/#{part}.3mf").file?
|
||||
assert destination.join("previews/#{part}_iso.png").file?
|
||||
end
|
||||
%w[iso front back left right top].each do |view|
|
||||
assert destination.join("previews/assembly_#{view}.png").file?
|
||||
end
|
||||
|
||||
config.parameters["button_reset"] = true
|
||||
build(config, root.join("cases"), tools, model, renderer)
|
||||
assert_equal 1, Dir.glob(destination.join("history/case-*.yml")).length
|
||||
end
|
||||
end
|
||||
|
||||
def test_failed_build_leaves_existing_package_untouched
|
||||
Dir.mktmpdir do |directory|
|
||||
root = Pathname(directory)
|
||||
destination = root.join("cases/demo")
|
||||
FileUtils.mkdir_p(destination)
|
||||
File.write(destination.join("sentinel"), "keep")
|
||||
model = root.join("model.scad")
|
||||
renderer = root.join("renderer.py")
|
||||
File.write(model, "cube(1);\n")
|
||||
File.write(renderer, "# renderer\n")
|
||||
failing = Class.new do
|
||||
attr_reader :openscad, :blender
|
||||
def initialize
|
||||
@openscad = @blender = "/fake"
|
||||
end
|
||||
def check! = self
|
||||
def run!(*)
|
||||
raise FeatherCaseWizard::ToolError, "planned failure"
|
||||
end
|
||||
end.new
|
||||
config = FeatherCaseWizard::Config.create(name: "Demo", slug: "demo")
|
||||
|
||||
assert_raises(FeatherCaseWizard::ToolError) do
|
||||
FeatherCaseWizard::PackageBuilder.new(
|
||||
config, root: root.join("cases"), toolchain: failing,
|
||||
model_source: model, renderer_source: renderer
|
||||
).build!
|
||||
end
|
||||
assert_equal "keep", destination.join("sentinel").read
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build(config, root, tools, model, renderer)
|
||||
FeatherCaseWizard::PackageBuilder.new(
|
||||
config, root: root, toolchain: tools,
|
||||
model_source: model, renderer_source: renderer
|
||||
).build!
|
||||
end
|
||||
|
||||
def create_fake_tools(root)
|
||||
bin = root.join("bin")
|
||||
FileUtils.mkdir_p(bin)
|
||||
openscad = bin.join("openscad")
|
||||
blender = bin.join("blender")
|
||||
File.write(openscad, <<~RUBY)
|
||||
#!/usr/bin/env ruby
|
||||
output = ARGV[ARGV.index("-o") + 1]
|
||||
require "fileutils"
|
||||
FileUtils.mkdir_p(File.dirname(output))
|
||||
File.binwrite(output, "mesh")
|
||||
RUBY
|
||||
File.write(blender, <<~RUBY)
|
||||
#!/usr/bin/env ruby
|
||||
require "fileutils"
|
||||
args = ARGV[(ARGV.index("--") + 1)..]
|
||||
_mesh, output, basename, selected = args
|
||||
views = selected ? selected.split(",") : %w[iso front back left right top]
|
||||
FileUtils.mkdir_p(output)
|
||||
views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") }
|
||||
RUBY
|
||||
FileUtils.chmod(0o755, [openscad, blender])
|
||||
FeatherCaseWizard::Toolchain.new(
|
||||
env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, "BLENDER" => blender.to_s}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "minitest/autorun"
|
||||
require "tmpdir"
|
||||
|
||||
require_relative "../lib/feather_case_wizard"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "test_helper"
|
||||
|
||||
class ToolchainTest < Minitest::Test
|
||||
def test_reports_both_missing_dependencies
|
||||
toolchain = FeatherCaseWizard::Toolchain.new(env: {"PATH" => ""})
|
||||
error = assert_raises(FeatherCaseWizard::ToolError) { toolchain.check! }
|
||||
|
||||
assert_match(/OpenSCAD/, error.message)
|
||||
assert_match(/Blender/, error.message)
|
||||
assert_match(/apt-get/, error.message)
|
||||
end
|
||||
|
||||
def test_environment_overrides_are_used
|
||||
Dir.mktmpdir do |directory|
|
||||
executable = File.join(directory, "tool")
|
||||
File.write(executable, "#!/bin/sh\nexit 0\n")
|
||||
FileUtils.chmod(0o755, executable)
|
||||
toolchain = FeatherCaseWizard::Toolchain.new(
|
||||
env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable}
|
||||
)
|
||||
|
||||
assert_equal executable, toolchain.openscad
|
||||
assert_equal executable, toolchain.blender
|
||||
assert_same toolchain, toolchain.check!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "stringio"
|
||||
require_relative "test_helper"
|
||||
|
||||
class WizardTest < Minitest::Test
|
||||
def test_minimal_new_case_flow_and_confirmation
|
||||
input = StringIO.new(
|
||||
"Demo Case\n" \
|
||||
"\n" \
|
||||
"1\n" \
|
||||
"1\n" \
|
||||
"\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"1\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"y\n"
|
||||
)
|
||||
output = StringIO.new
|
||||
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output))
|
||||
|
||||
config = wizard.create
|
||||
|
||||
assert wizard.confirm_build(config)
|
||||
assert_equal "demo-case", config.slug
|
||||
assert_equal ["3mf"], config.outputs["formats"]
|
||||
assert_equal %w[body lid], config.printable_parts
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user