gitlab-ce/spec/models/ml/model_version_spec.rb

301 lines
9.5 KiB
Ruby

# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ml::ModelVersion, feature_category: :mlops do
using RSpec::Parameterized::TableSyntax
let_it_be(:base_project) { create(:project) }
let_it_be(:model1) { create(:ml_models, project: base_project) }
let_it_be(:model2) { create(:ml_models, project: base_project) }
let_it_be(:model_version1) { create(:ml_model_versions, model: model1, version: '4.0.0') }
let_it_be(:model_version2) { create(:ml_model_versions, model: model_version1.model, version: '6.0.0') }
let_it_be(:model_version3) { create(:ml_model_versions, model: model2, version: '5.0.0') }
let_it_be(:model_version4) { create(:ml_model_versions, model: model_version3.model, version: '4.0.1') }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:model) }
it { is_expected.to belong_to(:package).class_name('Packages::MlModel::Package') }
it { is_expected.to have_one(:candidate).class_name('Ml::Candidate') }
it { is_expected.to have_many(:metadata) }
end
describe 'validation' do
let_it_be(:valid_version) { '1.0.0' }
let_it_be(:valid_package) do
build_stubbed(:ml_model_package, project: base_project, version: valid_version, name: model1.name)
end
let_it_be(:valid_description) { 'Valid description' }
let(:package) { valid_package }
let(:version) { valid_version }
let(:description) { valid_description }
subject(:errors) do
mv = described_class.new(version: version, model: model1, package: package, project: model1.project,
description: description)
mv.validate
mv.errors
end
it 'validates a valid model version' do
expect(errors).to be_empty
end
describe 'version' do
where(:ctx, :version) do
'can\'t be blank' | ''
'is invalid' | '!!()()'
'is too long (maximum is 255 characters)' | ('a' * 256)
'must follow semantic version' | '1'
end
with_them do
it { expect(errors.messages.values.flatten).to include(ctx) }
end
context 'when version is not unique in project+name' do
let_it_be(:existing_model_version) do
create(:ml_model_versions, model: model1)
end
let(:version) { existing_model_version.version }
it { expect(errors).to include(:version) }
end
end
describe 'description' do
context 'when description is too large' do
let(:description) { 'a' * 10_001 }
it { expect(errors).to include(:description) }
end
context 'when description is below threshold' do
let(:description) { 'a' * 100 }
it { expect(errors).not_to include(:description) }
end
end
describe 'model' do
context 'when project is different' do
before do
allow(model1).to receive(:project_id).and_return(non_existing_record_id)
end
it { expect(errors[:model]).to include('model project must be the same') }
end
end
describe 'package' do
where(:property, :value, :error_message) do
:name | 'another_name' | 'package name must be the same'
:version | 'another_version' | 'package version must be the same'
:project_id | 0 | 'package project must be the same'
end
with_them do
before do
allow(package).to receive(property).and_return(:value)
end
it { expect(errors[:package]).to include(error_message) }
end
end
end
describe '#add_metadata' do
it 'accepts an array of metadata and persists it to the model version' do
input = [
{ project_id: base_project.id, key: 'tag1', value: 'value1' },
{ project_id: base_project.id, key: 'tag2', value: 'value2' }
]
expect { model_version1.add_metadata(input) }.to change { model_version1.metadata.count }.by(2)
end
it 'raises an error when duplicate key names are supplied' do
input = [
{ project_id: base_project.id, key: 'tag1', value: 'value1' },
{ project_id: base_project.id, key: 'tag1', value: 'value2' }
]
expect { model_version1.add_metadata(input) }.to raise_error(ActiveRecord::RecordInvalid)
end
it 'raises an error when validation fails' do
input = [
{ project_id: base_project.id, key: nil, value: 'value1' }
]
expect { model_version1.add_metadata(input) }.to raise_error(ActiveRecord::RecordInvalid)
end
end
describe '#find_or_create!' do
let_it_be(:existing_model_version) { create(:ml_model_versions, model: model1, version: '1.0.0') }
let(:version) { existing_model_version.version }
let(:package) { nil }
let(:description) { 'Some description' }
subject(:find_or_create) { described_class.find_or_create!(model1, version, package, description) }
context 'if model version exists' do
it 'returns the model version', :aggregate_failures do
expect { find_or_create }.not_to change { Ml::ModelVersion.count }
is_expected.to eq(existing_model_version)
end
end
context 'if model version does not exist' do
let(:version) { '2.0.0' }
let(:package) { create(:ml_model_package, project: model1.project, name: model1.name, version: version) }
it 'creates another model version', :aggregate_failures do
expect { find_or_create }.to change { Ml::ModelVersion.count }.by(1)
model_version = find_or_create
expect(model_version.version).to eq(version)
expect(model_version.model).to eq(model1)
expect(model_version.description).to eq(description)
expect(model_version.package).to eq(package)
end
end
end
describe '#by_project_id_and_id' do
let(:id) { model_version1.id }
let(:project_id) { model_version1.project.id }
subject { described_class.by_project_id_and_id(project_id, id) }
context 'if exists' do
it { is_expected.to eq(model_version1) }
end
context 'if id has no match' do
let(:id) { non_existing_record_id }
it { is_expected.to be_nil }
end
context 'if project id does not match' do
let(:project_id) { non_existing_record_id }
it { is_expected.to be_nil }
end
end
describe '.by_project_id_name_and_version' do
let(:version) { model_version1.version }
let(:project_id) { model_version1.project.id }
let(:model_name) { model_version1.model.name }
let_it_be(:latest_version) { create(:ml_model_versions, model: model_version1.model) }
subject { described_class.by_project_id_name_and_version(project_id, model_name, version) }
context 'if exists' do
it { is_expected.to eq(model_version1) }
end
context 'if id has no match' do
let(:version) { non_existing_record_id }
it { is_expected.to be_nil }
end
context 'if project id does not match' do
let(:project_id) { non_existing_record_id }
it { is_expected.to be_nil }
end
context 'if model name does not match' do
let(:model_name) { non_existing_record_id }
it { is_expected.to be_nil }
end
end
describe '.order_by_model_id_id_desc' do
subject { described_class.order_by_model_id_id_desc }
it 'orders by (model_id, id desc)' do
is_expected.to match_array([model_version2, model_version1, model_version4, model_version3])
end
end
describe '.latest_by_model' do
subject { described_class.latest_by_model }
it 'returns only the latest model version per model id' do
is_expected.to match_array([model_version3, model_version2])
end
end
describe '.including_relations' do
subject(:scoped) { described_class.including_relations }
it 'loads latest version', :aggregate_failures do
expect(scoped.first.association_cached?(:project)).to be(true)
expect(scoped.first.association_cached?(:model)).to be(true)
end
end
describe '.by_version' do
subject(:filtered) { described_class.by_version('4.0') }
it 'returns versions with the prefix' do
expect(filtered).to contain_exactly(model_version1, model_version4)
end
end
describe '.order_by_version' do
subject(:ordered) { described_class.order_by_version(order) }
context 'when order is asc' do
let(:order) { 'asc' }
it { is_expected.to match_array([model_version1, model_version4, model_version3, model_version2]) }
end
context 'when order is desc' do
let(:order) { 'desc' }
it { is_expected.to match_array([model_version2, model_version3, model_version4, model_version1]) }
end
context 'when order is invalid' do
let(:order) { 'invalid' }
it 'throws error' do
expect { ordered }.to raise_error(ArgumentError)
end
end
end
context 'when parsing semver components' do
let(:model_version) { build(:ml_model_versions, model: model1, version: semver, project: base_project) }
where(:semver, :valid, :major, :minor, :patch, :prerelease) do
'1' | false | nil | nil | nil | nil
'1.2' | false | nil | nil | nil | nil
'1.2.3' | true | 1 | 2 | 3 | nil
'1.2.3-beta' | true | 1 | 2 | 3 | 'beta'
'1.2.3.beta' | false | nil | nil | nil | nil
end
with_them do
it do
expect(model_version.semver_major).to be major
expect(model_version.semver_minor).to be minor
expect(model_version.semver_patch).to be patch
expect(model_version.semver_prerelease).to eq prerelease
end
end
end
end