301 lines
9.5 KiB
Ruby
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
|