Files
growstuff/spec/models/crop_spec.rb
T
Brenda WallaceandClaude Sonnet 5.5 ab024ac51a Address review comments on packet photo labels
Keep every packet label when a side is photographed again, and use the newest
photo of a side. Before, giving a second photo the same label cleared the
older photo's, which let it back into the crop's photos and default picture.

Carry the label through the HTML photo picker (its search form and the links
that add a photo), so a packet photo added without JavaScript is labelled.

Have the OCR script treat any network failure on an image (DNS, timeout) as
that image failing, so the rest of the batch still runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:27 +13:00

693 lines
23 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
describe Crop do
context 'all fields present' do
let(:crop) { create(:tomato) }
it 'saves a basic crop' do
crop.save.should be(true)
end
it 'is fetchable from the database' do
crop.save
@crop2 = described_class.find_by(name: 'tomato')
@crop2.en_wikipedia_url.should eq("http://en.wikipedia.org/wiki/Tomato")
@crop2.slug.should eq("tomato")
end
it 'stringifies as the system name' do
crop.save
crop.to_s.should eq('tomato')
end
it 'has a creator' do
crop.save
crop.creator.should be_an_instance_of Member
end
end
context 'invalid data' do
it 'does not save a crop without a system name' do
crop = build(:crop, name: nil)
expect { crop.save }.to raise_error ActiveRecord::StatementInvalid
end
end
context 'ordering' do
before do
@uppercase = create(:uppercasecrop, created_at: 1.minute.ago)
@lowercase = create(:lowercasecrop, created_at: 2.days.ago)
end
it 'recent scope sorts by creation date' do
described_class.recent.first.should == @uppercase
end
end
context 'popularity' do
let(:tomato) { create(:tomato) }
let(:maize) { create(:maize) }
before do
create_list(:planting, 10, crop: maize)
create_list(:planting, 3, crop: tomato)
end
it "sorts by most plantings" do
expect(described_class.popular.first).to eq maize
create_list(:planting, 10, crop: tomato)
expect(described_class.popular.first).to eq tomato
end
end
it 'finds a default scientific name' do
@crop = create(:tomato)
expect(@crop.default_scientific_name).to be_nil
@sn = create(:solanum_lycopersicum, crop: @crop)
@crop.reload
expect(@crop.default_scientific_name.to_s).to eq @sn.name
end
it 'counts plantings' do
@crop = create(:tomato)
expect(@crop.plantings.size).to eq 0
@planting = create(:planting, crop: @crop)
@crop.reload
expect(@crop.plantings.size).to eq 1
end
context "wikipedia url" do
subject { build(:tomato, en_wikipedia_url: wikipedia_url) }
context 'not a url' do
let(:wikipedia_url) { 'this is not valid' }
it { expect(subject).not_to be_valid }
end
context 'http url' do
let(:wikipedia_url) { 'http://en.wikipedia.org/wiki/SomePage' }
it { expect(subject).to be_valid }
end
context 'with ssl' do
let(:wikipedia_url) { 'https://en.wikipedia.org/wiki/SomePage' }
it { expect(subject).to be_valid }
end
context 'with utf8 macrons' do
let(:wikipedia_url) { 'https://en.wikipedia.org/wiki/Māori' }
it { expect(subject).to be_valid }
end
context 'urlencoded' do
let(:wikipedia_url) { 'https://en.wikipedia.org/wiki/M%C4%81ori' }
it { expect(subject).to be_valid }
end
context 'with new lines in url' do
let(:wikipedia_url) { 'http://en.wikipedia.org/wiki/SomePage\n\nBrendaRocks' }
it { expect(subject).not_to be_valid }
end
context "with script tags in url" do
let(:wikipedia_url) { 'http://en.wikipedia.org/wiki/SomePage<script>alert(\'BrendaRocks\')</script>' }
it { expect(subject).not_to be_valid }
end
end
context 'varieties' do
it 'has a crop hierarchy' do
@tomato = create(:tomato)
@roma = create(:roma, parent_id: @tomato.id)
expect(@roma.parent).to eq @tomato
expect(@tomato.varieties).to eq [@roma]
end
it 'toplevel scope works' do
@tomato = create(:tomato)
@roma = create(:roma, parent_id: @tomato.id)
expect(described_class.toplevel).to eq [@tomato]
end
end
context 'photos' do
shared_examples 'has default photo' do
it { expect(described_class.has_photos).to include(crop) }
end
let!(:crop) { create(:tomato) }
context 'with a planting photo' do
let!(:photo) { create(:photo, owner: planting.owner) }
let!(:planting) { create(:planting, crop:) }
before { planting.photos << photo }
it { expect(crop.default_photo).to eq photo }
it 'caches thumbnail_url string in Rails.cache' do
expected_url = photo.source == 'flickr' ? photo.fullsize_url : photo.thumbnail_url
expect(crop.thumbnail_url).to eq expected_url
cached_value = Rails.cache.read("#{crop.cache_key_with_version}/thumbnail_url")
expect(cached_value).to eq expected_url
end
include_examples 'has default photo'
end
context 'with a harvest photo' do
let!(:harvest) { create(:harvest, crop:) }
let!(:photo) { create(:photo, owner: harvest.owner) }
before { harvest.photos << photo }
it { expect(crop.default_photo).to eq photo }
include_examples 'has default photo'
context 'and planting photo' do
let(:planting) { create(:planting, crop:) }
let!(:planting_photo) { create(:photo, owner: planting.owner) }
before { planting.photos << planting_photo }
it 'prefers the planting photo' do
expect(crop.default_photo.id).to eq planting_photo.id
end
end
end
context 'with a photo of a seed packet' do
let(:seed) { create(:seed, crop:) }
let!(:packet_photo) { create(:photo, owner: seed.owner, likes_count: 10) }
before { seed.photo_associations.create!(photo: packet_photo, label: 'back') }
it('is not one of the crop photos') { expect(crop.photos).not_to include(packet_photo) }
it('is not the default photo') { expect(crop.default_photo).to be_nil }
it('does not make the crop interesting') { expect(described_class.has_photos).not_to include(crop) }
context 'with the same photo on another seed, with no label there' do
let(:other_seed) { create(:seed, crop:, owner: seed.owner) }
before { other_seed.photos << packet_photo }
it('is still not one of the crop photos') { expect(crop.photos).not_to include(packet_photo) }
it('is still not the default photo') { expect(crop.default_photo).to be_nil }
end
context 'with the packet photographed again, so a newer photo has the label' do
before { seed.photo_associations.create!(photo: create(:photo, owner: seed.owner), label: 'back') }
it('still leaves out the older packet photo') { expect(crop.photos).not_to include(packet_photo) }
it('leaves out the newer one too') { expect(crop.photos).to be_empty }
end
context 'with a photo of the seeds too' do
let!(:seed_photo) { create(:photo, owner: seed.owner) }
before { seed.photos << seed_photo }
it('has the photo of the seeds') { expect(crop.photos).to contain_exactly(seed_photo) }
end
end
context 'with no plantings or harvests' do
it 'has no default photo' do
expect(crop.default_photo).to be_nil
end
it { expect(crop.photos.size).to eq 0 }
it { expect(crop.photos.by_model(Planting).size).to eq 0 }
it { expect(crop.photos.by_model(Harvest).size).to eq 0 }
it { expect(crop.photos.by_model(Seed).size).to eq 0 }
end
describe 'finding all photos' do
let(:planting) { create(:planting, crop:) }
let(:harvest) { create(:harvest, crop:) }
let(:seed) { create(:seed, crop:) }
before do
# Add photos to all
planting.photos << create(:photo, owner: planting.owner)
harvest.photos << create(:photo, owner: harvest.owner)
seed.photos << create(:photo, owner: seed.owner)
end
it { expect(crop.photos.size).to eq 3 }
it { expect(crop.photos.by_model(Planting).size).to eq 1 }
it { expect(crop.photos.by_model(Harvest).size).to eq 1 }
it { expect(crop.photos.by_model(Seed).size).to eq 1 }
end
end
context 'sunniness' do
let(:crop) { create(:tomato) }
it 'returns a hash of sunniness values' do
create(:sunny_planting, crop:)
create(:sunny_planting, crop:)
create(:semi_shady_planting, crop:)
create(:shady_planting, crop:)
crop.sunniness.should be_an_instance_of Hash
end
it 'counts each sunniness value' do
create(:sunny_planting, crop:)
create(:sunny_planting, crop:)
create(:semi_shady_planting, crop:)
create(:shady_planting, crop:)
crop.sunniness.should == { 'sun' => 2, 'shade' => 1, 'semi-shade' => 1 }
end
it 'ignores unused sunniness values' do
create(:sunny_planting, crop:)
create(:sunny_planting, crop:)
create(:semi_shady_planting, crop:)
crop.sunniness.should == { 'sun' => 2, 'semi-shade' => 1 }
end
end
context 'planted_from' do
let(:crop) { create(:tomato) }
it 'returns a hash of sunniness values' do
create(:seed_planting, crop:)
create(:seed_planting, crop:)
create(:seedling_planting, crop:)
create(:cutting_planting, crop:)
crop.planted_from.should be_an_instance_of Hash
end
it 'counts each planted_from value' do
create(:seed_planting, crop:)
create(:seed_planting, crop:)
create(:seedling_planting, crop:)
create(:cutting_planting, crop:)
crop.planted_from.should == { 'seed' => 2, 'seedling' => 1, 'cutting' => 1 }
end
it 'ignores unused planted_from values' do
create(:seed_planting, crop:)
create(:seed_planting, crop:)
create(:seedling_planting, crop:)
crop.planted_from.should == { 'seed' => 2, 'seedling' => 1 }
end
end
context 'popular plant parts' do
let(:crop) { create(:tomato) }
it 'returns a hash of plant_part values' do
crop.popular_plant_parts.should be_an_instance_of Hash
end
it 'counts each plant_part value' do
@fruit = create(:plant_part)
@seed = create(:plant_part, name: 'seed')
@root = create(:plant_part, name: 'root')
@bulb = create(:plant_part, name: 'bulb')
@harvest1 = create(:harvest,
crop:,
plant_part: @fruit)
@harvest2 = create(:harvest,
crop:,
plant_part: @fruit)
@harvest3 = create(:harvest,
crop:,
plant_part: @seed)
@harvest4 = create(:harvest,
crop:,
plant_part: @root)
crop.popular_plant_parts.should == { [@fruit.id, @fruit.name] => 2,
[@seed.id, @seed.name] => 1,
[@root.id, @root.name] => 1 }
end
end
context 'interesting' do
subject { described_class.interesting }
# first, a couple of candidate crops
let(:first_crop) { create(:crop) }
let(:second_crop) { create(:crop) }
let(:first_crop_planting) { first_crop.plantings.first }
let(:second_crop_planting) { second_crop.plantings.first }
let(:member) { create(:member, login_name: 'pikachu') }
describe 'lists interesting crops' do
before do
# they need 3+ plantings each to be interesting
create_list(:planting, 3, crop: first_crop, owner: member)
create_list(:planting, 3, crop: second_crop, owner: member)
# crops need 3+ photos to be interesting
first_crop_planting.photos = create_list :photo, 3, owner: member
second_crop_planting.photos = create_list :photo, 3, owner: member
end
it { is_expected.to include first_crop }
it { is_expected.to include second_crop }
it { expect(subject.size).to eq 2 }
end
describe 'crops without plantings are not interesting' do
before do
# only first_crop has plantings
create_list(:planting, 3, crop: first_crop, owner: member)
# ... and photos
first_crop_planting.photos = create_list(:photo, 3, owner: member)
end
it { is_expected.to include first_crop }
it { is_expected.not_to include second_crop }
it { expect(subject.size).to eq 1 }
end
describe 'crops without photos are not interesting' do
before do
# both crops have plantings
create_list(:planting, 3, crop: first_crop, owner: member)
create_list(:planting, 3, crop: second_crop, owner: member)
# but only first_crop has photos
first_crop_planting.photos = create_list(:photo, 3, owner: member)
end
it { is_expected.to include first_crop }
it { is_expected.not_to include second_crop }
it { expect(subject.size).to eq 1 }
end
end
context "harvests" do
let!(:crop) { create(:crop) }
let!(:harvest) { create(:harvest, crop:) }
it "has harvests" do
expect(crop.harvests).to eq [harvest]
end
end
it "doesn't duplicate plant_parts" do
@maize = create(:maize)
@pp1 = create(:plant_part)
@h1 = create(:harvest, crop: @maize, plant_part: @pp1)
@h2 = create(:harvest, crop: @maize, plant_part: @pp1)
expect(@maize.plant_parts).to eq [@pp1]
end
context "csv loading" do
before do
# don't use 'let' for this -- we need to actually create it,
# regardless of whether it's used.
@cropbot = create(:cropbot)
end
context "scientific names" do
it "adds a scientific name to a crop that has none" do
row = ["parent", "http://en.wikipedia.org/wiki/Parent", "", "Foo bar"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.scientific_names.size).to eq 1
expect(tomato.default_scientific_name.to_s).to eq "Foo bar"
end
it "picks up scientific name from parent crop if available" do
parent = CsvImporter.new.import_crop(
["parent", "http://en.wikipedia.org/wiki/Parent", "", "Parentis cropis"]
)
tomato = CsvImporter.new.import_crop(
["Tomato", "http://en.wikipedia.org/wiki/Parent", "parent"]
)
expect(tomato.parent).to eq parent
expect(tomato.parent.default_scientific_name.to_s).to eq "Parentis cropis"
expect(tomato.default_scientific_name.to_s).to eq "Parentis cropis"
end
it "doesn't add a duplicate scientific name" do
row = ["parent", "http://en.wikipedia.org/wiki/Parent", "", "Foo bar, Foo bar"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.scientific_names.size).to eq 1
end
it "doesn't add a duplicate scientific name from parent" do
CsvImporter.new.import_crop(
["parent", "http://en.wikipedia.org/wiki/Parent", "", "Parentis cropis"]
)
tomato = CsvImporter.new.import_crop(
["Tomato", "http://en.wikipedia.org/wiki/Parent", "parent", "Parentis cropis"]
)
expect(tomato.scientific_names.size).to eq 1
end
it "loads a crop with multiple scientific names" do
row = ["parent", "http://en.wikipedia.org/wiki/Parent", "", "Foo,Bar"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.scientific_names[0].name).to eq "Foo"
expect(tomato.scientific_names[1].name).to eq "Bar"
end
it "loads multiple scientific names with variant spacing" do
row = ["parent", "http://en.wikipedia.org/wiki/Parent", "", "Baz, Quux"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.scientific_names.size).to eq 2
expect(tomato.scientific_names[0].name).to eq "Baz"
expect(tomato.scientific_names[1].name).to eq "Quux"
end
end # scientific names
context "alternate names" do
it "loads an alternate name" do
row = ["tomato", "http://en.wikipedia.org/wiki/Parent", "", "", "Foo"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.alternate_names.size).to eq 1
expect(tomato.alternate_names.last.name).to eq "Foo"
end
it "adds multiple alternate names" do
row = ["tomato", "http://en.wikipedia.org/wiki/Parent", "", "", "Foo, Bar"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.alternate_names.size).to eq 2
expect(tomato.alternate_names[0].name).to eq "Foo"
expect(tomato.alternate_names[1].name).to eq "Bar"
end
it "adds multiple alt names with variant spacing" do
row = ["tomato", "http://en.wikipedia.org/wiki/Parent", "", "", "Foo,Bar,Baz, Quux"]
tomato = CsvImporter.new.import_crop(row)
expect(tomato.alternate_names.size).to eq 4
expect(tomato.alternate_names[0].name).to eq "Foo"
expect(tomato.alternate_names[1].name).to eq "Bar"
expect(tomato.alternate_names[2].name).to eq "Baz"
expect(tomato.alternate_names[3].name).to eq "Quux"
end
it "Adds a duplicate alternate name for second crop" do
row = ["tomato", "http://en.wikipedia.org/wiki/tomato", "", "", "Foo"]
tomato = CsvImporter.new.import_crop(row)
row = ["tomoto", "http://en.wikipedia.org/wiki/tomoto", "", "", "Foo"]
tomoto = CsvImporter.new.import_crop(row)
expect(tomato.alternate_names.size).to eq 1
expect(tomoto.alternate_names.size).to eq 1
end
end # alternate names
it "loads the simplest possible crop" do
tomato_row = ["tomato", "http://en.wikipedia.org/wiki/Tomato"]
tomato = CsvImporter.new.import_crop(tomato_row)
expect(tomato.name).to eq "tomato"
expect(tomato.en_wikipedia_url).to eq 'http://en.wikipedia.org/wiki/Tomato'
expect(tomato.creator).to eq @cropbot
end
it "loads a crop with a scientific name" do
tomato_row = ["tomato", "http://en.wikipedia.org/wiki/Tomato", "", "Solanum lycopersicum"]
tomato = CsvImporter.new.import_crop(tomato_row)
expect(tomato.name).to eq "tomato"
expect(tomato.scientific_names.size).to eq 1
expect(tomato.scientific_names.last.name).to eq "Solanum lycopersicum"
end
it "loads a crop with an alternate name" do
crop = CsvImporter.new.import_crop(
["tomato", "http://en.wikipedia.org/wiki/Tomato", nil, nil, "Foo"]
)
expect(crop.name).to eq "tomato"
expect(crop.alternate_names.size).to eq 1
expect(crop.alternate_names.last.name).to eq "Foo"
end
it "loads a crop with a parent" do
parent = create(:crop, name: 'parent')
crop = CsvImporter.new.import_crop(
["tomato", "http://en.wikipedia.org/wiki/Tomato", "parent"]
)
expect(crop.parent).to eq parent
end
it "loads a crop with a missing parent" do
tomato_row = "tomato,http://en.wikipedia.org/wiki/Tomato,parent"
CSV.parse(tomato_row) do |row|
CsvImporter.new.import_crop(row)
end
loaded = described_class.last
expect(loaded.parent).to be_nil
end
it "doesn't add unnecessary duplicate crops" do
tomato_row = "tomato,http://en.wikipedia.org/wiki/Tomato,,Solanum lycopersicum"
CSV.parse(tomato_row) do |row|
CsvImporter.new.import_crop(row)
end
loaded = described_class.last
expect(loaded.name).to eq "tomato"
expect(loaded.en_wikipedia_url).to eq 'http://en.wikipedia.org/wiki/Tomato'
expect(loaded.creator).to eq @cropbot
end
end
context "crop-post association" do
let!(:tomato) { create(:tomato) }
let!(:maize) { create(:maize) }
let!(:post) { create(:post, body: "[maize](crop)[tomato](crop)[tomato](crop)") }
describe "destroying a crop" do
before do
tomato.destroy
end
it "deletes the association between post and the crop(tomato)" do
expect(Post.find(post.id).crops).to eq [maize]
end
it "does not delete the posts" do
expect(Post.find(post.id)).not_to be_nil
end
end
end
context "destroying a crop" do
let!(:crop_a) { create(:crop) }
let!(:crop_b) { create(:crop) }
before do
CropCompanion.create(crop_a: crop_a, crop_b: crop_b)
CropCompanion.create(crop_a: crop_b, crop_b: crop_a)
end
it "destroys companion links" do
expect { crop_a.destroy }.to change(CropCompanion, :count).from(2).to(0)
end
end
context "crop rejections" do
let!(:rejected_reason) do
create(:crop, name: 'tomato',
approval_status: 'rejected',
reason_for_rejection: 'not edible')
end
let!(:rejected_other) do
create(:crop, name: 'tomato',
approval_status: 'rejected',
reason_for_rejection: 'other',
rejection_notes: 'blah blah blah')
end
describe "rejecting a crop" do
it "gives reason if a default option" do
expect(rejected_reason.rejection_explanation).to eq "not edible"
end
it "shows rejection notes if reason was other" do
expect(rejected_other.rejection_explanation).to eq "blah blah blah"
end
end
end
describe 'its size on a garden layout' do
let(:tomato) { create(:crop, name: 'tomato') }
let(:variety) { create(:crop, name: 'island bay tomato', parent: tomato) }
it 'has none until someone sets it' do
expect(tomato.layout_diameter).to be_nil
end
it 'follows its parent crop until it has its own' do
tomato.update!(default_diameter: 2)
expect(variety.layout_diameter).to eq 2
variety.update!(default_diameter: 1.5)
expect(variety.layout_diameter).to eq 1.5
end
it 'must be more than nothing, and no more than a plant can be drawn' do
expect(build(:crop, default_diameter: 0)).not_to be_valid
expect(build(:crop, default_diameter: Plant::MAX_DIAMETER + 1)).not_to be_valid
expect(build(:crop, default_diameter: 0.5)).to be_valid
end
end
describe 'its icon' do
let(:tomato) { create(:crop, name: 'tomato') }
let(:variety) { create(:crop, name: 'island bay tomato', parent: tomato) }
it 'has none until one is chosen, or OpenFarm had one' do
expect(tomato.svg_icon).to be_nil
end
it "draws the app's icon chosen for it" do
tomato.update!(icon: 'tomato')
expect(tomato.svg_icon).to eq Rails.root.join('app/assets/images/crops/tomato.svg').read
end
it 'prefers its chosen icon to the one OpenFarm had' do
tomato.update!(icon: 'tomato', openfarm_data: { 'attributes' => { 'svg_icon' => '<svg>openfarm</svg>' } })
expect(tomato.svg_icon).to include('viewBox="0 0 32 32"')
end
it 'falls back to the one OpenFarm had' do
tomato.update!(openfarm_data: { 'attributes' => { 'svg_icon' => '<svg>openfarm</svg>' } })
expect(tomato.svg_icon).to eq '<svg>openfarm</svg>'
end
it 'follows its parent crop until it has its own' do
tomato.update!(icon: 'tomato')
expect(variety.svg_icon).to eq tomato.svg_icon
variety.update!(icon: 'cherries')
expect(variety.svg_icon).to eq described_class.icon_svg('cherries')
end
it "must be one of the app's icons" do
expect(build(:crop, icon: 'tomato')).to be_valid
expect(build(:crop, icon: 'dragon')).not_to be_valid
expect(build(:crop, icon: '')).to be_valid
end
it 'lists the icons there are to choose from' do
expect(described_class.icon_names).to include('tomato', 'herb', 'leafy_green')
expect(described_class.icon_names).to eq described_class.icon_names.sort
end
end
end