From 7cc064b4a88d07046913337e235b94c93991197b Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 16:36:20 -0400 Subject: [PATCH 1/2] feat: give contents ownership of module membership and order Links and games join modules only through contents, which append to the end of their siblings. Co-Authored-By: Claude Opus 5.5 --- .../admin/content_modules_controller.rb | 2 +- app/controllers/admin/games_controller.rb | 6 +- app/controllers/admin/links_controller.rb | 12 +-- app/models/concerns/contentable.rb | 6 +- app/models/concerns/ordered.rb | 16 ++++ app/models/content.rb | 13 +++- app/models/content_module.rb | 26 ++++--- app/models/game.rb | 9 +-- app/models/link.rb | 7 +- .../admin/content_modules/_form.html.erb | 3 - app/views/admin/content_modules/edit.html.erb | 1 - app/views/admin/games/_form.html.erb | 1 - app/views/admin/games/_game.html.erb | 4 - app/views/admin/games/new.html.erb | 3 - app/views/admin/links/_form.html.erb | 5 +- app/views/admin/links/edit.html.erb | 2 +- app/views/admin/links/new.html.erb | 2 +- ...20261031181034_add_position_to_contents.rb | 19 +++++ ...ove_content_module_from_links_and_games.rb | 27 +++++++ db/schema.rb | 12 +-- db/seeds.rb | 33 +++++++- .../admin/content_modules_controller_test.rb | 21 +++-- .../admin/games_controller_test.rb | 11 +-- .../admin/links_controller_test.rb | 9 ++- test/fixtures/contents.yml | 8 +- test/fixtures/games.yml | 2 - test/fixtures/links.yml | 2 - test/models/content_module_test.rb | 77 +++++++++++++++++-- test/models/content_test.rb | 45 +++++++++++ test/models/game_test.rb | 31 ++++++-- test/models/link_test.rb | 21 ++++- 31 files changed, 334 insertions(+), 102 deletions(-) create mode 100644 db/migrate/20261031181034_add_position_to_contents.rb create mode 100644 db/migrate/20261031181544_remove_content_module_from_links_and_games.rb diff --git a/app/controllers/admin/content_modules_controller.rb b/app/controllers/admin/content_modules_controller.rb index 1074f86..c78c544 100644 --- a/app/controllers/admin/content_modules_controller.rb +++ b/app/controllers/admin/content_modules_controller.rb @@ -49,6 +49,6 @@ def set_content_module end def content_module_params - params.expect(content_module: [ :name, :program_id, :level, :position ]) + params.expect(content_module: [ :name, :program_id, :level ]) end end diff --git a/app/controllers/admin/games_controller.rb b/app/controllers/admin/games_controller.rb index 790a6c0..3e40cb6 100644 --- a/app/controllers/admin/games_controller.rb +++ b/app/controllers/admin/games_controller.rb @@ -6,8 +6,7 @@ def index end def new - @game = Game.new(content_module_id: params[:content_module_id]) - @content_module = @game.content_module + @game = Game.new end def edit @@ -18,7 +17,6 @@ def create respond_to do |format| if @game.save - Content.create!(content_module: @game.content_module, contentable: @game) if @game.content_module format.html { redirect_back(fallback_location: admin_games_path, notice: "Game was successfully created.") } else format.html { render :new, status: :unprocessable_content } @@ -53,6 +51,6 @@ def set_game end def game_params - params.expect(game: [ :title, :slug, :content_module_id, :description ]) + params.expect(game: [ :title, :slug, :description ]) end end diff --git a/app/controllers/admin/links_controller.rb b/app/controllers/admin/links_controller.rb index 219330f..be7aa74 100644 --- a/app/controllers/admin/links_controller.rb +++ b/app/controllers/admin/links_controller.rb @@ -3,14 +3,15 @@ class Admin::LinksController < AdminController before_action :set_link, only: %i[edit update destroy] def new - @link = Link.new(content_module: @content_module) + @link = Link.new end def create - @link = Link.new(link_params.merge(content_module: @content_module)) + @link = Link.new(link_params) + + @link.build_content(content_module: @content_module) if @link.save - @content_module.contents.create!(contentable: @link) redirect_to edit_admin_content_module_path(@content_module), notice: "Link was successfully created." else render :new, status: :unprocessable_entity @@ -29,8 +30,9 @@ def update end def destroy + content_module = @link.content_module @link.destroy! - redirect_to edit_admin_content_module_path(@link.content_module), notice: "Link was successfully deleted.", status: :see_other + redirect_to edit_admin_content_module_path(content_module), notice: "Link was successfully deleted.", status: :see_other end private @@ -43,6 +45,6 @@ def set_link end def link_params - params.expect(link: [ :title, :url, :link_type, :position ]) + params.expect(link: [ :title, :url, :link_type ]) end end diff --git a/app/models/concerns/contentable.rb b/app/models/concerns/contentable.rb index 9ce103c..6c772a4 100644 --- a/app/models/concerns/contentable.rb +++ b/app/models/concerns/contentable.rb @@ -1,7 +1,9 @@ +# A content owns its link, which is deleted together with it. +# A game is shared across modules, so it is kept when a content is removed. module Contentable extend ActiveSupport::Concern - included do - has_one :content, as: :contentable + def owned_by_content? + false end end diff --git a/app/models/concerns/ordered.rb b/app/models/concerns/ordered.rb index 2e56b03..f6ea8e9 100644 --- a/app/models/concerns/ordered.rb +++ b/app/models/concerns/ordered.rb @@ -1,7 +1,23 @@ +# Orders records by an integer +position+ column. New records go to the end of their siblings. +# +# A model that includes this must define +position_siblings+, the relation of records it is +# ordered among (for example, the contents of one module). module Ordered extend ActiveSupport::Concern included do scope :ordered, -> { order(:position) } + + before_create :append_to_siblings + end + + private + + def position_siblings + raise NotImplementedError, "#{self.class} must define #position_siblings to be ordered" + end + + def append_to_siblings + self.position = (position_siblings.maximum(:position) || 0) + 1 end end diff --git a/app/models/content.rb b/app/models/content.rb index f1dc551..d1410d7 100644 --- a/app/models/content.rb +++ b/app/models/content.rb @@ -1,15 +1,26 @@ class Content < ApplicationRecord + include Ordered + belongs_to :content_module delegated_type :contentable, types: %w[Link Game] + validates :contentable_id, uniqueness: { + scope: [ :content_module_id, :contentable_type ], + message: "is already in this module" + } + # Ensure the associated contentable record is destroyed if allowed before destroying this content. # This allows us to have dependent: :destroy behavior for contentable records. before_destroy :destroy_owned_record private + def position_siblings + content_module.contents + end + def destroy_owned_record - contentable.destroy if contentable.destroy_with_attachable? + contentable.destroy if contentable.owned_by_content? end end diff --git a/app/models/content_module.rb b/app/models/content_module.rb index 9013471..fac8aed 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -2,18 +2,26 @@ class ContentModule < ApplicationRecord include Ordered belongs_to :program - has_many :contents, dependent: :destroy - has_many :links, -> { ordered }, - through: :contents, - source: :contentable, - source_type: "Link" - has_many :games, - through: :contents, - source: :contentable, - source_type: "Game" + has_many :contents, -> { ordered }, dependent: :destroy + has_many :games, through: :contents, source: :contentable, source_type: "Game" + has_many :links, through: :contents, source: :contentable, source_type: "Link" has_many :classroom_modules, dependent: :restrict_with_error enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true validates :name, :level, presence: true + + # A module that joins another program or level goes to the end of it. + before_update :append_to_siblings, if: -> { program_id_changed? || level_changed? } + + def available_games + Game.where.not(id: games).order(:title) + end + + private + + # Modules are ordered within one program and level, the same grouping the admin index shows. + def position_siblings + program.content_modules.where(level: level) + end end diff --git a/app/models/game.rb b/app/models/game.rb index 38d9fb5..7c0cad3 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,15 +1,10 @@ class Game < ApplicationRecord include Contentable - belongs_to :content_module, optional: true - has_many :game_attempts, dependent: :destroy - + has_many :contents, as: :contentable, dependent: :destroy + has_many :content_modules, through: :contents has_many :game_attempts, dependent: :destroy validates :title, :slug, presence: true validates :title, :slug, uniqueness: true - - def destroy_with_attachable? - false - end end diff --git a/app/models/link.rb b/app/models/link.rb index 48ab603..f12510a 100644 --- a/app/models/link.rb +++ b/app/models/link.rb @@ -1,15 +1,16 @@ class Link < ApplicationRecord - include Ordered include Contentable - belongs_to :content_module + # :delete skips Content callbacks to avoid a destroy loop + has_one :content, as: :contentable, dependent: :delete + has_one :content_module, through: :content enum :link_type, { survey: "survey" }, validate: true validates :title, :url, :link_type, presence: true validates :url, format: { with: /\Ahttps?:\/\/.+\z/i, message: "must start with http:// or https://" }, allow_blank: true - def destroy_with_attachable? + def owned_by_content? true end end diff --git a/app/views/admin/content_modules/_form.html.erb b/app/views/admin/content_modules/_form.html.erb index c9449b5..c343ada 100644 --- a/app/views/admin/content_modules/_form.html.erb +++ b/app/views/admin/content_modules/_form.html.erb @@ -22,9 +22,6 @@ <%= form.select :level, ContentModule.levels.keys.map { |l| [ l.humanize, l ] }, { include_blank: "Select a level" }, class: "select w-full" %> - <%= form.label :position, class: "label" %> - <%= form.number_field :position, class: "input w-full" %> - <%= form.submit class: "btn btn-primary" %> <% end %> diff --git a/app/views/admin/content_modules/edit.html.erb b/app/views/admin/content_modules/edit.html.erb index 0ef6e3b..ad81a80 100644 --- a/app/views/admin/content_modules/edit.html.erb +++ b/app/views/admin/content_modules/edit.html.erb @@ -57,7 +57,6 @@

