Seed sample assets shared between dev and test
One seeder runs from both rake dev and test setup. Photos are CC0,
downloaded into an XDG cache instead of committed to keep binaries out
of git (CI caches them). Test counts became deltas since setup now
seeds a baseline. rake dev also gains live reload via fd | entr.

Assisted-by: Claude Opus 4.8 via Claude Code
change wywotopwrpkunrtnrnrwzquktvozotum
commit f6929f6413eec581e8680911d974f053ff11e3db
author Alpha Chen <alpha@kejadlen.dev>
date
parent 8d9a8cbd
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6f8c8c6..c727384 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,16 @@ jobs:
         with:
           bundler-cache: true
 
+      # Seed photos download from Wikimedia Commons on first use; cache them so
+      # the suite only hits the network when the photo set in lib/seeds.rb
+      # changes. Keyed without restore-keys on purpose: cache files are named by
+      # photo key, so a changed URL must miss and re-download rather than reuse
+      # a stale image.
+      - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+        with:
+          path: ~/.cache/domus/seeds
+          key: domus-seeds-${{ hashFiles('lib/seeds.rb') }}
+
       - run: bundle exec rake test
 
       - run: bundle exec rake check
diff --git a/Rakefile b/Rakefile
index 9553930..1c8de15 100644
--- a/Rakefile
+++ b/Rakefile
@@ -29,10 +29,12 @@ end
 
 task default: %i[test check]
 
-desc "Start dev server"
-task :dev do
-  require "rack"
-  Rack::Server.start(config: "config.ru", Port: 9292)
+desc "Start dev server with live reload (needs fd + entr)"
+task dev: %w[db:migrate db:seed] do
+  # Watch the Ruby/template/asset sources and restart rackup on change. fd is
+  # scoped to lib/ and public/ (plus config.ru) so downloaded seed images and
+  # vendored gems don't trip the reloader.
+  sh "{ fd -e rb -e ru -e css -e svg . lib public; echo config.ru; } | entr -r bundle exec rackup -p 9292"
 end
 
 namespace :db do
@@ -59,6 +61,7 @@ namespace :db do
 
   desc "Seed the database with development data"
   task :seed do
-    # Add seed data here
+    require "seeds"
+    puts Domus::Seeds.call(DOMUS_APP) ? "Seeded." : "Already seeded."
   end
 end
