From 341a24e22e19fee1d082a2ba8c7a48b8788b36bc Mon Sep 17 00:00:00 2001 From: Albert Yi Date: Sat, 6 Feb 2010 23:11:26 -0500 Subject: [PATCH] tag unit test --- app/models/tag.rb | 222 +++++++++++++++++++++++ app/models/user.rb | 2 + config/danbooru_default_config.rb | 37 ++++ db/development_structure.sql | 60 +++++- db/migrate/20100205162521_create_tags.rb | 17 ++ test/factories/tag.rb | 10 + test/factories/user.rb | 12 +- test/unit/tag_test.rb | 54 +++++- 8 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20100205162521_create_tags.rb create mode 100644 test/factories/tag.rb diff --git a/app/models/tag.rb b/app/models/tag.rb index 972262cc1..1629bc597 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,2 +1,224 @@ class Tag < ActiveRecord::Base + class CategoryMapping + Danbooru.config.reverse_tag_category_mapping.each do |value, category| + define_method(category.downcase) do + value + end + end + + def regexp + @regexp ||= Regexp.compile(Danbooru.config.tag_category_mapping.keys.sort_by {|x| -x.size}.join("|")) + end + + def value_for(string) + Danbooru.config.tag_category_mapping[string.downcase] || 0 + end + end + + attr_accessible :category + + after_save {|rec| Cache.put("tag_type:#{cache_safe_name}", rec.category_name)} + + + ### Category Methods ### + def self.categories + @category_mapping ||= CategoryMapping.new + end + + def category_name + Danbooru.config.reverse_tag_category_mapping[category] + end + + + ### Statistics Methods ### + def self.trending + raise NotImplementedError + end + + + ### Name Methods ### + def self.normalize_name(name) + name.downcase.tr(" ", "_").gsub(/\A[-~*]+/, "") + end + + def self.find_or_create_by_name(name, options = {}) + name = normalize_name(name) + category = self.class.types.general + + if name =~ /\A(#{categories.regexp}):(.+)\Z/ + category = self.class.types.value_for($1) + end + + tag = find_by_name(name) + + if tag + if category > 0 && !(options[:user] && !options[:user].is_privileged? && tag.post_count > 10) + tag.update_attribute(:category, category) + end + + tag + else + returning Tag.new do |tag| + tag.name = name + tag.category = category + tag.save + end + end + end + + def cache_safe_name + name.gsub(/[^a-zA-Z0-9_-]/, "_") + end + + + ### Update methods ### + def self.mass_edit(start_tags, result_tags, updater_id, updater_ip_addr) + raise NotImplementedError + + Post.find_by_tags(start_tags).each do |p| + start = TagAlias.to_aliased(scan_tags(start_tags)) + result = TagAlias.to_aliased(scan_tags(result_tags)) + tags = (p.cached_tags.scan(/\S+/) - start + result).join(" ") + p.update_attributes(:updater_user_id => updater_id, :updater_ip_addr => updater_ip_addr, :tags => tags) + end + end + + + ### Parse Methods ### + def self.scan_query(query) + query.to_s.downcase.scan(/\S+/).uniq + end + + def self.scan_tags(tags) + tags.to_s.downcase.gsub(/[&,;]/, "").scan(/\S+/).uniq + end + + def self.parse_cast(object, type) + case type + when :integer + object.to_i + + when :float + object.to_f + + when :date + begin + object.to_date + rescue Exception + nil + end + + when :filesize + object =~ /^(\d+(?:\.\d*)?|\d*\.\d+)([kKmM]?)[bB]?$/ + + size = $1.to_f + unit = $2 + + conversion_factor = case unit + when /m/i + 1024 * 1024 + when /k/i + 1024 + else + 1 + end + + (size * conversion_factor).to_i + end + end + + def self.parse_helper(range, type = :integer) + # "1", "0.5", "5.", ".5": + # (-?(\d+(\.\d*)?|\d*\.\d+)) + case range + when /^(.+?)\.\.(.+)/ + return [:between, parse_cast($1, type), parse_cast($2, type)] + + when /^<=(.+)/, /^\.\.(.+)/ + return [:lte, parse_cast($1, type)] + + when /^<(.+)/ + return [:lt, parse_cast($1, type)] + + when /^>=(.+)/, /^(.+)\.\.$/ + return [:gte, parse_cast($1, type)] + + when /^>(.+)/ + return [:gt, parse_cast($1, type)] + + else + return [:eq, parse_cast(range, type)] + + end + end + + def self.parse_query(query, options = {}) + q = Hash.new {|h, k| h[k] = []} + + scan_query(query).each do |token| + if token =~ /^(sub|md5|-rating|rating|width|height|mpixels|score|filesize|source|id|date|order|change|status|tagcount|gentagcount|arttagcount|chartagcount|copytagcount):(.+)$/ + if $1 == "user" + q[:user] = $2 + elsif $1 == "fav" + q[:fav] = $2 + elsif $1 == "sub" + q[:subscriptions] = $2 + elsif $1 == "md5" + q[:md5] = $2 + elsif $1 == "-rating" + q[:rating_negated] = $2 + elsif $1 == "rating" + q[:rating] = $2 + elsif $1 == "id" + q[:post_id] = parse_helper($2) + elsif $1 == "width" + q[:width] = parse_helper($2) + elsif $1 == "height" + q[:height] = parse_helper($2) + elsif $1 == "mpixels" + q[:mpixels] = parse_helper($2, :float) + elsif $1 == "score" + q[:score] = parse_helper($2) + elsif $1 == "filesize" + q[:filesize] = parse_helper($2, :filesize) + elsif $1 == "source" + q[:source] = $2.to_escaped_for_sql_like + "%" + elsif $1 == "date" + q[:date] = parse_helper($2, :date) + elsif $1 == "tagcount" + q[:tag_count] = parse_helper($2) + elsif $1 == "gentagcount" + q[:general_tag_count] = parse_helper($2) + elsif $1 == "arttagcount" + q[:artist_tag_count] = parse_helper($2) + elsif $1 == "chartagcount" + q[:character_tag_count] = parse_helper($2) + elsif $1 == "copytagcount" + q[:copyright_tag_count] = parse_helper($2) + elsif $1 == "order" + q[:order] = $2 + elsif $1 == "change" + q[:change] = parse_helper($2) + elsif $1 == "status" + q[:status] = $2 + end + elsif token[0] == "-" && token.size > 1 + q[:exclude] << token[1..-1] + elsif token[0] == "~" && token.size > 1 + q[:include] << token[1..-1] + elsif token.include?("*") + matches = where(["name LIKE ? ESCAPE E'\\\\'", token.to_escaped_for_sql_like]).all(:select => "name", :limit => 25, :order => "post_count DESC").map(&:name) + matches = ["~no_matches~"] if matches.empty? + q[:include] += matches + else + q[:related] << token + end + end + + q[:exclude] = TagAlias.to_aliased(q[:exclude], :strip_prefix => true) if q.has_key?(:exclude) + q[:include] = TagAlias.to_aliased(q[:include], :strip_prefix => true) if q.has_key?(:include) + q[:related] = TagAlias.to_aliased(q[:related]) if q.has_key?(:related) + + return q + end end diff --git a/app/models/user.rb b/app/models/user.rb index 61d2e710d..1c493cd36 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,8 @@ require 'digest/sha1' class User < ActiveRecord::Base attr_accessor :password + attr_accessible :password_hash, :email, :last_logged_in_at, :last_forum_read_at, :has_mail, :receive_email_notifications, :comment_threshold, :always_resize_images, :favorite_tags, :blacklisted_tags + validates_length_of :name, :within => 2..20, :on => :create validates_format_of :name, :with => /\A[^\s;,]+\Z/, :on => :create, :message => "cannot have whitespace, commas, or semicolons" validates_uniqueness_of :name, :case_sensitive => false, :on => :create diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 71d3d8295..1cfa13522 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -57,6 +57,11 @@ module Danbooru 1024 end + # When calculating statistics based on the posts table, gather this many posts to sample from. + def post_sample_size + 300 + end + # List of memcached servers def memcached_servers %w(localhost:11211) @@ -102,8 +107,40 @@ module Danbooru 5 end + # The name of the server the app is hosted on. def server_host Socket.gethostname end + + # Returns a hash mapping various tag categories to a numerical value. + # Be sure to update the reverse_tag_category_mapping also. + def tag_category_mapping + @tag_category_mapping ||= { + "general" => 0, + "gen" => 0, + + "artist" => 1, + "art" => 1, + + "copyright" => 3, + "copy" => 3, + "co" => 3, + + "character" => 4, + "char" => 4, + "ch" => 4 + } + end + + # Returns a hash maping numerical category values to their + # string equivalent. Be sure to update the tag_category_mapping also. + def reverse_tag_category_mapping + @reverse_tag_category_mapping ||= { + 0 => "General", + 1 => "Artist", + 3 => "Copyright", + 4 => "Character" + } + end end end diff --git a/db/development_structure.sql b/db/development_structure.sql index 20eab8d20..a6584013f 100644 --- a/db/development_structure.sql +++ b/db/development_structure.sql @@ -24,6 +24,40 @@ CREATE TABLE schema_migrations ( ); +-- +-- Name: tags; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE tags ( + id integer NOT NULL, + name character varying(255) NOT NULL, + post_count integer DEFAULT 0 NOT NULL, + category integer DEFAULT 0 NOT NULL, + related_tags text, + created_at timestamp without time zone, + updated_at timestamp without time zone +); + + +-- +-- Name: tags_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE tags_id_seq + START WITH 1 + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE tags_id_seq OWNED BY tags.id; + + -- -- Name: users; Type: TABLE; Schema: public; Owner: -; Tablespace: -- @@ -72,6 +106,13 @@ CREATE SEQUENCE users_id_seq ALTER SEQUENCE users_id_seq OWNED BY users.id; +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE tags ALTER COLUMN id SET DEFAULT nextval('tags_id_seq'::regclass); + + -- -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- @@ -79,6 +120,14 @@ ALTER SEQUENCE users_id_seq OWNED BY users.id; ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); +-- +-- Name: tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY tags + ADD CONSTRAINT tags_pkey PRIMARY KEY (id); + + -- -- Name: users_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: -- @@ -87,6 +136,13 @@ ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +-- +-- Name: index_tags_on_name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_tags_on_name ON tags USING btree (name); + + -- -- Name: index_users_on_email; Type: INDEX; Schema: public; Owner: -; Tablespace: -- @@ -112,4 +168,6 @@ CREATE UNIQUE INDEX unique_schema_migrations ON schema_migrations USING btree (v -- PostgreSQL database dump complete -- -INSERT INTO schema_migrations (version) VALUES ('20100204211522'); \ No newline at end of file +INSERT INTO schema_migrations (version) VALUES ('20100204211522'); + +INSERT INTO schema_migrations (version) VALUES ('20100205162521'); \ No newline at end of file diff --git a/db/migrate/20100205162521_create_tags.rb b/db/migrate/20100205162521_create_tags.rb new file mode 100644 index 000000000..1f54eaf07 --- /dev/null +++ b/db/migrate/20100205162521_create_tags.rb @@ -0,0 +1,17 @@ +class CreateTags < ActiveRecord::Migration + def self.up + create_table :tags do |t| + t.column :name, :string, :null => false + t.column :post_count, :integer, :null => false, :default => 0 + t.column :category, :integer, :null => false, :default => 0 + t.column :related_tags, :text + t.timestamps + end + + add_index :tags, :name, :unique => true + end + + def self.down + drop_table :tags + end +end diff --git a/test/factories/tag.rb b/test/factories/tag.rb new file mode 100644 index 000000000..f8460c4d8 --- /dev/null +++ b/test/factories/tag.rb @@ -0,0 +1,10 @@ +Factory.define(:tag) do |f| + f.name {Faker::Name.first_name} + f.post_count 0 + f.category Tag.categories.general + f.related_tags "" +end + +Factory.define(:artist_tag, :parent => :tag) do |f| + f.category Tag.categories.artist +end diff --git a/test/factories/user.rb b/test/factories/user.rb index 9a0d5797d..3151312a8 100644 --- a/test/factories/user.rb +++ b/test/factories/user.rb @@ -4,26 +4,26 @@ Factory.define(:user) do |f| f.email {Faker::Internet.email} end -Factory.define(:banned_user) do |f| +Factory.define(:banned_user, :parent => :user) do |f| f.is_banned true end -Factory.define(:privileged_user) do |f| +Factory.define(:privileged_user, :parent => :user) do |f| f.is_privileged true end -Factory.define(:contributor_user) do |f| +Factory.define(:contributor_user, :parent => :user) do |f| f.is_contributor true end -Factory.define(:janitor_user) do |f| +Factory.define(:janitor_user, :parent => :user) do |f| f.is_janitor true end -Factory.define(:moderator_user) do |f| +Factory.define(:moderator_user, :parent => :user) do |f| f.is_moderator true end -Factory.define(:admin_user) do |f| +Factory.define(:admin_user, :parent => :user) do |f| f.is_admin true end diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index 04498b2a8..77e62e684 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -1,8 +1,54 @@ -require 'test_helper' +require File.dirname(__FILE__) + '/../test_helper' class TagTest < ActiveSupport::TestCase - # Replace this with your real tests. - test "the truth" do - assert true + context "A tag category mapping" do + setup do + MEMCACHE.flush_all + end + + should "exist" do + assert_nothing_raised {Tag.categories} + end + + should "have convenience methods for the four main categories" do + assert_equal(0, Tag.categories.general) + assert_equal(1, Tag.categories.artist) + assert_equal(3, Tag.categories.copyright) + assert_equal(4, Tag.categories.character) + end + + should "have a regular expression for matching category names and shortcuts" do + regexp = Tag.categories.regexp + + assert_match(regexp, "artist") + assert_match(regexp, "art") + assert_match(regexp, "copyright") + assert_match(regexp, "copy") + assert_match(regexp, "co") + assert_match(regexp, "character") + assert_match(regexp, "char") + assert_match(regexp, "ch") + assert_no_match(regexp, "c") + assert_no_match(regexp, "woodle") + end + + should "map a category name to its value" do + assert_equal(0, Tag.categories.value_for("general")) + assert_equal(0, Tag.categories.value_for("gen")) + assert_equal(1, Tag.categories.value_for("artist")) + assert_equal(1, Tag.categories.value_for("art")) + assert_equal(0, Tag.categories.value_for("unknown")) + end + end + + context "A tag" do + setup do + MEMCACHE.flush_all + end + + should "know its category name" do + @tag = Factory.create(:artist_tag) + assert_equal("Artist", @tag.category_name) + end end end