Games

- <%= link_to "Add game", new_admin_game_path(content_module_id: @content_module.id), class: "btn btn-sm btn-primary" %>
<% if @content_module.games.any? %> diff --git a/app/views/admin/games/_form.html.erb b/app/views/admin/games/_form.html.erb index e16b914..a17e651 100644 --- a/app/views/admin/games/_form.html.erb +++ b/app/views/admin/games/_form.html.erb @@ -1,5 +1,4 @@ <%= form_with(model: [:admin, game]) do |form| %> - <%= form.hidden_field :content_module_id if game.content_module_id.present? %>
<% if game.errors.any? %>
diff --git a/app/views/admin/games/_game.html.erb b/app/views/admin/games/_game.html.erb index f7ddbd7..f585356 100644 --- a/app/views/admin/games/_game.html.erb +++ b/app/views/admin/games/_game.html.erb @@ -7,10 +7,6 @@ Slug: <%= game.slug %>
-
- Content module: - <%= game.content_module_id %> -
Description: <%= game.description %> diff --git a/app/views/admin/games/new.html.erb b/app/views/admin/games/new.html.erb index 1cfe2e0..99983d5 100644 --- a/app/views/admin/games/new.html.erb +++ b/app/views/admin/games/new.html.erb @@ -3,9 +3,6 @@ diff --git a/app/views/admin/links/_form.html.erb b/app/views/admin/links/_form.html.erb index 4709ee3..a897152 100644 --- a/app/views/admin/links/_form.html.erb +++ b/app/views/admin/links/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with(model: link.persisted? ? [:admin, link] : [:admin, link.content_module, link]) do |form| %> +<%= form_with(model: link.persisted? ? [:admin, link] : [:admin, content_module, link]) do |form| %>
<% if link.errors.any? %>
@@ -21,9 +21,6 @@ <%= form.select :link_type, Link.link_types.keys.map { |t| [ t.humanize, t ] }, { include_blank: "Select a type" }, class: "select w-full" %> - <%= form.label :position, class: "label" %> - <%= form.number_field :position, class: "input w-full" %> - <%= form.submit class: "btn btn-primary" %>
<% end %> diff --git a/app/views/admin/links/edit.html.erb b/app/views/admin/links/edit.html.erb index b9db752..bba1e74 100644 --- a/app/views/admin/links/edit.html.erb +++ b/app/views/admin/links/edit.html.erb @@ -11,7 +11,7 @@

