Merge branch 'feature/71896-change-identifier-with-semantic-identifiers' into feature/72855-new-project-with-semantic-identifiers

This commit is contained in:
Tomas Hykel
2026-03-16 21:16:55 +01:00
25 changed files with 584 additions and 613 deletions
@@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= settings_primer_form_with(model: project,
url: project_identifier_path(project),
id: "change-identifier-form",
class: "mt-4") do |f| %>
mt: 4) do |f| %>
<%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %>
<% end %>
<% end %>
@@ -46,27 +46,29 @@ See COPYRIGHT and LICENSE files for more details.
%>
<% end %>
<% if OpenProject::FeatureDecisions.semantic_work_package_ids_active? %>
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
<%=
render(Primer::Beta::Subhead.new) do |component|
component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) }
end
%>
<%=
settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f|
render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f)))
end
%>
<%= render(Primer::Beta::Button.new(
tag: :a,
href: projects_identifier_dialog_path(project_id: project),
data: { turbo_stream: true }
)) { t("projects.settings.change_identifier") } %>
<% end %>
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
<%=
render(Primer::Beta::Subhead.new) do |component|
component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) }
end
%>
<%=
settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f|
render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f)))
end
%>
<%=
render(
Primer::Beta::Button.new(
tag: :a,
href: identifier_update_dialog_project_identifier_path(project_id: project),
data: { turbo_stream: true },
mt: 2
)
) { t("projects.settings.change_identifier") }
%>
<% end %>
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
<%=
render(Primer::Beta::Subhead.new) do |component|
@@ -24,21 +24,6 @@
end
end
unless OpenProject::FeatureDecisions.semantic_work_package_ids_active?
header.with_action_button(
tag: :a,
mobile_icon: :pencil,
mobile_label: t("projects.settings.change_identifier"),
size: :medium,
href: project_identifier_path(@project),
aria: { label: t("projects.settings.change_identifier") },
title: t("projects.settings.change_identifier")
) do |button|
button.with_leading_visual_icon(icon: :pencil)
t("projects.settings.change_identifier")
end
end
header.with_action_menu(
menu_arguments: {
anchor_align: :end
@@ -86,10 +86,10 @@
end
end
row.with_column(flex: 1) do
render(Primer::Beta::Text.new) { entry[:suggested_handle] }
render(Primer::Beta::Text.new) { entry[:suggested_identifier] }
end
row.with_column(flex: 1) do
render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_handle]) }
render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_identifier]) }
end
end
end
@@ -53,12 +53,11 @@ module WorkPackages
end
# Produces a realistic-looking example work package ID for the preview table.
# The sequence number is derived deterministically from the handle so it looks
# The sequence number is derived deterministically from the identifier so it looks
# varied across projects but is stable across renders. Range: 1500.
# Single-digit numbers are zero-padded ("FP-07"), two/three digits are not ("FP-42").
def sample_wp_id(handle)
n = (handle.bytes.sum % 500) + 1
"#{handle}-#{format('%02d', n)}"
def sample_wp_id(identifier)
n = (identifier.bytes.sum % 500) + 1
"#{identifier}-#{n}"
end
end
end
@@ -34,7 +34,9 @@ class Projects::IdentifierController < ApplicationController
before_action :find_project_by_project_id
before_action :authorize
def show; end
def identifier_update_dialog
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project)
end
def update
service_call = Projects::UpdateService
@@ -45,11 +47,9 @@ class Projects::IdentifierController < ApplicationController
if service_call.success?
flash[:notice] = I18n.t(:notice_successful_update)
redirect_to project_settings_general_path(@project)
elsif OpenProject::FeatureDecisions.semantic_work_package_ids_active? # Handle error for the new modal
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project),
else
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: service_call.result),
status: :unprocessable_entity
else # Handle error for the legacy standalone identifier setting page
render action: "show", status: :unprocessable_entity
end
end
end
+1 -9
View File
@@ -34,8 +34,7 @@ class ProjectsController < ApplicationController
menu_item :overview
menu_item :roadmap, only: :roadmap
before_action :find_project, except: %i[index new create destroy destroy_info identifier_dialog]
before_action :find_project_by_project_id, only: %i[identifier_dialog]
before_action :find_project, except: %i[index new create destroy destroy_info]
before_action :find_project_including_archived, only: %i[destroy destroy_info]
before_action :load_query_or_deny_access, only: %i[index]
before_action :authorize,
@@ -46,7 +45,6 @@ class ProjectsController < ApplicationController
before_action :find_optional_template, only: %i[new create]
no_authorization_required! :index
no_authorization_required! :identifier_dialog
include SortHelper
include PaginationHelper
@@ -163,12 +161,6 @@ class ProjectsController < ApplicationController
respond_with_dialog Projects::DeleteDialogComponent.new(project: @project)
end
def identifier_dialog
return render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active?
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project)
end
def deactivate_work_package_attachments
call = Projects::UpdateService
.new(user: current_user, model: @project, contract_class: Projects::SettingsContract)
@@ -31,13 +31,12 @@ module Projects
module Settings
class EditableIdentifierForm < ApplicationForm
form do |f|
if Project.semantic_alphanumeric_identifier?
if Setting::WorkPackageIdentifier.alphanumeric?
f.text_field(
name: :identifier,
label: attribute_name(:identifier),
caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"),
required: true,
maxlength: Project::SEMANTIC_IDENTIFIER_MAX_LENGTH,
validation_message: validation_message_for(:identifier)
)
else
@@ -46,7 +45,6 @@ module Projects
label: attribute_name(:identifier),
caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"),
required: true,
maxlength: Project::IDENTIFIER_MAX_LENGTH,
validation_message: validation_message_for(:identifier)
)
end
@@ -55,7 +53,7 @@ module Projects
private
def validation_message_for(attribute)
model.errors.messages_for(attribute).to_sentence.presence
model.errors.full_messages_for(attribute).to_sentence.presence
end
end
end
@@ -31,7 +31,7 @@ module Projects
module Settings
class IdentifierForm < ApplicationForm
form do |f|
caption_key = if Project.semantic_alphanumeric_identifier?
caption_key = if Setting::WorkPackageIdentifier.alphanumeric?
:text_project_identifier_description
else
:text_project_identifier_url_description
+7 -9
View File
@@ -48,7 +48,7 @@ class Project < ApplicationRecord
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
# reserved identifiers
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog identifier_suggestion].freeze
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze
enum :workspace_type, {
project: "project",
@@ -211,18 +211,20 @@ class Project < ApplicationRecord
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
validates :identifier,
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
if: ->(p) { p.identifier_changed? && p.identifier.present? && !Project.semantic_alphanumeric_identifier? }
if: ->(p) {
p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric?
}
# When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules.
validates :identifier,
format: { with: /\A[A-Z]/, message: :must_start_with_letter },
if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? }
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
validates :identifier,
format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters },
length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH },
if: ->(p) {
p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? &&
p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? &&
p.identifier.match?(/\A[A-Z]/)
}
@@ -280,12 +282,8 @@ class Project < ApplicationRecord
User.current.allowed_in_project?(:copy_projects, self)
end
def self.semantic_alphanumeric_identifier?
OpenProject::FeatureDecisions.semantic_work_package_ids_active? && Setting::WorkPackageIdentifier.alphanumeric?
end
def self.suggest_identifier(name)
if semantic_alphanumeric_identifier?
if Setting::WorkPackageIdentifier.alphanumeric?
WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name)
else
name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project"
@@ -41,33 +41,46 @@ module WorkPackages
.limit(DISPLAY_COUNT)
.to_a
suggestions = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.call(
suggestions = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.call(
preview,
in_use_handles:,
reserved_handles:
in_use_identifiers:,
reserved_identifiers:
)
Result.new(projects_data: suggestions, total_count: total)
projects_data = suggestions.map do |entry|
entry.merge(error_reason: error_reason(entry[:current_identifier]))
end
Result.new(projects_data:, total_count: total)
end
private
# FIXME: Replace WHERE clause with:
# Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id))
# once all valid identifiers have been migrated to handle rows.
def problematic_scope
@problematic_scope ||= Project.where(
"length(identifier) > ? OR identifier ~ ?",
WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH,
"[^a-zA-Z0-9]"
ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max],
"[^a-zA-Z0-9_]"
)
end
def in_use_handles
Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set
def error_reason(identifier)
if identifier.length > ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
:too_long
elsif identifier.match?(/[^a-zA-Z0-9_]/)
:special_characters
elsif in_use_identifiers.include?(identifier)
:in_use
elsif reserved_identifiers.include?(identifier)
:reserved
end
end
def reserved_handles
def in_use_identifiers
@in_use_identifiers ||= Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set
end
def reserved_identifiers
# TODO: OldProjectIdentifier.pluck(:identifier).to_set
# once the OldProjectIdentifier model and migration are added.
Set.new
@@ -1,146 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
module IdentifierAutofix
# Generates a short uppercase acronym suggestion for each given project.
#
# The suggestion is derived from the project name: taking the first letter of
# each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two
# projects produce the same acronym, a numeric suffix resolves the collision
# ("SC", "SC2", "SC3", …).
#
# Each result entry includes an error_reason classifying why the project's
# current identifier is problematic:
# - :too_long — identifier length exceeds HANDLE_MAX_LENGTH
# - :special_characters — identifier contains characters outside [a-zA-Z0-9]
# - :in_use — identifier is another project's active handle
# - :reserved — identifier appears in another project's handle history
#
class ProjectHandleSuggestionGenerator
HANDLE_MAX_LENGTH = 5
SINGLE_WORD_LENGTH = 3
FALLBACK_HANDLE = "PROJ"
SUFFIX_LIMIT = 10_000
def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new)
new.call(projects, reserved_handles:, in_use_handles:)
end
def self.suggest_for_name(name)
new.suggest_for_name(name)
end
def suggest_for_name(name)
handle_from_name(name)
end
def call(projects, reserved_handles:, in_use_handles:)
generate_suggestions(projects, reserved_handles:, in_use_handles:)
end
private
def generate_suggestions(projects, reserved_handles:, in_use_handles:)
used_handles = Set.new
used_handles.merge(in_use_handles)
used_handles.merge(reserved_handles)
projects.map do |project|
base = handle_from_name(project.name)
handle = unique_handle(base, used_handles)
used_handles << handle
{
project:,
current_identifier: project.identifier,
suggested_handle: handle,
error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:)
}
end
end
def handle_from_name(name)
# Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside
# their word rather than treated as separators by the ASCII-only [a-zA-Z].
words = name.to_s.scan(/[[:alpha:][:digit:]]+/)
return FALLBACK_HANDLE if words.empty?
words.size == 1 ? handle_from_single_word(words.first) : handle_from_words(words)
end
def handle_from_single_word(word)
# e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_HANDLE
t = I18n.with_locale(:en) { I18n.transliterate(word) }
chars = t.scan(/[A-Za-z0-9]/).first(SINGLE_WORD_LENGTH).map(&:upcase).join
chars.empty? ? FALLBACK_HANDLE : chars
end
def handle_from_words(words)
# Multi-word names: take initials (first letter of each word), truncated.
acronym = words.filter_map do |word|
ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0]
ch if ch&.match?(/\A[A-Z0-9]\z/)
end.join
return FALLBACK_HANDLE if acronym.empty?
acronym.slice(0, HANDLE_MAX_LENGTH)
end
def unique_handle(base, used_handles)
return base unless used_handles.include?(base)
counter = 2
loop do
raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \
if counter > SUFFIX_LIMIT
suffix = counter.to_s
candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}"
break candidate unless used_handles.include?(candidate)
counter += 1
end
end
def error_reason(identifier, reserved_handles:, in_use_handles:)
if identifier.length > HANDLE_MAX_LENGTH
:too_long
elsif identifier.match?(/[^a-zA-Z0-9]/)
:special_characters
elsif in_use_handles.include?(identifier)
:in_use
elsif reserved_handles.include?(identifier)
:reserved
end
end
end
end
end
@@ -0,0 +1,217 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
module IdentifierAutofix
# Generates a short uppercase semantic identifier for each project.
#
# Identifiers are 210 uppercase alphanumeric characters that always start
# with a letter.
#
# == Algorithm
#
# *Multi-word names* use word initials, truncated to +IDENTIFIER_LENGTH[:base]+ (5):
# "Flight Planning Algorithm" → "FPA"
# "A B C D E F G H I J K" → "ABCDE"
#
# *Single-word names* use the first +IDENTIFIER_LENGTH[:single_word]+ (3) characters:
# "Banana" → "BAN"
#
# *Accented characters* are transliterated ("Cécile" → "CEC").
# *Non-Latin scripts* that have no transliteration fall back to "PROJ".
#
# == Collision resolution
#
# When a candidate is already taken, the identifier is progressively widened
# with more characters from the name, up to +IDENTIFIER_LENGTH[:max]+ (10):
#
# Multi-word: "SC" → "STC" → "STCO" → "STRCO" → … → "STREACOMMU"
# Single-word: "BAN" → "BANA" → "BANAN" → "BANANA"
# Initials: "ABCDE" → "ABCDEF" → … → "ABCDEFGHIJ"
#
# If all expansion candidates are exhausted, a numeric suffix is appended
# as a last resort ("GO" → "GO2").
#
class ProjectIdentifierSuggestionGenerator
IDENTIFIER_LENGTH = { min: 2, max: 10, base: 5, single_word: 3 }.freeze
FALLBACK_IDENTIFIER = "PROJ"
SUFFIX_LIMIT = 10_000
def self.call(projects, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
new.call(projects, reserved_identifiers:, in_use_identifiers:)
end
# Returns a single suggested identifier string for the given project name.
#
def self.suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
new.suggest_identifier(name, reserved_identifiers:, in_use_identifiers:)
end
def call(projects, reserved_identifiers:, in_use_identifiers:)
generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:)
end
def suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
used = reserved_identifiers | in_use_identifiers
candidates = identifier_candidates(name)
find_unique(candidates, used)
end
private
def generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:)
used_identifiers = reserved_identifiers | in_use_identifiers
projects.map do |project|
candidates = identifier_candidates(project.name)
identifier = find_unique(candidates, used_identifiers)
used_identifiers << identifier
{
project:,
current_identifier: project.identifier,
suggested_identifier: identifier
}
end
end
# Returns an ordered list of progressively longer identifier candidates
# derived from the project name. The first unique candidate wins.
def identifier_candidates(name)
words = transliterated_words(name)
return [FALLBACK_IDENTIFIER] if words.empty?
candidates = words.size == 1 ? single_word_candidates(words.first) : multi_word_candidates(words)
candidates = candidates.filter_map do |c|
stripped = ensure_starts_with_letter(c)
stripped if stripped&.length.to_i >= IDENTIFIER_LENGTH[:min]
end
candidates.presence || [FALLBACK_IDENTIFIER]
end
# Splits a name into words and transliterates each, returning only words
# that contain at least one ASCII-alphanumeric character.
def transliterated_words(name)
# Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside
# their word rather than treated as separators by the ASCII-only [a-zA-Z].
raw_words = name.to_s.scan(/[[:alpha:][:digit:]]+/)
raw_words.filter_map do |word|
t = I18n.with_locale(:en) { I18n.transliterate(word) }
clean = t.gsub(/[^A-Za-z0-9]/, "")
clean.presence
end
end
# "Banana" → ["BAN", "BANA", "BANAN", "BANANA"]
def single_word_candidates(word)
chars = word.upcase
max_len = [chars.length, IDENTIFIER_LENGTH[:max]].min
return [] if max_len < IDENTIFIER_LENGTH[:min]
start_len = IDENTIFIER_LENGTH[:single_word].clamp(IDENTIFIER_LENGTH[:min], max_len)
(start_len..max_len).map { chars[0, it] }
end
# "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …]
# "A B C D E F G H I J K" → ["ABCDE", "ABCDEF", …, "ABCDEFGHIJ"]
#
# Starts with initials truncated to IDENTIFIER_LENGTH[:base], progressively
# includes more initials, then expands words beyond single chars.
def multi_word_candidates(words)
upcased_words = words.map(&:upcase)
candidates = initial_candidates(upcased_words)
append_expansion_candidates!(candidates, upcased_words) if candidates.last.length < IDENTIFIER_LENGTH[:max]
candidates
end
def initial_candidates(upcased_words)
initials = upcased_words.pluck(0).join[0, IDENTIFIER_LENGTH[:max]]
start = [IDENTIFIER_LENGTH[:base], initials.length].min
(start..initials.length).map { initials[0, it] }
end
# Progressively pulls more characters from each word left-to-right.
def append_expansion_candidates!(candidates, upcased_words)
chars_per_word = upcased_words.map { 1 }
loop do
expandable = upcased_words.each_index.find { |i| chars_per_word[i] < upcased_words[i].length }
break unless expandable
chars_per_word[expandable] += 1
candidate = build_candidate(upcased_words, chars_per_word)
candidates << candidate unless candidates.include?(candidate)
break if candidate.length >= IDENTIFIER_LENGTH[:max]
end
end
def build_candidate(upcased_words, chars_per_word)
parts = upcased_words.each_with_index.map { |w, i| w[0, chars_per_word[i]] }
parts.join[0, IDENTIFIER_LENGTH[:max]]
end
# Strips leading digits so identifiers always start with a letter.
# For names like "3D Printing Lab", initials "3PL" become "PL".
# This is lossy but acceptable for auto-generated suggestions.
def ensure_starts_with_letter(candidate)
candidate.sub(/\A\d+/, "").presence
end
# Iterates through expansion candidates, then falls back to numeric suffix.
# Candidates are already filtered to start with a letter and meet min length.
def find_unique(candidates, used_identifiers)
candidates.each do |candidate|
return candidate unless used_identifiers.include?(candidate)
end
base = candidates.last || FALLBACK_IDENTIFIER
numeric_suffix_fallback(base, used_identifiers)
end
def numeric_suffix_fallback(base, used_identifiers)
# Ensure the base itself starts with a letter before appending digits.
base = ensure_starts_with_letter(base) || FALLBACK_IDENTIFIER
counter = 2
loop do
raise "Could not find a unique identifier for base '#{base}' within #{SUFFIX_LIMIT} attempts" \
if counter > SUFFIX_LIMIT
suffix = counter.to_s
candidate = "#{base[0, IDENTIFIER_LENGTH[:max] - suffix.length]}#{suffix}"
return candidate unless used_identifiers.include?(candidate)
counter += 1
end
end
end
end
end
@@ -1,94 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
render Primer::OpenProject::PageHeader.new do |header|
header.with_title { t("project.identifier.title") }
header.with_breadcrumbs(
[{ href: project_overview_path(@project.id), text: @project.name },
{ href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") },
t("project.identifier.title")]
)
end
%>
<%= error_messages_for @project %>
<%= form_for @project,
url: project_identifier_path(@project),
html: { class: "danger-zone form -vertical" } do |f| %>
<section class="form--section">
<h3 class="form--section-title">
<%= t("project.identifier.title") %>
</h3>
<p class="danger-zone--warning">
<span class="icon icon-error"></span>
<span><%= t("project.identifier.warning_one").html_safe %></span>
<br>
<span class="icon icon-error"></span>
<span><%= t("project.identifier.warning_two").html_safe %></span>
</p>
<%= styled_label_tag "identifier", Project.human_attribute_name(:identifier), class: "-required" %>
<div class="danger-zone--verification">
<% if Project.semantic_alphanumeric_identifier? %>
<%= f.text_field :identifier,
pattern: "[A-Z][A-Z0-9_]*",
maxlength: 10,
title: t(:text_project_identifier_handle_format) %>
<% else %>
<%= f.text_field :identifier,
pattern: "[a-z][a-z0-9\\-_]*",
maxlength: 100,
title: t(:text_project_identifier_format) %>
<% end %>
<span>
<%= f.submit t(:button_update), class: "button -primary -with-icon icon-checkmark" %>
</span>
<span>
<%= link_to project_settings_general_path(@project), class: "button" do %>
<%= op_icon("button--icon icon-cancel") %>
<span class="button--text"><%= t(:button_cancel) %></span>
<% end %>
</span>
</div>
<br>
<div class="form--field-instructions">
<%= t(
:text_length_between, min: 1,
max: Project::IDENTIFIER_MAX_LENGTH
) %>
<%= t(:text_project_identifier_info).html_safe %>
</div>
</section>
<% end %>
+1 -1
View File
@@ -137,7 +137,7 @@ Rails.application.reloader.to_prepare do
"projects/settings/subitems": %i[show update],
"projects/settings/template": %i[show update toggle_template],
"projects/templated": %i[create destroy],
"projects/identifier": %i[show update update_identifier_dialog],
"projects/identifier": %i[show update identifier_update_dialog],
"projects/status": %i[update destroy]
},
permissible_on: :project,
+1 -1
View File
@@ -394,7 +394,7 @@ en:
box_header:
label_project: Project
label_previous_identifier: Previous identifier
label_autofixed_suggestion: Autofixed suggestion
label_autofixed_suggestion: Future identifier
label_example_work_package_id: Example work package ID
autofix_preview:
error_too_long: Has to be fewer than 5 characters
+3 -4
View File
@@ -283,9 +283,6 @@ Rails.application.routes.draw do
namespace :projects do
resource :menu, only: %i[show]
resource :filters, only: %i[show]
get "identifier_dialog", to: "/projects#identifier_dialog",
as: :identifier_dialog,
defaults: { format: :turbo_stream }
end
get "projects/identifier_suggestion", to: "projects/identifier_suggestions#show", as: :projects_identifier_suggestion
@@ -359,7 +356,9 @@ Rails.application.routes.draw do
get :dialog
end
end
resource :identifier, only: %i[show update], controller: "identifier"
resource :identifier, only: %i[show update], controller: "identifier" do
get :identifier_update_dialog, on: :member, defaults: { format: :turbo_stream }
end
resource :status, only: %i[update destroy], controller: "status"
resource :creation_wizard, only: %i[show update], controller: "creation_wizard" do
get :help_text, on: :member
@@ -76,9 +76,7 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do
end
end
describe "Identifier" do
before { with_flags(semantic_work_package_ids: true) }
describe "Identifier", with_flag: { semantic_work_package_ids: true } do
it_behaves_like "section with heading", "Identifier"
it "renders a Change identifier button" do
@@ -38,7 +38,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent,
{
project:,
current_identifier: identifier,
suggested_handle: handle,
suggested_identifier: handle,
error_reason:
}
end
@@ -76,7 +76,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent,
it "shows a realistic example work package ID" do
render_inline(component)
# Numbers are deterministic from the handle's byte sum; format is handle + zero-padded number.
# Numbers are deterministic from the identifier's byte sum.
expect(page).to have_text("FP-151") # "FP".bytes.sum % 500 + 1 = 151
expect(page).to have_text("VLNP-321") # "VLNP".bytes.sum % 500 + 1 = 321
end
@@ -131,7 +131,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t
let(:problematic_result) do
WorkPackages::IdentifierAutofix::PreviewQuery::Result.new(
projects_data: [
{ project:, current_identifier: "bad-proj", suggested_handle: "BP", error_reason: :special_characters }
{ project:, current_identifier: "bad-proj", suggested_identifier: "BP", error_reason: :special_characters }
],
total_count: 1
)
@@ -48,7 +48,7 @@ RSpec.describe Projects::IdentifierController do
context "with an invalid identifier" do
it "does not change the project identifier and correctly renders the view" do
previous_identifier = project.identifier
put :update, params: { project_id: project.id, project: { identifier: "bad identifier" } }
put :update, params: { project_id: project.id, project: { identifier: "bad identifier" }, format: :turbo_stream }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include("Identifier is invalid")
+49 -80
View File
@@ -48,106 +48,75 @@ RSpec.describe "Projects", "editing settings", :js do
visit project_settings_general_path(project.id)
expect(page).to have_no_text :all, "Active"
expect(page).to have_no_text :all, "Identifier"
end
describe "identifier edit" do
it "updates the project identifier" do
visit projects_path
click_on project.name
click_on "Project settings"
click_on "Change identifier"
context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do
it "updates the project identifier via dialog" do
visit project_settings_general_path(project)
expect(page).to have_content "Change the project's identifier".upcase
expect(page).to have_current_path "/projects/foo-project/identifier"
click_on "Change identifier"
fill_in "project[identifier]", with: "foo-bar"
click_on "Update"
expect(page).to have_dialog "Change project identifier"
expect(page).to have_content "Successful update."
expect(page)
.to have_current_path %r{/projects/foo-bar/settings/general}
expect(Project.first.identifier).to eq "foo-bar"
within "dialog" do
expect(page).to have_text "This will permanently change identifiers and URLs"
fill_in "project[identifier]", with: "foo-bar"
click_on "Change identifier"
end
expect(page).to have_content "Successful update."
expect(page).to have_current_path %r{/projects/foo-bar/settings/general}
expect(project.reload.identifier).to eq "foo-bar"
end
end
it "displays error messages on invalid input" do
visit project_identifier_path(project)
context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do
it "updates the project identifier via dialog" do
visit project_settings_general_path(project)
fill_in "project[identifier]", with: "FOOO"
click_on "Update"
click_on "Change identifier"
expect(page).to have_content "Identifier is invalid."
expect(page).to have_current_path "/projects/foo-project/identifier"
end
expect(page).to have_dialog "Change project identifier"
context "with the semantic work package IDs flag enabled", with_flag: { semantic_work_package_ids: true } do
context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do
it "updates the project identifier via dialog" do
visit project_settings_general_path(project)
within "dialog" do
expect(page).to have_text "This will permanently change identifiers and URLs"
fill_in "project[identifier]", with: "FOOBAR"
click_on "Change identifier"
end
expect(page).to have_content "Successful update."
expect(page).to have_current_path %r{/projects/FOOBAR/settings/general}
expect(project.reload.identifier).to eq "FOOBAR"
end
it "displays an error when the identifier does not start with a letter" do
visit project_settings_general_path(project)
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
fill_in "project[identifier]", with: "123ABC"
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
expect(page).to have_text "This will permanently change identifiers and URLs"
fill_in "project[identifier]", with: "foo-bar"
click_on "Change identifier"
end
expect(page).to have_content "Successful update."
expect(page).to have_current_path %r{/projects/foo-bar/settings/general}
expect(project.reload.identifier).to eq "foo-bar"
expect(page).to have_text "The first character has to be a letter."
end
end
context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do
it "updates the project identifier via dialog" do
visit project_settings_general_path(project)
it "displays an error when the identifier contains special characters" do
visit project_settings_general_path(project)
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
fill_in "project[identifier]", with: "FOO@BAR"
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
expect(page).to have_text "This will permanently change identifiers and URLs"
fill_in "project[identifier]", with: "FOOBAR"
click_on "Change identifier"
end
expect(page).to have_content "Successful update."
expect(page).to have_current_path %r{/projects/FOOBAR/settings/general}
expect(project.reload.identifier).to eq "FOOBAR"
end
it "displays an error when the identifier does not start with a letter" do
visit project_settings_general_path(project)
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
fill_in "project[identifier]", with: "123ABC"
click_on "Change identifier"
expect(page).to have_text "The first character has to be a letter."
end
end
it "displays an error when the identifier contains special characters" do
visit project_settings_general_path(project)
click_on "Change identifier"
expect(page).to have_dialog "Change project identifier"
within "dialog" do
fill_in "project[identifier]", with: "FOO@BAR"
click_on "Change identifier"
expect(page).to have_text "Special characters not allowed."
end
expect(page).to have_text "Special characters not allowed."
end
end
end
@@ -52,6 +52,15 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do
end
end
context "when a project has underscores in its identifier" do
before { create_valid_project(name: "My Project", identifier: "my_proj") }
it "does not flag it as problematic" do
expect(result.total_count).to eq(0)
expect(result.projects_data).to be_empty
end
end
context "when there are fewer than DISPLAY_COUNT problematic projects" do
let!(:problematic) do
[
@@ -90,15 +99,32 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do
let!(:second_project) { create_problematic_project(name: "Foxtrot Papa", identifier: "foxtrot-papa") }
it "does not assign the same handle to both" do
handles = result.projects_data.pluck(:suggested_handle)
expect(handles.uniq.size).to eq(handles.size)
identifiers = result.projects_data.pluck(:suggested_identifier)
expect(identifiers.uniq.size).to eq(identifiers.size)
end
end
it "returns Result entries shaped like generator output" do
it "returns Result entries with project, current_identifier, suggested_identifier, and error_reason" do
create_problematic_project(name: "Alpha Beta", identifier: "alpha-beta")
entry = result.projects_data.first
expect(entry).to include(:project, :current_identifier, :suggested_handle, :error_reason)
expect(entry).to include(:project, :current_identifier, :suggested_identifier, :error_reason)
end
describe "error_reason classification" do
it "assigns :too_long when identifier length exceeds MAX_IDENTIFIER_LENGTH" do
create_problematic_project(name: "Test", identifier: "averylongidentifier")
expect(result.projects_data.first[:error_reason]).to eq(:too_long)
end
it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do
create_problematic_project(name: "Test", identifier: "ab-c")
expect(result.projects_data.first[:error_reason]).to eq(:special_characters)
end
it "assigns :too_long (priority) when identifier is both too long and has special chars" do
create_problematic_project(name: "Test", identifier: "my-very-long-identifier")
expect(result.projects_data.first[:error_reason]).to eq(:too_long)
end
end
end
@@ -1,194 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator do
describe ".call" do
context "when given an empty array" do
it "returns an empty array" do
expect(described_class.call([])).to be_empty
end
end
context "when a project has a too-long identifier" do
shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") }
it "returns one suggestion entry for the project" do
result = described_class.call([project])
expect(result.size).to eq(1)
expect(result.first[:project]).to eq(project)
expect(result.first[:current_identifier]).to eq("verylongidentifier")
expect(result.first[:error_reason]).to eq(:too_long)
expect(result.first[:suggested_handle]).to be_present
expect(result.first[:suggested_handle].length).to be <= described_class::HANDLE_MAX_LENGTH
end
end
context "when a project has a special-character identifier" do
# "fs" is 2 chars (≤ HANDLE_MAX_LENGTH) but contains no special chars;
# use "f-s" (3 chars ≤ HANDLE_MAX_LENGTH) to trigger :special_characters.
shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") }
it "returns a suggestion entry with error_reason :special_characters" do
result = described_class.call([project])
expect(result.size).to eq(1)
expect(result.first[:error_reason]).to eq(:special_characters)
end
end
context "when multiple projects generate conflicting handles" do
shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") }
shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") }
it "generates unique handles for each project" do
handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle)
expect(handles.uniq.size).to eq(handles.size)
end
it "appends a numeric suffix to resolve conflicts" do
handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle)
expect(handles).to include("SC")
expect(handles.any? { it.match?(/\ASC\d+\z/) }).to be true
end
end
end
describe ".suggest_for_name" do
it "returns an acronym derived from a multi-word name" do
expect(described_class.suggest_for_name("Flight Planning Algorithm")).to eq("FPA")
end
it "returns an uppercase prefix for a single-word name" do
expect(described_class.suggest_for_name("Banana")).to eq("BAN")
end
end
describe "handle generation from project name" do
{
# Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars
"Banana" => "BAN",
"Kiwi" => "KIW",
"Strawberry" => "STR",
"Cécile" => "CEC", # single word with accented letter
# Multi-word names: initials, truncated to HANDLE_MAX_LENGTH (5)
"Flight Planning Algorithm" => "FPA",
"Fly & Sky" => "FS",
"Social media marketing" => "SMM",
"Arcanos (mobile-web-app)" => "AMWA",
"Flight Planning Training" => "FPT",
"A B C D E F G H I J K" => "ABCDE", # truncated to HANDLE_MAX_LENGTH (5)
"Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"]
"étude de cas" => "EDC", # Unicode: é→E via transliteration
# Non-Latin scripts have no transliteration entries (I18n.transliterate → "?").
# All initials are dropped and the name falls back to FALLBACK_HANDLE.
"日本語プロジェクト" => "PROJ", # Japanese: every initial → "?" → fallback
"Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped
}.each do |project_name, expected_handle|
it "generates '#{expected_handle}' from '#{project_name}'" do
project = create(:project, identifier: "bad-id", name: project_name)
expect(described_class.call([project]).first[:suggested_handle]).to eq(expected_handle)
end
end
end
describe "unique_handle conflict resolution" do
it "uses the base handle when not yet taken" do
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
expect(described_class.call([project]).first[:suggested_handle]).to eq("SC")
end
it "increments the suffix until unique" do
p1 = create(:project, identifier: "sc-a", name: "Stream Communicator")
p2 = create(:project, identifier: "sc-b", name: "Stream Channel")
p3 = create(:project, identifier: "sc-c", name: "Something Cool")
expect(described_class.call([p1, p2, p3]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3")
end
it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do
p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J")
p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J")
handles = described_class.call([p1, p2]).pluck(:suggested_handle)
expect(handles.all? { it.length <= described_class::HANDLE_MAX_LENGTH }).to be true
expect(handles.uniq.size).to eq(2)
end
it "does not suggest a handle that is already in use (pre-seeded collision)" do
# "SC" is pre-seeded as an in-use handle; the generator must skip it and use "SC2".
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
result = described_class.call([project], in_use_handles: Set["SC"])
expect(result.first[:suggested_handle]).not_to eq("SC")
expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) # e.g. "SC2"
end
end
describe "error reason assignment" do
it "assigns :too_long when identifier length exceeds HANDLE_MAX_LENGTH" do
project = create(:project, identifier: "verylongidentifier", name: "Test")
expect(described_class.call([project]).first[:error_reason]).to eq(:too_long)
end
it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do
project = create(:project, identifier: "ab-c", name: "Test")
expect(described_class.call([project]).first[:error_reason]).to eq(:special_characters)
end
it "assigns :too_long (priority) when identifier is both too long and has special chars" do
project = create(:project, identifier: "my-very-long-identifier", name: "Test")
expect(described_class.call([project]).first[:error_reason]).to eq(:too_long)
end
it "assigns :in_use when identifier is another project's active handle" do
# "abc" is valid (lowercase alphanumeric, ≤ 5 chars, no special chars)
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
result = described_class.call([project], in_use_handles: Set["abc"])
expect(result.first[:error_reason]).to eq(:in_use)
end
it "assigns :reserved when identifier appears in historical handles" do
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
result = described_class.call([project], reserved_handles: Set["abc"])
expect(result.first[:error_reason]).to eq(:reserved)
end
it "prefers :in_use over :reserved when identifier is in both sets" do
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
result = described_class.call([project], in_use_handles: Set["abc"], reserved_handles: Set["abc"])
expect(result.first[:error_reason]).to eq(:in_use)
end
it "prefers :too_long over :in_use when identifier is also too long" do
# "toolong" is 7 chars (> HANDLE_MAX_LENGTH=5) and alphanumeric — too_long wins
project = create(:project, identifier: "toolong", name: "Too Long Handle")
result = described_class.call([project], in_use_handles: Set["toolong"])
expect(result.first[:error_reason]).to eq(:too_long)
end
end
end
@@ -0,0 +1,209 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator do
describe ".call" do
context "when given an empty array" do
it "returns an empty array" do
expect(described_class.call([])).to be_empty
end
end
context "when a project has a too-long identifier" do
shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") }
it "returns one suggestion entry for the project" do
result = described_class.call([project])
expect(result.size).to eq(1)
expect(result.first[:project]).to eq(project)
expect(result.first[:current_identifier]).to eq("verylongidentifier")
expect(result.first[:suggested_identifier]).to be_present
expect(result.first[:suggested_identifier].length).to be <= described_class::IDENTIFIER_LENGTH[:max]
end
end
context "when a project has a special-character identifier" do
shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") }
it "returns a suggestion entry with a suggested_identifier" do
result = described_class.call([project])
expect(result.size).to eq(1)
expect(result.first[:suggested_identifier]).to eq("FS")
end
end
context "when multiple projects generate conflicting identifiers" do
shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") }
shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") }
it "generates unique identifiers for each project" do
identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier)
expect(identifiers.uniq.size).to eq(identifiers.size)
end
it "resolves conflicts by widening the acronym, not numeric suffixes" do
identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier)
expect(identifiers).to include("SC")
# Second project expands to "STC" (Stream → ST, Channel → C) instead of "SC2"
expect(identifiers).to include("STC")
end
end
end
describe "identifier generation from project name" do
{
# Single-word names: first IDENTIFIER_LENGTH[:single_word] (3) transliterated chars
"Banana" => "BAN",
"Kiwi" => "KIW",
"Strawberry" => "STR",
"Cécile" => "CEC",
# Multi-word names: initials (truncated to IDENTIFIER_LENGTH[:base] = 5)
"Flight Planning Algorithm" => "FPA",
"Fly & Sky" => "FS",
"Social media marketing" => "SMM",
"Arcanos (mobile-web-app)" => "AMWA",
"Flight Planning Training" => "FPT",
"A B C D E F G H I J K" => "ABCDE",
"Cécile Martin" => "CM",
"étude de cas" => "EDC",
# Non-Latin scripts: every initial → "?" → fallback
"日本語プロジェクト" => "PROJ",
# Mixed: only "Plan" survives transliteration → single-word → starts at 3 chars
"Plan 日本" => "PLA"
}.each do |project_name, expected_identifier|
it "generates '#{expected_identifier}' from '#{project_name}'" do
project = create(:project, identifier: "bad-id", name: project_name)
expect(described_class.call([project]).first[:suggested_identifier]).to eq(expected_identifier)
end
end
end
describe "must start with a letter" do
it "strips leading digits from generated identifiers" do
project = create(:project, identifier: "bad-id", name: "3D Printing Lab")
result = described_class.call([project]).first[:suggested_identifier]
expect(result).to match(/\A[A-Z]/)
end
it "falls back to PROJ for all-digit names" do
project = create(:project, identifier: "bad-id", name: "123 456")
result = described_class.call([project]).first[:suggested_identifier]
expect(result).to eq("PROJ")
end
end
describe "minimum identifier length" do
it "never generates identifiers shorter than MIN_IDENTIFIER_LENGTH" do
# Single letter word — too short on its own
project = create(:project, identifier: "bad-id", name: "A")
result = described_class.call([project]).first[:suggested_identifier]
expect(result.length).to be >= described_class::IDENTIFIER_LENGTH[:min]
end
end
describe "collision resolution by widening" do
it "uses the base identifier when not yet taken" do
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
expect(described_class.call([project]).first[:suggested_identifier]).to eq("SC")
end
it "widens the acronym instead of appending numeric suffixes" do
p1 = create(:project, identifier: "sc-a", name: "Stream Communicator")
p2 = create(:project, identifier: "sc-b", name: "Stream Channel")
p3 = create(:project, identifier: "sc-c", name: "Something Cool")
identifiers = described_class.call([p1, p2, p3]).pluck(:suggested_identifier)
expect(identifiers).to contain_exactly("SC", "STC", "SOC")
end
it "expands single-word identifiers on collision" do
p1 = create(:project, identifier: "bad-a", name: "Banana")
p2 = create(:project, identifier: "bad-b", name: "Banking")
identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier)
# Both start as "BAN"; second expands to "BANK"
expect(identifiers).to contain_exactly("BAN", "BANK")
end
it "keeps all identifiers within MAX_IDENTIFIER_LENGTH" do
p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J")
p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J")
identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier)
expect(identifiers.all? { it.length <= described_class::IDENTIFIER_LENGTH[:max] }).to be true
expect(identifiers.uniq.size).to eq(2)
end
it "does not suggest an identifier that is already in use (pre-seeded collision)" do
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
result = described_class.call([project], in_use_identifiers: Set["SC"])
# "SC" is taken, so it widens to "STC" (Stream → ST, Communicator → C)
expect(result.first[:suggested_identifier]).to eq("STC")
end
it "falls back to numeric suffix only when all expansion candidates are exhausted" do
# Reserve all expansion candidates for "Go" (a 2-char word)
project = create(:project, identifier: "bad-id", name: "Go")
result = described_class.call([project], in_use_identifiers: Set["GO"])
# "GO" is taken, no further expansion possible, so numeric suffix
expect(result.first[:suggested_identifier]).to eq("GO2")
end
it "assigns identifiers in array order — first project claims the base" do
p1 = create(:project, identifier: "bad-a", name: "Stream Communicator")
p2 = create(:project, identifier: "bad-b", name: "Stream Channel")
result = described_class.call([p1, p2])
# p1 is first in the array, so it claims "SC"; p2 gets the widened "STC"
expect(result[0][:suggested_identifier]).to eq("SC")
expect(result[1][:suggested_identifier]).to eq("STC")
# Reversed order: p2 now claims "SC"
reversed = described_class.call([p2, p1])
expect(reversed[0][:suggested_identifier]).to eq("SC")
expect(reversed[1][:suggested_identifier]).to eq("STC")
end
end
describe ".suggest_identifier" do
it "produces the same identifier as .call for the same name" do
project = build_stubbed(:project, name: "Alpha Beta", identifier: "alpha-beta")
batch_result = described_class.call([project]).first[:suggested_identifier]
single_result = described_class.suggest_identifier("Alpha Beta")
expect(single_result).to eq(batch_result)
end
end
describe ".call result shape" do
it "does not include error_reason (that is PreviewQuery's concern)" do
project = create(:project, identifier: "ab-c", name: "Test")
expect(described_class.call([project]).first).not_to have_key(:error_reason)
end
end
end