diff --git a/lib/seeds.rb b/lib/seeds.rb
new file mode 100644
index 0000000..9f816f7
--- /dev/null
+++ b/lib/seeds.rb
@@ -0,0 +1,103 @@
+# rbs_inline: enabled
+
+require "fileutils"
+require "open-uri"
+require "pathname"
+
+module Domus
+  # Development and test seed data. Populates a few sample assets so a fresh
+  # checkout has something to look at, using real CC0 (public domain) cat
+  # photos from Wikimedia Commons. Photos download once into an XDG cache and
+  # are reused across runs, so dev and the test suite share both the seeder
+  # and the cached images.
+  module Seeds
+    USER_AGENT = "domus-seed/1.0 (https://github.com/kejadlen/domus)"
+
+    # CC0 cat photos keyed by a short name. CC0 needs no attribution, but the
+    # Wikimedia Commons file pages document the license and provenance:
+    #   https://creativecommons.org/publicdomain/zero/1.0/
+    #   grass:  commons.wikimedia.org/wiki/File:Domestic_shorthair_cat_portrait_in_grass.jpg
+    #   tabby:  commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg
+    #   paw:    commons.wikimedia.org/wiki/File:Domestic_cat_paw.jpg
+    #   kitten: commons.wikimedia.org/wiki/File:A_mother_cat_of_Meitei_domestic_cat_breed_(Meitei_house_cat_variety)_suckling_her_only_little_newborn_baby_kitten_05.jpg
+    PHOTOS = {
+      grass: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3c/Domestic_shorthair_cat_portrait_in_grass.jpg/960px-Domestic_shorthair_cat_portrait_in_grass.jpg",
+      tabby: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Tabby_cat_with_blue_eyes-3336579.jpg/960px-Tabby_cat_with_blue_eyes-3336579.jpg",
+      paw: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Domestic_cat_paw.jpg/960px-Domestic_cat_paw.jpg",
+      kitten: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/A_mother_cat_of_Meitei_domestic_cat_breed_%28Meitei_house_cat_variety%29_suckling_her_only_little_newborn_baby_kitten_05.jpg/960px-A_mother_cat_of_Meitei_domestic_cat_breed_%28Meitei_house_cat_variety%29_suckling_her_only_little_newborn_baby_kitten_05.jpg",
+    } #: Hash[Symbol, String]
+
+    # Sample household assets. The drill is intentionally photoless so the
+    # asset page's add-photo affordance shows up in dev.
+    ASSETS = [
+      {
+        name: "Bosch 800 dishwasher",
+        description: "Stainless interior, third rack. Replaces the GE that flooded.",
+        photos: %i[grass],
+      },
+      {
+        name: "LG French-door refrigerator",
+        description: "Counter-depth, craft-ice maker. Bought refurbished in 2023.",
+        photos: %i[tabby kitten],
+      },
+      {
+        name: "Weber Genesis grill",
+        description: "Three-burner propane. Cover lives in the shed over winter.",
+        photos: %i[paw],
+      },
+      {
+        name: "Ryobi cordless drill",
+        description: "18V ONE+. Two batteries, charger in the garage drawer.",
+        photos: [],
+      },
+    ] #: Array[Hash[Symbol, untyped]]
+
+    # Seeds the database when it's empty, returning true when it inserted data
+    # and false when assets already exist. The empty check keeps repeated dev
+    # runs and CI idempotent. Photos download (and cache) outside the
+    # transaction so a slow network doesn't hold the write lock open.
+    # : (App) -> bool
+    def self.call(app)
+      db = app.db
+      return false unless db[:assets].empty?
+
+      keys = ASSETS.flat_map { |spec| spec[:photos] }.uniq #: Array[Symbol]
+      sources = keys.to_h { |key| [key, fetch(key)] } #: Hash[Symbol, Pathname]
+
+      db.transaction do
+        now = Time.now
+        ASSETS.each do |spec|
+          asset_id = db[:assets].insert(name: spec[:name], description: spec[:description], created_at: now)
+          spec[:photos].each do |key|
+            file_id = db[:files].insert(extension: ".jpg", created_at: now)
+            dest = app.file_path(id: file_id, extension: ".jpg")
+            FileUtils.mkdir_p(dest.dirname)
+            FileUtils.cp(sources.fetch(key), dest)
+            db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+          end
+        end
+      end
+      true
+    end
+
+    # Returns the cached path to a seed photo, downloading it on first use.
+    # : (Symbol) -> Pathname
+    def self.fetch(key)
+      path = cache_dir / "#{key}.jpg"
+      return path if path.exist?
+
+      cache_dir.mkpath
+      data = URI.parse(PHOTOS.fetch(key)).open("User-Agent" => USER_AGENT, &:read) #: String
+      path.binwrite(data)
+      path
+    end
+
+    # The XDG cache directory for downloaded seed photos.
+    # : () -> Pathname
+    def self.cache_dir
+      base = ENV["XDG_CACHE_HOME"]
+      base = "#{Dir.home}/.cache" if base.nil? || base.empty?
+      Pathname(base) / "domus" / "seeds"
+    end
+  end
+end
diff --git a/sig/generated/seeds.rbs b/sig/generated/seeds.rbs
new file mode 100644
index 0000000..a399e60
--- /dev/null
+++ b/sig/generated/seeds.rbs
@@ -0,0 +1,40 @@
+# Generated from lib/seeds.rb with RBS::Inline
+
+module Domus
+  # Development and test seed data. Populates a few sample assets so a fresh
+  # checkout has something to look at, using real CC0 (public domain) cat
+  # photos from Wikimedia Commons. Photos download once into an XDG cache and
+  # are reused across runs, so dev and the test suite share both the seeder
+  # and the cached images.
+  module Seeds
+    USER_AGENT: ::String
+
+    # CC0 cat photos keyed by a short name. CC0 needs no attribution, but the
+    # Wikimedia Commons file pages document the license and provenance:
+    #   https://creativecommons.org/publicdomain/zero/1.0/
+    #   grass:  commons.wikimedia.org/wiki/File:Domestic_shorthair_cat_portrait_in_grass.jpg
+    #   tabby:  commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg
+    #   paw:    commons.wikimedia.org/wiki/File:Domestic_cat_paw.jpg
+    #   kitten: commons.wikimedia.org/wiki/File:A_mother_cat_of_Meitei_domestic_cat_breed_(Meitei_house_cat_variety)_suckling_her_only_little_newborn_baby_kitten_05.jpg
+    PHOTOS: Hash[Symbol, String]
+
+    # Sample household assets. The drill is intentionally photoless so the
+    # asset page's add-photo affordance shows up in dev.
+    ASSETS: Array[Hash[Symbol, untyped]]
+
+    # Seeds the database when it's empty, returning true when it inserted data
+    # and false when assets already exist. The empty check keeps repeated dev
+    # runs and CI idempotent. Photos download (and cache) outside the
+    # transaction so a slow network doesn't hold the write lock open.
+    # : (App) -> bool
+    def self.call: (untyped app) -> untyped
+
+    # Returns the cached path to a seed photo, downloading it on first use.
+    # : (Symbol) -> Pathname
+    def self.fetch: (untyped key) -> untyped
+
+    # The XDG cache directory for downloaded seed photos.
+    # : () -> Pathname
+    def self.cache_dir: () -> untyped
+  end
+end
diff --git a/storage/files/1.jpg b/storage/files/1.jpg
new file mode 100644
index 0000000..1a9a8c0
Binary files /dev/null and b/storage/files/1.jpg differ
diff --git a/storage/files/2.jpg b/storage/files/2.jpg
new file mode 100644
index 0000000..c7fc297
Binary files /dev/null and b/storage/files/2.jpg differ
diff --git a/storage/files/3.jpg b/storage/files/3.jpg
new file mode 100644
index 0000000..7d2ff03
Binary files /dev/null and b/storage/files/3.jpg differ
diff --git a/storage/files/4.jpg b/storage/files/4.jpg
new file mode 100644
index 0000000..988ce65
Binary files /dev/null and b/storage/files/4.jpg differ
diff --git a/test/test_app.rb b/test/test_app.rb
index be61a3d..9d7c06b 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -11,7 +11,15 @@ class TestApp < Minitest::Test
 
   def domus = Domus::Web.opts.fetch(:app)
 