Edit Link

- <%= render "form", link: @link %> + <%= render "form", link: @link, content_module: @link.content_module %>
diff --git a/app/views/admin/links/new.html.erb b/app/views/admin/links/new.html.erb index 35513db..05b0d88 100644 --- a/app/views/admin/links/new.html.erb +++ b/app/views/admin/links/new.html.erb @@ -11,7 +11,7 @@

New Link

- <%= render "form", link: @link %> + <%= render "form", link: @link, content_module: @content_module %>
diff --git a/db/migrate/20261031181034_add_position_to_contents.rb b/db/migrate/20261031181034_add_position_to_contents.rb new file mode 100644 index 0000000..b751586 --- /dev/null +++ b/db/migrate/20261031181034_add_position_to_contents.rb @@ -0,0 +1,19 @@ +class AddPositionToContents < ActiveRecord::Migration[8.1] + def change + add_column :contents, :position, :integer + add_index :contents, [ :content_module_id, :position ] + add_index :contents, [ :content_module_id, :contentable_type, :contentable_id ], unique: true, name: "index_contents_on_module_and_contentable" + + reversible do |dir| + dir.up do + # Numbers contents in id order, so link positions set before this migration are not kept. + execute <<~SQL + UPDATE contents SET position = ( + SELECT COUNT(*) FROM contents earlier + WHERE earlier.content_module_id = contents.content_module_id AND earlier.id <= contents.id + ) + SQL + end + end + end +end diff --git a/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb b/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb new file mode 100644 index 0000000..51b9ca4 --- /dev/null +++ b/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb @@ -0,0 +1,27 @@ +class RemoveContentModuleFromLinksAndGames < ActiveRecord::Migration[8.1] + def up + remove_reference :links, :content_module, foreign_key: true, index: true + remove_column :links, :position + remove_reference :games, :content_module, foreign_key: true, index: true + end + + def down + add_reference :links, :content_module, foreign_key: true, index: true + add_column :links, :position, :integer + add_reference :games, :content_module, foreign_key: true, index: true + + execute <<~SQL + UPDATE links SET + content_module_id = (SELECT content_module_id FROM contents WHERE contentable_type = 'Link' AND contentable_id = links.id), + position = (SELECT position FROM contents WHERE contentable_type = 'Link' AND contentable_id = links.id) + SQL + + # A game can be in many modules, but the old column held one, so pick the lowest content id. + execute <<~SQL + UPDATE games SET + content_module_id = (SELECT content_module_id FROM contents WHERE contentable_type = 'Game' AND contentable_id = games.id ORDER BY id LIMIT 1) + SQL + + change_column_null :links, :content_module_id, false + end +end diff --git a/db/schema.rb b/db/schema.rb index a58913e..fc75edd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_29_190944) do +ActiveRecord::Schema[8.1].define(version: 2026_10_31_181544) do create_table "classroom_modules", force: :cascade do |t| t.integer "classroom_program_id", null: false t.integer "content_module_id", null: false @@ -60,7 +60,10 @@ t.integer "contentable_id", null: false t.string "contentable_type", null: false t.datetime "created_at", null: false + t.integer "position" t.datetime "updated_at", null: false + t.index ["content_module_id", "contentable_type", "contentable_id"], name: "index_contents_on_module_and_contentable", unique: true + t.index ["content_module_id", "position"], name: "index_contents_on_content_module_id_and_position" t.index ["content_module_id"], name: "index_contents_on_content_module_id" t.index ["contentable_type", "contentable_id"], name: "index_contents_on_contentable" end @@ -81,25 +84,20 @@ end create_table "games", force: :cascade do |t| - t.integer "content_module_id" t.datetime "created_at", null: false t.text "description" t.string "slug", null: false t.string "title", null: false t.datetime "updated_at", null: false - t.index ["content_module_id"], name: "index_games_on_content_module_id" t.index ["slug"], name: "index_games_on_slug", unique: true end create_table "links", force: :cascade do |t| - t.integer "content_module_id", null: false t.datetime "created_at", null: false t.string "link_type", null: false - t.integer "position" t.string "title", null: false t.datetime "updated_at", null: false t.string "url", null: false - t.index ["content_module_id"], name: "index_links_on_content_module_id" end create_table "programs", force: :cascade do |t| @@ -172,8 +170,6 @@ add_foreign_key "contents", "content_modules" add_foreign_key "game_attempts", "games" add_foreign_key "game_attempts", "students" - add_foreign_key "games", "content_modules" - add_foreign_key "links", "content_modules" add_foreign_key "sessions", "users" add_foreign_key "student_sessions", "students" add_foreign_key "students", "classrooms" diff --git a/db/seeds.rb b/db/seeds.rb index d8385d2..c064636 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,13 +16,38 @@ def maybe(&block) # Production should not have any seeded data for the content or schools return if Rails.env.production? +fruit_veggie_quiz = Game.find_or_create_by!(slug: "fruit-veggie-quiz") do |game| + game.title = "Fruit and Veggie Quiz" + game.description = "Sort fruits and vegetables into the right food groups." +end +sleep_habits = Game.find_or_create_by!(slug: "sleep-habits") do |game| + game.title = "Sleep Habits" + game.description = "Pick the habits that help you get a good night's sleep." +end +# Intentionally not attached to any module so the "Add game" dropdown has an option. +Game.find_or_create_by!(slug: "hydration-heroes") do |game| + game.title = "Hydration Heroes" + game.description = "Learn how much water your body needs each day." +end +games_by_program = { programs.first => fruit_veggie_quiz, programs.last => sleep_habits } + +# The first module of each program and level has a survey then a game. +# The second module of the basic level has a game then a survey. +# Other modules have only a survey. ContentModule.levels.keys.each do |level| programs.each do |program| + game = games_by_program.fetch(program) 2.times do |i| - mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level, position: i + 1) - # TODO: Update this when the delegated types migration is completed - link = Link.new(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey", position: 1, content_module: mod) - mod.contents.create! contentable: link + mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level) + link = Link.new(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey") + contentables = if i.zero? + [ link, game ] + elsif level == "basic" + [ game, link ] + else + [ link ] + end + contentables.each { |contentable| mod.contents.create!(contentable: contentable) } end end end diff --git a/test/controllers/admin/content_modules_controller_test.rb b/test/controllers/admin/content_modules_controller_test.rb index 1336eb3..639c00e 100644 --- a/test/controllers/admin/content_modules_controller_test.rb +++ b/test/controllers/admin/content_modules_controller_test.rb @@ -25,12 +25,22 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest test "should create content module" do assert_difference "ContentModule.count" do post admin_content_modules_url, params: { - content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate", position: 1 } + content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate" } } end assert_redirected_to admin_content_modules_url end + test "create appends the module and ignores a submitted position" do + ContentModule.create!(program: programs(:one), level: "moderate", name: "Existing") + + post admin_content_modules_url, params: { + content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate", position: 99 } + } + + assert_equal 2, ContentModule.find_by!(name: "New Module").position + end + test "should not create with missing name" do assert_no_difference "ContentModule.count" do post admin_content_modules_url, params: { @@ -46,11 +56,10 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest end test "edit lists links ordered by position" do - @content_module.links.destroy_all - last_link = Link.create!(content_module: @content_module, title: "Last Link", url: "https://example.com/2", link_type: "survey", position: 2) - first_link = Link.create!(content_module: @content_module, title: "First Link", url: "https://example.com/1", link_type: "survey", position: 1) - Content.create!(content_module: @content_module, contentable: last_link) - Content.create!(content_module: @content_module, contentable: first_link) + contents(:two).destroy! + @content_module.contents.create!(contentable: Link.new(title: "Last Link", url: "https://example.com/2", link_type: "survey")) + first = @content_module.contents.create!(contentable: Link.new(title: "First Link", url: "https://example.com/1", link_type: "survey")) + first.update!(position: 0) get edit_admin_content_module_url(@content_module) diff --git a/test/controllers/admin/games_controller_test.rb b/test/controllers/admin/games_controller_test.rb index 6fae0f6..d3f4b9e 100644 --- a/test/controllers/admin/games_controller_test.rb +++ b/test/controllers/admin/games_controller_test.rb @@ -3,7 +3,6 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest setup do @game = games(:one) - @content_module = content_modules(:one) sign_in_as users(:one) end @@ -13,13 +12,15 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest end test "should get new" do - get new_admin_game_url(content_module_id: @content_module.id) + get new_admin_game_url assert_response :success end - test "should create game" do - assert_difference [ "Game.count", "Content.count" ] do - post admin_games_url, params: { game: { slug: "new-game", title: "New Game", content_module_id: @content_module.id } } + test "creating a game does not add it to a module" do + assert_difference "Game.count" do + assert_no_difference "Content.count" do + post admin_games_url, params: { game: { slug: "new-game", title: "New Game" } } + end end assert_redirected_to admin_games_url diff --git a/test/controllers/admin/links_controller_test.rb b/test/controllers/admin/links_controller_test.rb index 8e9a7a5..72ed1b4 100644 --- a/test/controllers/admin/links_controller_test.rb +++ b/test/controllers/admin/links_controller_test.rb @@ -12,17 +12,18 @@ class Admin::LinksControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "should create link" do - assert_difference "Link.count" do + test "create adds the new link to the end of the module" do + assert_difference [ "Link.count", "@mod.contents.count" ], 1 do post admin_content_module_links_url(@mod), params: { - link: { title: "New Survey", url: "https://example.com/new", link_type: "survey", position: 3 } + link: { title: "New Survey", url: "https://example.com/new", link_type: "survey" } } end assert_redirected_to edit_admin_content_module_url(@mod) + assert_equal "New Survey", @mod.contents.last.contentable.title end test "should not create with missing title" do - assert_no_difference "Link.count" do + assert_no_difference [ "Link.count", "Content.count" ] do post admin_content_module_links_url(@mod), params: { link: { title: "", url: "https://example.com", link_type: "survey" } } diff --git a/test/fixtures/contents.yml b/test/fixtures/contents.yml index 1d64a6d..d60d7f6 100644 --- a/test/fixtures/contents.yml +++ b/test/fixtures/contents.yml @@ -2,10 +2,10 @@ one: content_module: one - contentable_id: <%= ActiveRecord::FixtureSet.identify(:one) %> - contentable_type: Game + contentable: one (Game) + position: 1 two: content_module: one - contentable_id: <%= ActiveRecord::FixtureSet.identify(:one) %> - contentable_type: Link + contentable: one (Link) + position: 2 diff --git a/test/fixtures/games.yml b/test/fixtures/games.yml index bd0ba0c..14779e4 100644 --- a/test/fixtures/games.yml +++ b/test/fixtures/games.yml @@ -3,11 +3,9 @@ one: title: Game 1 slug: game-1 - content_module: one description: MyText two: title: Game 2 slug: game-2 - content_module: one description: MyText diff --git a/test/fixtures/links.yml b/test/fixtures/links.yml index c6fce72..e6b2526 100644 --- a/test/fixtures/links.yml +++ b/test/fixtures/links.yml @@ -1,6 +1,4 @@ one: - content_module: one title: Health Survey url: https://example.com/survey/1 link_type: survey - position: 1 diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb index c8df5bd..3a1c3d9 100644 --- a/test/models/content_module_test.rb +++ b/test/models/content_module_test.rb @@ -28,17 +28,77 @@ class ContentModuleTest < ActiveSupport::TestCase test "destroys associated links" do content_module = ContentModule.create!(program: @program, level: "basic", name: "With Links") - link = Link.create!(content_module: content_module, title: "A Link", url: "https://example.com", link_type: "survey") - Content.create!(content_module: content_module, contentable: link) + link = Link.new(title: "A Link", url: "https://example.com", link_type: "survey") + content_module.contents.create!(contentable: link) assert_difference "Link.count", -1 do content_module.destroy! end end - test "exposes delegated contentables" do - assert_equal [ links(:one) ], content_modules(:one).links.to_a - assert_equal [ games(:one) ], content_modules(:one).games.to_a + test "available games exclude games already in the module" do + assert_not_includes content_modules(:one).available_games, games(:one) + end + + test "available games include games only in another module" do + other_module = ContentModule.create!(program: @program, level: "basic", name: "Other Module") + other_module.contents.create!(contentable: games(:two)) + + assert_includes content_modules(:one).available_games, games(:two) + end + + test "available games include unattached games" do + assert_includes content_modules(:one).available_games, games(:two) + end + + test "returns contents ordered by position" do + contents(:one).update!(position: 2) + contents(:two).update!(position: 1) + + assert_equal [ contents(:two), contents(:one) ], content_modules(:one).contents.to_a + end + + test "appends new modules after the highest position in their program and level" do + first = create_module("First") + second = create_module("Second") + + assert_equal [ 1, 2 ], [ first.position, second.position ] + end + + test "positions are separate for each level and program" do + create_module("Advanced") + moderate = create_module("Moderate", level: "moderate") + other_program = create_module("Other", program: programs(:two)) + + assert_equal 1, moderate.position + assert_equal 1, other_program.position + end + + test "moves to the end of its new level when the level changes" do + mod = create_module("Advanced") + create_module("Moderate", level: "moderate") + + mod.update!(level: "moderate") + + assert_equal 2, mod.position + end + + test "moves to the end of its new program when the program changes" do + mod = create_module("Advanced") + create_module("Other", program: programs(:two)) + + mod.update!(program: programs(:two)) + + assert_equal 2, mod.position + end + + test "keeps its position when other fields change" do + first = create_module("First") + create_module("Second") + + first.update!(name: "Renamed") + + assert_equal 1, first.reload.position end test "cannot be destroyed when classroom modules exist" do @@ -49,4 +109,11 @@ class ContentModuleTest < ActiveSupport::TestCase assert_not content_module.destroy assert content_module.persisted? end + + private + + # Defaults to a level no fixture uses, so positions start empty. + def create_module(name, program: @program, level: "advanced", **attributes) + ContentModule.create!(program: program, level: level, name: name, **attributes) + end end diff --git a/test/models/content_test.rb b/test/models/content_test.rb index e006b41..281814b 100644 --- a/test/models/content_test.rb +++ b/test/models/content_test.rb @@ -13,4 +13,49 @@ class ContentTest < ActiveSupport::TestCase assert_equal content_modules(:one), content.content_module end + + test "appends new content after the highest position in its module" do + content = content_modules(:one).contents.create!(contentable: games(:two)) + + assert_equal 3, content.position + end + + test "starts positions at 1 in an empty module" do + content = create_module.contents.create!(contentable: games(:one)) + + assert_equal 1, content.position + end + + test "cannot add the same game to a module twice" do + duplicate = content_modules(:one).contents.build(contentable: games(:one)) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:contentable_id], "is already in this module" + end + + test "can add the same game to different modules" do + content = create_module.contents.build(contentable: games(:one)) + + assert content.valid? + end + + test "destroying a link's content destroys the link" do + assert_difference [ "Link.count", "Content.count" ], -1 do + contents(:two).destroy! + end + end + + test "destroying a game's content keeps the game" do + assert_difference "Content.count", -1 do + assert_no_difference "Game.count" do + contents(:one).destroy! + end + end + end + + private + + def create_module + ContentModule.create!(program: programs(:one), level: "basic", name: "Another Module") + end end diff --git a/test/models/game_test.rb b/test/models/game_test.rb index 3622a6f..cd070bf 100644 --- a/test/models/game_test.rb +++ b/test/models/game_test.rb @@ -2,26 +2,41 @@ class GameTest < ActiveSupport::TestCase test "should not save game without title" do - game = Game.new(slug: "test-slug", content_module: ContentModule.new) + game = Game.new(slug: "test-slug") assert_not game.save, "Saved the game without a title" end test "should not save game without slug" do - game = Game.new(title: "Test Game", content_module: ContentModule.new) + game = Game.new(title: "Test Game") assert_not game.save, "Saved the game without a slug" end test "should not save game with duplicate title" do - content_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Test Module") - Game.create!(title: "Test Game", slug: "test-slug", content_module: content_module) - duplicate_game = Game.new(title: "Test Game", slug: "another-slug", content_module: content_module) + Game.create!(title: "Test Game", slug: "test-slug") + duplicate_game = Game.new(title: "Test Game", slug: "another-slug") assert_not duplicate_game.save, "Saved the game with a duplicate title" end + test "destroying a game destroys all its contents" do + other_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Other Module") + other_module.contents.create!(contentable: games(:one)) + + assert_difference "Content.count", -2 do + games(:one).destroy! + end + assert_empty Content.where(contentable: games(:one)) + end + + test "destroying a game deletes its student attempts" do + assert_difference "GameAttempt.count", -games(:one).game_attempts.count do + games(:one).destroy! + end + assert_empty GameAttempt.where(game: games(:one)) + end + test "should not save game with duplicate slug" do - content_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Test Module") - Game.create!(title: "Test Game", slug: "test-slug", content_module: content_module) - duplicate_game = Game.new(title: "Another Game", slug: "test-slug", content_module: content_module) + Game.create!(title: "Test Game", slug: "test-slug") + duplicate_game = Game.new(title: "Another Game", slug: "test-slug") assert_not duplicate_game.save, "Saved the game with a duplicate slug" end end diff --git a/test/models/link_test.rb b/test/models/link_test.rb index c1889be..f357244 100644 --- a/test/models/link_test.rb +++ b/test/models/link_test.rb @@ -2,24 +2,37 @@ class LinkTest < ActiveSupport::TestCase test "is valid with required fields" do - link = Link.new(content_module: content_modules(:one), title: "My Link", url: "https://example.com", link_type: "survey") + link = Link.new(title: "My Link", url: "https://example.com", link_type: "survey") assert link.valid? end test "is invalid without a title" do - link = Link.new(content_module: content_modules(:one), url: "https://example.com", link_type: "survey") + link = Link.new(url: "https://example.com", link_type: "survey") assert_not link.valid? assert_includes link.errors[:title], "can't be blank" end test "is invalid without a url" do - link = Link.new(content_module: content_modules(:one), title: "My Link", link_type: "survey") + link = Link.new(title: "My Link", link_type: "survey") assert_not link.valid? assert_includes link.errors[:url], "can't be blank" end + test "destroying a link destroys its content" do + content_id = contents(:two).id + + assert_difference [ "Link.count", "Content.count" ], -1 do + links(:one).destroy! + end + assert_nil Content.find_by(id: content_id) + end + + test "finds its content module through its content" do + assert_equal content_modules(:one), links(:one).content_module + end + test "is invalid without a link_type" do - link = Link.new(content_module: content_modules(:one), title: "My Link", url: "https://example.com") + link = Link.new(title: "My Link", url: "https://example.com") assert_not link.valid? end end From f1c06db7dd511f059813d7f380d28f05fa07dd1b Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 19:54:38 -0400 Subject: [PATCH 2/2] fix: updates --- app/models/concerns/contentable.rb | 5 ++- app/models/concerns/ordered.rb | 7 ++-- app/models/content_module.rb | 5 +-- app/models/game.rb | 2 + ...20261031181034_add_position_to_contents.rb | 12 ------ ...ove_content_module_from_links_and_games.rb | 18 +-------- db/seeds.rb | 1 + skills/rails-test-fixtures-conventions.md | 28 +++++++++++--- .../admin/content_modules_controller_test.rb | 13 ++++--- test/models/content_module_test.rb | 26 ++++--------- test/models/content_test.rb | 38 ++++++++++++++----- test/models/game_test.rb | 28 ++++++++++++-- test/models/link_test.rb | 12 ++++-- 13 files changed, 109 insertions(+), 86 deletions(-) diff --git a/app/models/concerns/contentable.rb b/app/models/concerns/contentable.rb index 6c772a4..51acc30 100644 --- a/app/models/concerns/contentable.rb +++ b/app/models/concerns/contentable.rb @@ -1,8 +1,9 @@ -# A content owns its link, which is deleted together with it. -# A game is shared across modules, so it is kept when a content is removed. module Contentable extend ActiveSupport::Concern + # Whether the content record owns this contentable, so the contentable is destroyed together with it. + # + # @return [Boolean] def owned_by_content? false end diff --git a/app/models/concerns/ordered.rb b/app/models/concerns/ordered.rb index f6ea8e9..c846074 100644 --- a/app/models/concerns/ordered.rb +++ b/app/models/concerns/ordered.rb @@ -1,7 +1,4 @@ # Orders records by an integer +position+ column. New records go to the end of their siblings. -# -# A model that includes this must define +position_siblings+, the relation of records it is -# ordered among (for example, the contents of one module). module Ordered extend ActiveSupport::Concern @@ -13,6 +10,10 @@ module Ordered private + # This method is required to override. It is the relation of records this record is + # ordered among (for example, the contents of one module). + # + # @return [ActiveRecord::Relation] def position_siblings raise NotImplementedError, "#{self.class} must define #position_siblings to be ordered" end diff --git a/app/models/content_module.rb b/app/models/content_module.rb index fac8aed..3ea5e0f 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -12,12 +12,9 @@ class ContentModule < ApplicationRecord validates :name, :level, presence: true # A module that joins another program or level goes to the end of it. + # TODO: Consider if we just don't allow these types of edits to avoid this hidden callback behavior before_update :append_to_siblings, if: -> { program_id_changed? || level_changed? } - def available_games - Game.where.not(id: games).order(:title) - end - private # Modules are ordered within one program and level, the same grouping the admin index shows. diff --git a/app/models/game.rb b/app/models/game.rb index 7c0cad3..b3f096b 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -7,4 +7,6 @@ class Game < ApplicationRecord validates :title, :slug, presence: true validates :title, :slug, uniqueness: true + + scope :available_for, ->(content_module) { where.not(id: content_module.games).order(:title) } end diff --git a/db/migrate/20261031181034_add_position_to_contents.rb b/db/migrate/20261031181034_add_position_to_contents.rb index b751586..71e34ab 100644 --- a/db/migrate/20261031181034_add_position_to_contents.rb +++ b/db/migrate/20261031181034_add_position_to_contents.rb @@ -3,17 +3,5 @@ def change add_column :contents, :position, :integer add_index :contents, [ :content_module_id, :position ] add_index :contents, [ :content_module_id, :contentable_type, :contentable_id ], unique: true, name: "index_contents_on_module_and_contentable" - - reversible do |dir| - dir.up do - # Numbers contents in id order, so link positions set before this migration are not kept. - execute <<~SQL - UPDATE contents SET position = ( - SELECT COUNT(*) FROM contents earlier - WHERE earlier.content_module_id = contents.content_module_id AND earlier.id <= contents.id - ) - SQL - end - end end end diff --git a/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb b/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb index 51b9ca4..95ae340 100644 --- a/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb +++ b/db/migrate/20261031181544_remove_content_module_from_links_and_games.rb @@ -6,22 +6,6 @@ def up end def down - add_reference :links, :content_module, foreign_key: true, index: true - add_column :links, :position, :integer - add_reference :games, :content_module, foreign_key: true, index: true - - execute <<~SQL - UPDATE links SET - content_module_id = (SELECT content_module_id FROM contents WHERE contentable_type = 'Link' AND contentable_id = links.id), - position = (SELECT position FROM contents WHERE contentable_type = 'Link' AND contentable_id = links.id) - SQL - - # A game can be in many modules, but the old column held one, so pick the lowest content id. - execute <<~SQL - UPDATE games SET - content_module_id = (SELECT content_module_id FROM contents WHERE contentable_type = 'Game' AND contentable_id = games.id ORDER BY id LIMIT 1) - SQL - - change_column_null :links, :content_module_id, false + raise ActiveRecord::IrreversibleMigration end end diff --git a/db/seeds.rb b/db/seeds.rb index c064636..1c9ac2f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -34,6 +34,7 @@ def maybe(&block) # The first module of each program and level has a survey then a game. # The second module of the basic level has a game then a survey. # Other modules have only a survey. +# TODO: This is getting complex, we should extract this into a partial and make it more readable ContentModule.levels.keys.each do |level| programs.each do |program| game = games_by_program.fetch(program) diff --git a/skills/rails-test-fixtures-conventions.md b/skills/rails-test-fixtures-conventions.md index e2d874f..eaed12d 100644 --- a/skills/rails-test-fixtures-conventions.md +++ b/skills/rails-test-fixtures-conventions.md @@ -4,21 +4,39 @@ Goal: keep fixtures few. Too many fixtures = nobody understands the dependency g ## Rules -1. **1-2 fixtures per model max** +1. 1-2 fixtures per model max - Name them `:one` and `:two` - Plain, valid, boring attributes - Minimal dependencies - Add `:two` only to compare two records -2. **Customize in the test, not the fixture** +2. Customize in the test, not the fixture - `developers(:one).update!(field: value)` - Only set the field the test cares about - Extra DB write is fine, readability wins -3. **Use a custom helper method for when needed** +3. Use a custom helper method for when needed - Create a method like `create_thing` that allows customizing attributes -4. **New named fixture = last resort** +4. New named fixture = last resort - Only for deep/complex required associations - Give it a real name, not `:three` (e.g. `:paying_customer`) - - Max 1-2 per model \ No newline at end of file + - Max 1-2 per model + +5. Don't use a fixture when creating data inline would be more explicit + - A reader should not need to reference multiple fixtures to understand implicit relationships in the test + - Examples + - Bad: The reader doesn't know that games(:one) is already in modules(:one) + ```ruby + test "you cannot add a game more than once to the same module" do + refute modules(:one).contents.build(contentable: games(:one)).valid? + end + ``` + - Good: The build the module explicitly rather than using the fixture + ```ruby + test "you cannot add a game more than once to the same module" do + module_with_game = create_module + module_with_game.contents.create!(contentable: games(:one)) + refute module_with_game.contents.build(contentable: games(:one)).valid? + end + ``` \ No newline at end of file diff --git a/test/controllers/admin/content_modules_controller_test.rb b/test/controllers/admin/content_modules_controller_test.rb index 639c00e..62e74e5 100644 --- a/test/controllers/admin/content_modules_controller_test.rb +++ b/test/controllers/admin/content_modules_controller_test.rb @@ -56,15 +56,16 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest end test "edit lists links ordered by position" do - contents(:two).destroy! - @content_module.contents.create!(contentable: Link.new(title: "Last Link", url: "https://example.com/2", link_type: "survey")) - first = @content_module.contents.create!(contentable: Link.new(title: "First Link", url: "https://example.com/1", link_type: "survey")) - first.update!(position: 0) + content_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Ordered Module") + last, first = [ + content_module.contents.create!(contentable: Link.new(title: "Last Link", url: "https://example.com/2", link_type: "survey",), position: 2), + content_module.contents.create!(contentable: Link.new(title: "First Link", url: "https://example.com/1", link_type: "survey"), position: 1) + ] - get edit_admin_content_module_url(@content_module) + get edit_admin_content_module_url(content_module) hrefs = css_select("table tbody tr td:first-child a").map { |link| link["href"] } - assert_equal [ "https://example.com/1", "https://example.com/2" ], hrefs + assert_equal [ last.contentable.url, first.contentable.url ], hrefs end test "should update content module" do diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb index 3a1c3d9..04ebd7d 100644 --- a/test/models/content_module_test.rb +++ b/test/models/content_module_test.rb @@ -36,26 +36,13 @@ class ContentModuleTest < ActiveSupport::TestCase end end - test "available games exclude games already in the module" do - assert_not_includes content_modules(:one).available_games, games(:one) - end - - test "available games include games only in another module" do - other_module = ContentModule.create!(program: @program, level: "basic", name: "Other Module") - other_module.contents.create!(contentable: games(:two)) - - assert_includes content_modules(:one).available_games, games(:two) - end - - test "available games include unattached games" do - assert_includes content_modules(:one).available_games, games(:two) - end - test "returns contents ordered by position" do - contents(:one).update!(position: 2) - contents(:two).update!(position: 1) - - assert_equal [ contents(:two), contents(:one) ], content_modules(:one).contents.to_a + content_module = create_module("With Contents") + first, second = [ + content_module.contents.create!(contentable: games(:one), position: 1), + content_module.contents.create!(contentable: games(:two), position: 2) + ] + assert_equal [ second, first ], content_module.contents.to_a end test "appends new modules after the highest position in their program and level" do @@ -113,6 +100,7 @@ class ContentModuleTest < ActiveSupport::TestCase private # Defaults to a level no fixture uses, so positions start empty. + # TODO: This can drift based on the fixture file and cause unexpected behavior. We should consider a better way to handle this. def create_module(name, program: @program, level: "advanced", **attributes) ContentModule.create!(program: program, level: level, name: name, **attributes) end diff --git a/test/models/content_test.rb b/test/models/content_test.rb index 281814b..795c55e 100644 --- a/test/models/content_test.rb +++ b/test/models/content_test.rb @@ -2,22 +2,27 @@ class ContentTest < ActiveSupport::TestCase test "delegates to its contentable" do - content = contents(:one) + game = games(:one) + content = create_module.contents.create!(contentable: game) - assert_equal games(:one), content.contentable + assert_equal game, content.contentable assert_equal "Game", content.contentable_type end test "belongs to a content module" do - content = contents(:one) + content_module = create_module + content = content_module.contents.create!(contentable: games(:one)) - assert_equal content_modules(:one), content.content_module + assert_equal content_module, content.content_module end test "appends new content after the highest position in its module" do - content = content_modules(:one).contents.create!(contentable: games(:two)) + content_module = create_module + existing = content_module.contents.create!(contentable: games(:one)) - assert_equal 3, content.position + content = content_module.contents.create!(contentable: games(:two)) + + assert_equal existing.position + 1, content.position end test "starts positions at 1 in an empty module" do @@ -27,28 +32,37 @@ class ContentTest < ActiveSupport::TestCase end test "cannot add the same game to a module twice" do - duplicate = content_modules(:one).contents.build(contentable: games(:one)) + content_module = create_module + content_module.contents.create!(contentable: games(:one)) + + duplicate = content_module.contents.build(contentable: games(:one)) assert_not duplicate.valid? assert_includes duplicate.errors[:contentable_id], "is already in this module" end test "can add the same game to different modules" do + create_module.contents.create!(contentable: games(:one)) + content = create_module.contents.build(contentable: games(:one)) assert content.valid? end test "destroying a link's content destroys the link" do + content = create_module.contents.create!(contentable: build_link) + assert_difference [ "Link.count", "Content.count" ], -1 do - contents(:two).destroy! + content.destroy! end end test "destroying a game's content keeps the game" do + content = create_module.contents.create!(contentable: games(:one)) + assert_difference "Content.count", -1 do assert_no_difference "Game.count" do - contents(:one).destroy! + content.destroy! end end end @@ -56,6 +70,10 @@ class ContentTest < ActiveSupport::TestCase private def create_module - ContentModule.create!(program: programs(:one), level: "basic", name: "Another Module") + ContentModule.create!(program: programs(:one), level: "basic", name: "Module #{ContentModule.count}") + end + + def build_link + Link.new(title: "A Link", url: "https://example.com", link_type: "survey") end end diff --git a/test/models/game_test.rb b/test/models/game_test.rb index cd070bf..3fd2fad 100644 --- a/test/models/game_test.rb +++ b/test/models/game_test.rb @@ -18,13 +18,33 @@ class GameTest < ActiveSupport::TestCase end test "destroying a game destroys all its contents" do - other_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Other Module") - other_module.contents.create!(contentable: games(:one)) + game = Game.create!(title: "Shared Game", slug: "shared-game") + 2.times do |i| + ContentModule.create!(program: programs(:one), level: "basic", name: "Module #{i}").contents.create!(contentable: game) + end assert_difference "Content.count", -2 do - games(:one).destroy! + game.destroy! end - assert_empty Content.where(contentable: games(:one)) + assert_empty Content.where(contentable: game) + end + + test "available_for returns games not in the module, ordered by title" do + content_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Module") + other_module = ContentModule.create!(program: programs(:one), level: "basic", name: "Other Module") + in_module = Game.create!(title: "In Module", slug: "in-module").tap do + content_module.contents.create!(contentable: it) + end + in_other_module = Game.create!(title: "B In Other Module", slug: "in-other-module").tap do + other_module.contents.create!(contentable: it) + end + in_no_module = Game.create!(title: "A In No Module", slug: "in-no-module") + + + available = Game.available_for(content_module) + + assert_not_includes available, in_module + assert_equal [ in_no_module, in_other_module ], available.where(id: [ in_module, in_other_module, in_no_module ]).to_a end test "destroying a game deletes its student attempts" do diff --git a/test/models/link_test.rb b/test/models/link_test.rb index f357244..3a4995e 100644 --- a/test/models/link_test.rb +++ b/test/models/link_test.rb @@ -19,16 +19,20 @@ class LinkTest < ActiveSupport::TestCase end test "destroying a link destroys its content" do - content_id = contents(:two).id + link = Link.new(title: "My Link", url: "https://example.com", link_type: "survey") + content = content_modules(:one).contents.create!(contentable: link) assert_difference [ "Link.count", "Content.count" ], -1 do - links(:one).destroy! + link.destroy! end - assert_nil Content.find_by(id: content_id) + assert_nil Content.find_by(id: content.id) end test "finds its content module through its content" do - assert_equal content_modules(:one), links(:one).content_module + link = Link.new(title: "My Link", url: "https://example.com", link_type: "survey") + content_modules(:one).contents.create!(contentable: link) + + assert_equal content_modules(:one), link.content_module end test "is invalid without a link_type" do