# 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 61.6, config.dimensions["length"], 0.01 assert_in_delta 41.66, config.dimensions["width"], 0.01 assert_in_delta 12.25, config.parameters["wing_pitch"], 0.001 assert_in_delta 6.0, config.parameters["gps_antenna_height"], 0.001 assert_in_delta 2.4, config.parameters["oled_component_height"], 0.001 assert_in_delta 1.5, config.parameters["oled_display_height"], 0.001 assert_in_delta 2.0, config.parameters["usb_board_wall_gap"], 0.001 assert config.parameters["usb_opening_enabled"] assert config.parameters["usb_guide_enabled"] assert_equal ["3mf"], config.outputs["formats"] end def test_gps_height_uses_stack_pitch_antenna_height_and_top_gap subject = config subject.parameters["top_profile"] = "gps" parameters = subject.parameters expected_electronics_height = parameters["wing_pitch"] + parameters["gps_antenna_height"] + parameters["gps_top_gap"] assert_in_delta 23.25, expected_electronics_height, 0.001 assert_in_delta 35.85, subject.dimensions["height"] + parameters["lid_thickness"], 0.001 end def test_oled_height_uses_tallest_component_not_display_reference subject = config subject.parameters["top_profile"] = "oled128x64" original_height = subject.dimensions["height"] subject.parameters["oled_display_height"] = 10.0 assert_in_delta original_height, subject.dimensions["height"], 0.001 end def test_older_config_receives_default_oled_display_height data = FeatherCaseWizard::Config.deep_copy(config.data) data["parameters"].delete("oled_display_height") loaded = FeatherCaseWizard::Config.new(data) assert_in_delta 1.5, loaded.parameters["oled_display_height"], 0.001 end def test_older_config_preserves_opening_without_enabling_guide data = FeatherCaseWizard::Config.deep_copy(config.data) data["parameters"].delete("usb_opening_enabled") data["parameters"].delete("usb_guide_enabled") loaded = FeatherCaseWizard::Config.new(data) assert loaded.parameters["usb_opening_enabled"] refute loaded.parameters["usb_guide_enabled"] end def test_usb_guide_requires_opening subject = config subject.parameters["usb_opening_enabled"] = false error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/guide requires/, error.message) end def test_usb_opening_expands_upward_without_moving_bottom_edge parameters = config.parameters compensation = parameters["hole_compensation"] board_top = config.dimensions["floor"] + parameters["board_bottom_clearance"] + 1.6 old_center = board_top + 1.6 old_width = 12.5 + compensation old_height = 7.0 + compensation new_center = old_center + parameters["usb_vertical_offset"] new_width = parameters["usb_opening_width"] + compensation new_height = parameters["usb_opening_height"] + compensation assert_in_delta 1.0, new_width - old_width, 0.001 assert_in_delta old_center - old_height / 2, new_center - new_height / 2, 0.001 assert_in_delta 1.0, (new_center + new_height / 2) - (old_center + old_height / 2), 0.001 end def test_end_clearances_expand_case_length subject = config subject.parameters["usb_board_wall_gap"] = 20.0 subject.parameters["board_rear_clearance"] = 20.0 assert_in_delta 95.6, subject.dimensions["length"], 0.01 end def test_insert_defaults_match_selected_hardware parameters = config.parameters assert_in_delta 4.0, parameters["m25_insert_length"], 0.001 assert_in_delta 3.1, parameters["m25_insert_hole_diameter"], 0.001 assert_in_delta 3.5, parameters["m25_insert_outer_diameter"], 0.001 assert_in_delta 5.0, parameters["tripod_insert_length"], 0.001 assert_in_delta 7.2, parameters["tripod_insert_hole_diameter"], 0.001 assert_in_delta 8.0, parameters["tripod_insert_outer_diameter"], 0.001 end def test_tripod_floor_tracks_insert_length subject = config subject.parameters["tripod_insert_enabled"] = true assert_in_delta 7.0, subject.dimensions["floor"], 0.001 end def test_insert_coupon_uses_finished_pilot_ranges source = FeatherCaseWizard::MODEL_SOURCE.read assert_includes source, "m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];" assert_includes source, "tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];" assert_includes source, "bore_d = diameter;" refute_includes source, "bore_d = diameter + hole_compensation;" end def test_lid_rocker_mirrors_to_installed_feather_side source = FeatherCaseWizard::MODEL_SOURCE.read assert_includes source, "rocker_lid_center_y = case_width - rocker_installed_center_y;" assert_includes source, "translate([rocker_lid_center_x, rocker_lid_center_y, -eps])" assert_includes source, "rocker_installed_center_y - rocker_body_size[1] / 2" assert_includes source, "+ rocker_body_size[1] / 2 + rocker_partition_offset;" 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_combined_battery_and_tripod_case_adds_partition_clearance subject = config subject.parameters["battery_enabled"] = true subject.parameters["tripod_insert_enabled"] = true assert_in_delta 88.6, subject.dimensions["length"], 0.01 assert_in_delta 61.06, subject.dimensions["width"], 0.01 assert_in_delta 31.25, subject.dimensions["height"], 0.01 end def test_battery_board_clearance_moves_the_whole_bay_outward subject = config subject.parameters["battery_enabled"] = true original_width = subject.dimensions["width"] subject.parameters["battery_board_clearance"] += 2.0 assert_in_delta 2.0, subject.dimensions["width"] - original_width, 0.001 end def test_switch_tab_relief_uses_two_mm_lands_in_lid_and_coupon source = FeatherCaseWizard::MODEL_SOURCE.read assert_in_delta 2.0, config.parameters["rocker_tab_panel_thickness"], 0.001 assert_in_delta 2.0, config.parameters["rocker_tab_land"], 0.001 assert_includes source, "module rocker_tab_reliefs(center, opening_size)" assert_equal 2, source.scan(/rocker_tab_reliefs\(/).length - 1 assert_includes source, "edge_y = center[1] + side * opening_size[1] / 2;" assert_includes source, "opening_overlap = rocker_corner_radius;" assert_includes source, "cube([opening_size[0], rocker_tab_land + opening_overlap," refute_includes source, "edge_x = center[0] + side * opening_size[0] / 2;" end def test_switch_moves_two_mm_toward_partition_on_installed_lid assert_in_delta 2.0, config.parameters["rocker_partition_offset"], 0.001 end def test_switch_tab_panel_must_be_thinner_than_lid subject = config subject.parameters["rocker_tab_panel_thickness"] = subject.parameters["lid_thickness"] error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/rocker_tab_panel_thickness/, error.message) end def test_legacy_round_switch_fields_migrate_to_rocker_defaults data = config.data data["parameters"]["switch_hole_diameter"] = 12.0 data["parameters"]["switch_body_length"] = 24.0 subject = FeatherCaseWizard::Config.new(data) refute subject.parameters.key?("switch_hole_diameter") refute subject.parameters.key?("switch_body_length") assert_equal [12.9, 19.4], subject.parameters["rocker_cutout_size"] assert_equal [15.0, 21.0, 21.0], subject.parameters["rocker_body_size"] end def test_battery_partition_requires_three_06_mm_lines subject = config subject.parameters["battery_partition_thickness"] = 1.19 error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/battery_partition_thickness/, error.message) end def test_insert_entry_cannot_be_smaller_than_pilot subject = config subject.parameters["m25_insert_outer_diameter"] = 3.0 error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/m25_insert_outer_diameter/, error.message) end def test_lid_artwork_adds_base_and_accent_parts subject = config subject.parameters["lid_label"] = "GPS" subject.parameters["lid_logo_enabled"] = true assert subject.lid_artwork? assert_equal %w[body lid lid_base lid_accent], subject.printable_parts end def test_lid_artwork_rejects_oled_profile subject = config subject.parameters["top_profile"] = "oled128x64" subject.parameters["lid_label"] = "OLED" error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/not supported with the OLED profile/, error.message) end def test_lid_artwork_orientation_is_named subject = config subject.parameters["lid_art_orientation"] = "diagonal" error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/orientation/, error.message) 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_cutouts_on_same_wall_require_minimum_web subject = config first = cutout("rear", 18.0).merge("name" => "first") second = cutout("rear", 24.0).merge("name" => "second") subject.data["cutouts"] = [first, second] error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/too close/, error.message) end def test_front_cutout_cannot_overlap_usb_opening subject = config subject.data["cutouts"] = [ cutout("front", subject.dimensions["width"] / 2).merge("z" => 11.0) ] error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } assert_match(/USB opening or guide/, error.message) end def test_front_cutout_can_clear_usb_reservation subject = config subject.data["cutouts"] = [cutout("front", 8.0).merge("z" => 11.0)] assert_same subject, subject.validate! 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