+  # Every test starts from the shared seed baseline, so dev and the suite
+  # exercise the same Domus::Seeds path. Tests that need a clean slate call
+  # #wipe; tests that assert on counts compare deltas around the action.
   def setup
+    wipe
+    Domus::Seeds.call(domus)
+  end
+
+  def wipe
     domus.db[:asset_attachments].delete
     domus.db[:assets].delete
     domus.db[:files].delete
@@ -25,6 +33,7 @@ class TestApp < Minitest::Test
   end
 
   def test_root_lists_recent_assets_newest_first
+    wipe
     now = Time.now
     domus.db[:assets].insert(name: "Older asset", created_at: now - 86_400)
     domus.db[:assets].insert(name: "Newer asset", created_at: now)
@@ -46,6 +55,7 @@ class TestApp < Minitest::Test
   end
 
   def test_root_empty_state
+    wipe
     get "/"
     assert_equal 200, last_response.status
     assert_includes last_response.body, "Nothing tracked yet."
@@ -123,7 +133,7 @@ class TestApp < Minitest::Test
 
   def test_get_file_serves_stored_image
     post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
-    file = domus.db[:files].first
+    file = domus.db[:files].order(:id).last
 
     get "/files/#{file[:id]}#{file[:extension]}"
     assert_equal 200, last_response.status
@@ -138,82 +148,93 @@ class TestApp < Minitest::Test
   end
 
   def test_upload_image_saves_file_and_redirects
+    before = domus.db[:files].count
     post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
 
     assert_equal 302, last_response.status
-    rows = domus.db[:files].all
-    assert_equal 1, rows.size
-    assert_equal ".png", rows.first[:extension]
-    assert_equal "fake-png-bytes", File.read(domus.file_path(rows.first))
+    assert_equal before + 1, domus.db[:files].count
+    row = domus.db[:files].order(:id).last
+    assert_equal ".png", row[:extension]
+    assert_equal "fake-png-bytes", File.read(domus.file_path(row))
   end
 
   def test_upload_without_file_is_rejected
+    before = domus.db[:files].count
     post "/files", {}
 
     assert_equal 422, last_response.status
-    assert_equal 0, domus.db[:files].count
+    assert_equal before, domus.db[:files].count
   end
 
   def test_upload_rejects_non_image
+    before = domus.db[:files].count
     post "/files", "file" => upload("notes.txt", "text/plain", "hello")
 
     assert_equal 422, last_response.status
-    assert_equal 0, domus.db[:files].count
+    assert_equal before, domus.db[:files].count
   end
 
   def test_upload_rejects_unsupported_extension
+    before = domus.db[:files].count
     post "/files", "file" => upload("sketch.svg", "image/svg+xml", "<svg/>")
 
     assert_equal 422, last_response.status
-    assert_equal 0, domus.db[:files].count
+    assert_equal before, domus.db[:files].count
   end
 
   def test_upload_rejects_oversized_file
+    before = domus.db[:files].count
     oversized = "x" * (Domus::Web::MAX_UPLOAD_BYTES + 1)
     post "/files", "file" => upload("huge.png", "image/png", oversized)
 
     assert_equal 422, last_response.status
-    assert_equal 0, domus.db[:files].count
+    assert_equal before, domus.db[:files].count
   end
 
   def test_upload_with_asset_name_creates_asset_and_attachment
+    before = domus.db[:assets].count
     post "/files", "file" => upload("photo.png", "image/png", "bytes"), "asset_names[]" => "Laptop"
 
     assert_equal 302, last_response.status
-    asset = domus.db[:assets].first
-    refute_nil asset
+    assert_equal before + 1, domus.db[:assets].count
+    asset = domus.db[:assets].order(:id).last
     assert_equal "Laptop", asset[:name]
-    file = domus.db[:files].first
-    attachment = domus.db[:asset_attachments].first
+    file = domus.db[:files].order(:id).last
+    attachment = domus.db[:asset_attachments].where(asset_id: asset[:id]).first
     refute_nil attachment
-    assert_equal asset[:id], attachment[:asset_id]
     assert_equal file[:id], attachment[:file_id]
   end
 
   def test_upload_with_multiple_asset_names_creates_all
+    assets_before = domus.db[:assets].count
+    attachments_before = domus.db[:asset_attachments].count
     post "/files", "file" => upload("photo.png", "image/png", "bytes"),
       "asset_names[]" => ["Camera", "Laptop"]
 
     assert_equal 302, last_response.status
-    assert_equal 2, domus.db[:assets].count
-    assert_equal 2, domus.db[:asset_attachments].count
+    assert_equal assets_before + 2, domus.db[:assets].count
+    assert_equal attachments_before + 2, domus.db[:asset_attachments].count
   end
 
   def test_upload_with_blank_asset_names_ignored
+    assets_before = domus.db[:assets].count
+    attachments_before = domus.db[:asset_attachments].count
     post "/files", "file" => upload("photo.png", "image/png", "bytes"),
       "asset_names[]" => ["", "  "]
 
     assert_equal 302, last_response.status
-    assert_equal 0, domus.db[:assets].count
-    assert_equal 0, domus.db[:asset_attachments].count
+    assert_equal assets_before, domus.db[:assets].count
+    assert_equal attachments_before, domus.db[:asset_attachments].count
   end
 
   def test_upload_without_asset_names_creates_no_assets
+    assets_before = domus.db[:assets].count
+    attachments_before = domus.db[:asset_attachments].count
     post "/files", "file" => upload("photo.png", "image/png", "bytes")
 
     assert_equal 302, last_response.status
-    assert_equal 0, domus.db[:assets].count
-    assert_equal 0, domus.db[:asset_attachments].count
+    assert_equal assets_before, domus.db[:assets].count
+    assert_equal attachments_before, domus.db[:asset_attachments].count
   end
 
   private
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2485587..f0701ed 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -6,6 +6,7 @@ require "pathname"
 
 require_relative "../lib/app"
 require_relative "../lib/web"
+require_relative "../lib/seeds"
 
 storage = Dir.mktmpdir("domus-test")
 at_exit { FileUtils.rm_rf(storage) }