From 372a2e811317a497e868271405175970bccd4320 Mon Sep 17 00:00:00 2001 From: GOTOH Shunsuke Date: Sun, 2 Jul 2023 20:16:49 +0900 Subject: [PATCH] 1st commit: + porting implementation from Hashids.jl + porting tests from sqids-spec --- .github/workflows/CI.yml | 36 ++++ .github/workflows/CompatHelper.yml | 16 ++ .github/workflows/TagBot.yml | 31 +++ .gitignore | 1 + Project.toml | 13 ++ src/Blacklists.jl | 3 + src/Sqids.jl | 331 +++++++++++++++++++++++++++++ test/alphabet.jl | 46 ++++ test/blacklist.jl | 57 +++++ test/encoding.jl | 134 ++++++++++++ test/minlength.jl | 69 ++++++ test/runtests.jl | 11 + test/shuffle.jl | 76 +++++++ test/uniques.jl | 68 ++++++ 14 files changed, 892 insertions(+) create mode 100644 .github/workflows/CI.yml create mode 100644 .github/workflows/CompatHelper.yml create mode 100644 .github/workflows/TagBot.yml create mode 100644 .gitignore create mode 100644 Project.toml create mode 100644 src/Blacklists.jl create mode 100644 src/Sqids.jl create mode 100644 test/alphabet.jl create mode 100644 test/blacklist.jl create mode 100644 test/encoding.jl create mode 100644 test/minlength.jl create mode 100644 test/runtests.jl create mode 100644 test/shuffle.jl create mode 100644 test/uniques.jl diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..c4c1aaa --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,36 @@ +name: CI +on: + push: + branches: + - main + tags: ['*'] + pull_request: +concurrency: + # Skip intermediate builds: always. + # Cancel intermediate builds: only if it is a pull request build. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + version: + - '1.6' + - '1.9' + - 'nightly' + os: + - ubuntu-latest + arch: + - x64 + steps: + - uses: actions/checkout@v3 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v1 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml new file mode 100644 index 0000000..cba9134 --- /dev/null +++ b/.github/workflows/CompatHelper.yml @@ -0,0 +1,16 @@ +name: CompatHelper +on: + schedule: + - cron: 0 0 * * * + workflow_dispatch: +jobs: + CompatHelper: + runs-on: ubuntu-latest + steps: + - name: Pkg.add("CompatHelper") + run: julia -e 'using Pkg; Pkg.add("CompatHelper")' + - name: CompatHelper.main() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} + run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml new file mode 100644 index 0000000..2bacdb8 --- /dev/null +++ b/.github/workflows/TagBot.yml @@ -0,0 +1,31 @@ +name: TagBot +on: + issue_comment: + types: + - created + workflow_dispatch: + inputs: + lookback: + default: 3 +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read +jobs: + TagBot: + if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' + runs-on: ubuntu-latest + steps: + - uses: JuliaRegistries/TagBot@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ssh: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b067edd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/Manifest.toml diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..9fa0bcb --- /dev/null +++ b/Project.toml @@ -0,0 +1,13 @@ +name = "Sqids" +uuid = "5846b9ac-096c-425b-b363-8d1a03210e20" +authors = ["GOTOH Shunsuke and contributors"] +version = "0.1.0-DEV" + +[compat] +julia = "1.6" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/src/Blacklists.jl b/src/Blacklists.jl new file mode 100644 index 0000000..be654ae --- /dev/null +++ b/src/Blacklists.jl @@ -0,0 +1,3 @@ +module Blacklists +const blacklist=["0rgasm","1d10t","1d1ot","1di0t","1diot","1eccacu10","1eccacu1o","1eccacul0","1eccaculo","1mbec11e","1mbec1le","1mbeci1e","1mbecile","a11upat0","a11upato","a1lupat0","a1lupato","aand","ah01e","ah0le","aho1e","ahole","al1upat0","al1upato","allupat0","allupato","ana1","ana1e","anal","anale","anus","arrapat0","arrapato","arsch","arse","ass","b00b","b00be","b01ata","b0ceta","b0iata","b0ob","b0obe","b0sta","b1tch","b1te","b1tte","ba1atkar","balatkar","bastard0","bastardo","batt0na","battona","bitch","bite","bitte","bo0b","bo0be","bo1ata","boceta","boiata","boob","boobe","bosta","bran1age","bran1er","bran1ette","bran1eur","bran1euse","branlage","branler","branlette","branleur","branleuse","c0ck","c0g110ne","c0g11one","c0g1i0ne","c0g1ione","c0gl10ne","c0gl1one","c0gli0ne","c0glione","c0na","c0nnard","c0nnasse","c0nne","c0u111es","c0u11les","c0u1l1es","c0u1lles","c0ui11es","c0ui1les","c0uil1es","c0uilles","c11t","c11t0","c11to","c1it","c1it0","c1ito","cabr0n","cabra0","cabrao","cabron","caca","cacca","cacete","cagante","cagar","cagare","cagna","cara1h0","cara1ho","caracu10","caracu1o","caracul0","caraculo","caralh0","caralho","cazz0","cazz1mma","cazzata","cazzimma","cazzo","ch00t1a","ch00t1ya","ch00tia","ch00tiya","ch0d","ch0ot1a","ch0ot1ya","ch0otia","ch0otiya","ch1asse","ch1avata","ch1er","ch1ng0","ch1ngadaz0s","ch1ngadazos","ch1ngader1ta","ch1ngaderita","ch1ngar","ch1ngo","ch1ngues","ch1nk","chatte","chiasse","chiavata","chier","ching0","chingadaz0s","chingadazos","chingader1ta","chingaderita","chingar","chingo","chingues","chink","cho0t1a","cho0t1ya","cho0tia","cho0tiya","chod","choot1a","choot1ya","chootia","chootiya","cl1t","cl1t0","cl1to","clit","clit0","clito","cock","cog110ne","cog11one","cog1i0ne","cog1ione","cogl10ne","cogl1one","cogli0ne","coglione","cona","connard","connasse","conne","cou111es","cou11les","cou1l1es","cou1lles","coui11es","coui1les","couil1es","couilles","cracker","crap","cu10","cu1att0ne","cu1attone","cu1er0","cu1ero","cu1o","cul0","culatt0ne","culattone","culer0","culero","culo","cum","cunt","d11d0","d11do","d1ck","d1ld0","d1ldo","damn","de1ch","deich","depp","di1d0","di1do","dick","dild0","dildo","dyke","encu1e","encule","enema","enf01re","enf0ire","enfo1re","enfoire","estup1d0","estup1do","estupid0","estupido","etr0n","etron","f0da","f0der","f0ttere","f0tters1","f0ttersi","f0tze","f0utre","f1ca","f1cker","f1ga","fag","fica","ficker","figa","foda","foder","fottere","fotters1","fottersi","fotze","foutre","fr0c10","fr0c1o","fr0ci0","fr0cio","fr0sc10","fr0sc1o","fr0sci0","fr0scio","froc10","froc1o","froci0","frocio","frosc10","frosc1o","frosci0","froscio","fuck","g00","g0o","g0u1ne","g0uine","gandu","go0","goo","gou1ne","gouine","gr0gnasse","grognasse","haram1","harami","haramzade","hund1n","hundin","id10t","id1ot","idi0t","idiot","imbec11e","imbec1le","imbeci1e","imbecile","j1zz","jerk","jizz","k1ke","kam1ne","kamine","kike","leccacu10","leccacu1o","leccacul0","leccaculo","m1erda","m1gn0tta","m1gnotta","m1nch1a","m1nchia","m1st","mam0n","mamahuev0","mamahuevo","mamon","masturbat10n","masturbat1on","masturbate","masturbati0n","masturbation","merd0s0","merd0so","merda","merde","merdos0","merdoso","mierda","mign0tta","mignotta","minch1a","minchia","mist","musch1","muschi","n1gger","neger","negr0","negre","negro","nerch1a","nerchia","nigger","orgasm","p00p","p011a","p01la","p0l1a","p0lla","p0mp1n0","p0mp1no","p0mpin0","p0mpino","p0op","p0rca","p0rn","p0rra","p0uff1asse","p0uffiasse","p1p1","p1pi","p1r1a","p1rla","p1sc10","p1sc1o","p1sci0","p1scio","p1sser","pa11e","pa1le","pal1e","palle","pane1e1r0","pane1e1ro","pane1eir0","pane1eiro","panele1r0","panele1ro","paneleir0","paneleiro","patakha","pec0r1na","pec0rina","pecor1na","pecorina","pen1s","pendej0","pendejo","penis","pip1","pipi","pir1a","pirla","pisc10","pisc1o","pisci0","piscio","pisser","po0p","po11a","po1la","pol1a","polla","pomp1n0","pomp1no","pompin0","pompino","poop","porca","porn","porra","pouff1asse","pouffiasse","pr1ck","prick","pussy","put1za","puta","puta1n","putain","pute","putiza","puttana","queca","r0mp1ba11e","r0mp1ba1le","r0mp1bal1e","r0mp1balle","r0mpiba11e","r0mpiba1le","r0mpibal1e","r0mpiballe","rand1","randi","rape","recch10ne","recch1one","recchi0ne","recchione","retard","romp1ba11e","romp1ba1le","romp1bal1e","romp1balle","rompiba11e","rompiba1le","rompibal1e","rompiballe","ruff1an0","ruff1ano","ruffian0","ruffiano","s1ut","sa10pe","sa1aud","sa1ope","sacanagem","sal0pe","salaud","salope","saugnapf","sb0rr0ne","sb0rra","sb0rrone","sbattere","sbatters1","sbattersi","sborr0ne","sborra","sborrone","sc0pare","sc0pata","sch1ampe","sche1se","sche1sse","scheise","scheisse","schlampe","schwachs1nn1g","schwachs1nnig","schwachsinn1g","schwachsinnig","schwanz","scopare","scopata","sexy","sh1t","shit","slut","sp0mp1nare","sp0mpinare","spomp1nare","spompinare","str0nz0","str0nza","str0nzo","stronz0","stronza","stronzo","stup1d","stupid","succh1am1","succh1ami","succhiam1","succhiami","sucker","t0pa","tapette","test1c1e","test1cle","testic1e","testicle","tette","topa","tr01a","tr0ia","tr0mbare","tr1ng1er","tr1ngler","tring1er","tringler","tro1a","troia","trombare","turd","twat","vaffancu10","vaffancu1o","vaffancul0","vaffanculo","vag1na","vagina","verdammt","verga","w1chsen","wank","wichsen","x0ch0ta","x0chota","xana","xoch0ta","xochota","z0cc01a","z0cc0la","z0cco1a","z0ccola","z1z1","z1zi","ziz1","zizi","zocc01a","zocc0la","zocco1a","zoccola"] +end \ No newline at end of file diff --git a/src/Sqids.jl b/src/Sqids.jl new file mode 100644 index 0000000..1e4d9ec --- /dev/null +++ b/src/Sqids.jl @@ -0,0 +1,331 @@ +module Sqids + +export encode, decode, minValue, maxValue + +using Base.Checked: mul_with_overflow, add_with_overflow + +include("Blacklists.jl") + +const DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +const MIN_VALUE = 0 + +_shuffle(alphabet::AbstractString) = String(_shuffle!(collect(alphabet))) +function _shuffle!(chars::Vector{Char}) + L = length(chars) + for i = 0:L-2 + j = L - i - 1 + r = (i * j + Int(chars[i + 1]) + Int(chars[j + 1])) % L + chars[i + 1], chars[r + 1] = chars[r + 1], chars[i + 1] + end + chars +end + +""" + Sqids.Configuration + +Sqids' parameter-configuration. +Be sure to place the instance as the 1st argument of [`encode`](@ref), [`decode`](@ref), [`minValue`](@ref) (and [`maxValue`](@ref)). + +See also: [`configure`](@ref) +""" +struct Configuration{S} + alphabet::String + minLength::Int + blacklist::Set{String} + function Configuration(alphabet::AbstractString, minLength::Int, blacklist, strict::Bool = true) + # @assert blacklist isa Union{AbstractSet{<:AbstractString}, AbstractArray{<:AbstractString}} + length(alphabet) < 5 && throw(ArgumentError("Alphabet length must be at least 5.")) + length(unique(alphabet)) == length(alphabet) || throw(ArgumentError("Alphabet must contain unique characters.")) + MIN_VALUE ≤ minLength ≤ length(alphabet) || throw(ArgumentError("Minimum length has to be between $(MIN_VALUE) and $(length(alphabet)).")) + + # clean up blacklist: + # 1. all blacklist words should be lowercase + # 2. no words less than 3 chars + # 3. if some words contain chars that are not in the alphabet, remove those + filteredBlacklist = Set(filter(blacklist) do word + length(word) ≥ 3 && issetequal(word ∩ alphabet, word) + end .|> lowercase) + new{strict}(_shuffle(alphabet), minLength, filteredBlacklist) + end +end +Configuration(; alphabet::AbstractString = DEFAULT_ALPHABET, minLength::Int = 0, blacklist = Blacklists.blacklist, strict::Bool = true) = + Configuration(alphabet, minLength, blacklist, strict) + +""" + Sqids.configure() + Sqids.configure(alphabet=DEFAULT_ALPHABET, minLength=0, blacklist=Blacklists.blacklist, strict=false) + +Configure Sqids with parameters, and return [`Sqids.Configuration`](@ref) instance. +`Sqids.configure()` returns default-configuration. + +# Example +```julia-repl +julia> config = Sqids.configure(); + +julia> config = Sqids.configure(alphabet="abcdefghijklmnopqrstuvwxyz", minLength=16, blacklist=["foo", "bar"]); + +``` + +See also: [`Configuration`](@ref) +""" +configure(; kwargs...) = Configuration(; kwargs...) + +isstrict(::Configuration{S}) where {S} = (S::Bool) + +function _checked_muladd(x::T, y::Integer, z::Integer) where {T<:Integer} + _checked_muladd(promote(x, y, z)...)::Union{T, Nothing} +end +function _checked_muladd(x::T, y::T, z::T) where {T<:Integer} + xy, overflow = mul_with_overflow(x, y) + overflow && return nothing + result, overflow = add_with_overflow(xy, z) + overflow && return nothing + result +end +_checked_muladd(x::BigInt, y::Integer, z::Integer) = muladd(x, y, z) + +""" + encode(config::Sqids.Configuration, numbers::Array{<:Integer}) + +Encode the passed `numbers` to an id. + +# Example +```julia-repl +julia> encode(Sqids.configure(), [1, 2, 3]) +"8QRLaD" + +``` +""" +function encode(config::Configuration, numbers::AbstractArray{<:Integer}) + isempty(numbers) && return "" + # don't allow out-of-range numbers [might be lang-specific] + all(≥(minValue(config)), numbers) || throw(ArgumentError("Encoding supports numbers greater than or equal to $(minValue(config))")) + _encode_numbers(config, numbers, false) +end +function encode(config::Configuration{true}, numbers::AbstractArray{<:Integer}) + isempty(numbers) && return "" + # don't allow out-of-range numbers [might be lang-specific] + all(numbers) do num + minValue(config) ≤ num ≤ maxValue(config) + end || throw(ArgumentError("Encoding supports numbers between $(minValue(config)) and $(maxValue(config))")) + _encode_numbers(config, numbers, false) +end +function _encode_numbers(config::Configuration, numbers::AbstractArray{<:Integer}, partitioned::Bool = false) + # get a semi-random offset from input numbers + offset = foldl((a, (i, v)) -> a + Int(config.alphabet[v % length(config.alphabet) + 1]) + i - 1, pairs(numbers), init=length(numbers)) % length(config.alphabet) + + # prefix is the first character in the generated ID, used for randomization + # partition is the character used instead of the first separator to indicate that the first number in the input array is a throwaway number. this character is used only once to handle blacklist and/or padding. it's omitted completely in all other cases + # alphabet should not contain `prefix` or `partition` reserved characters + alphabet_chars = collect(config.alphabet)[[offset+1:end; begin:offset]] + prefix = popfirst!(alphabet_chars) + partition = popfirst!(alphabet_chars) + + id = sprint(sizehint=2*length(numbers)) do io + print(io, prefix) + # encode input array + for (i, num) in pairs(numbers) + # the last character of the alphabet is going to be reserved for the `separator` + alphabetWithoutSeparator = @view alphabet_chars[begin:end-1] + print(io, _to_id(num, alphabetWithoutSeparator)) + if i < length(numbers) + # prefix is used only for the first number + # separator = alphabet[end] + # for the barrier use the `separator` unless this is the first iteration and the first number is a throwaway number - then use the `partition` character + print(io, partitioned && i == 1 ? partition : alphabet_chars[end]) + + # shuffle on every iteration + alphabet_chars = _shuffle!(alphabet_chars) + end + end + end + + # if `minLength` is used and the ID is too short, add a throwaway number + if config.minLength > length(id) + # partitioning is required so we can safely throw away chunk of the ID during decoding + if !partitioned + numbers = [zero(eltype(numbers)); numbers] + id = _encode_numbers(config, numbers, true) + end + + # if adding a `partition` number did not make the length meet the `minLength` requirement, then make the new id this format: `prefix` character + a slice of the alphabet to make up the missing length + the rest of the ID without the `prefix` character + if config.minLength > length(id) + id = id[begin] * join(alphabet_chars[begin:config.minLength - length(id)]) * id[2:end] + end + end + + # if ID has a blocked word anywhere, add a throwaway number & start over + if _is_blocked_id(config, id) + if partitioned + # c8 ignore next 2 + if isstrict(config) && numbers[1] == maxValue(config) + throw(ArgumentError("Ran out of range checking against the blacklist")) + else + numbers[1] += 1 + end + else + numbers = [zero(eltype(numbers)); numbers] + end + + id = _encode_numbers(config, numbers, true) + end + + return id +end + +_to_id(num:: Integer, alphabet::AbstractString) = _to_id(num, collect(alphabet)) +function _to_id(num:: Integer, chars::AbstractVector{Char}) + L = length(chars) + id = Char[] + result = num + while true + pushfirst!(id, chars[result % L + 1]) + result = result ÷ L + result == 0 && break + end + # id = @view chars[reverse(digits(num, base=L)) .+ 1] + # id = chars[reverse(digits(num, base=L)) .+ 1] + return String(id) +end + +function _is_blocked_id(config::Configuration, id::AbstractString) + id = lowercase(id) + any(config.blacklist) do word + # no point in checking words that are longer than the ID + length(word) <= length(id) || return false + # short words have to match completely; otherwise, too many matches + if length(id) ≤ 3 && length(word) ≤ 3 + id == word + elseif occursin(r"\d", word) + # words with leet speak replacements are visible mostly on the ends of the ID + startswith(id, word) || endswith(id, word) + else + # otherwise, check for blacklisted word anywhere in the string + contains(id, word) + end + end +end + +""" + decode(config::Sqids.Configuration, id::AbstractString) + +Restore a numbers list from the passed `id`. + +# Example +```julia-repl +julia> decode(Sqids.configure(), "8QRLaD") +3-element Array{Int64,1}: + 1 + 2 + 3 + +``` +""" +function decode(config::Configuration, id::AbstractString) + isempty(id) && return Int[] + + # if a character is not in the alphabet, return an empty array + id ⊆ config.alphabet || return Int[] + + # ret = Vector{Integer}() + ret = Int[] + sizehint!(ret, length(id)) + T = Int + + # first character is always the `prefix` + prefix = id[begin] + + # `offset` is the semi-random position that was generated during encoding + offset = findfirst(==(prefix), config.alphabet) + + # re-arrange alphabet back into it's original form + # `partition` character is in second position + # alphabet has to be without reserved `prefix` & `partition` characters + alphabet_chars = collect(config.alphabet)[[offset+1:end; begin:offset-1]] + partition = popfirst!(alphabet_chars) + + # now it's safe to remove the prefix character from ID, it's not needed anymore + id_wk = @view id[begin+1:end] + + # if this ID contains the `partition` character (between 1st position and non-last position), throw away everything to the left of it, include the `partition` character + partition_index = findfirst(==(partition), id_wk) + if !isnothing(partition_index) && partition_index > 1 && partition_index < length(id_wk) + id_wk = @view id_wk[partition_index+1:end] + alphabet_chars = _shuffle!(alphabet_chars) + end + + # decode + while !isempty(id_wk) + separator = alphabet_chars[end] + chunks = split(id_wk, separator, limit=2) + # decode the number without using the `separator` character + alphabetWithoutSeparator = @view alphabet_chars[begin:end-1] + # push!(ret, _to_number(config, chunks[1], alphabetWithoutSeparator)) + num = _to_number(config, chunks[1], alphabetWithoutSeparator) + if !isstrict(config) + T = promote_type(T, typeof(num)) + if T !== eltype(ret) + ret = T.(ret) + sizehint!(ret, length(id)) + end + end + push!(ret, num) + # if this ID has multiple numbers, shuffle the alphabet because that's what encoding function did + length(chunks) < 2 && break + alphabet_chars = _shuffle!(alphabet_chars) + id_wk = chunks[2] + end + + return ret +end + +_to_number(config::Configuration, id::AbstractString, alphabet::AbstractString) = _to_number(config, id, collect(alphabet)) +# function _to_number(config::Configuration, id::AbstractString, chars::AbstractVector{Char}) +# L = length(chars) +# foldl(collect(id), init=0) do a, v +# a * L + findfirst(==(v), chars) - 1 +# end +# result +# end +_to_number(config::Configuration, id::AbstractString, chars::AbstractVector{Char}) = _to_number(config, id, 0, Dict(c=>i for (i, c) in pairs(chars))) +function _to_number(::Configuration{true}, id::AbstractString, init::Int, alphabet_dic::Dict{Char, Int}) + L = length(alphabet_dic) + foldl(id, init=init) do a, c + _number = _checked_muladd(a, L, alphabet_dic[c] - 1) + isnothing(_number) && throw(ArgumentError("Ran out of range decoding id($(id))")) + _number::Int + end +end +function _to_number(::Configuration, id::AbstractString, init::I, alphabet_dic::Dict{Char, Int}) where {I <: Integer} + L = length(alphabet_dic) + result::I = init + for (i, c) in pairs(id) + # result = result * L + alphabet_dic[c] - 1 + _number = _checked_muladd(result, L, alphabet_dic[c] - 1) + isnothing(_number) && return _to_number(widen(result), id[i:end], alphabet_dic) + result = _number + end + result +end + +""" + minValue(config::Sqids.Configuration) + +Return the minimum value available with Sqids. +Always returns `0`. + +See also: [`maxValue`](@ref) +""" +minValue(::Configuration) = MIN_VALUE + +""" + maxValue(config::Sqids.Configuration) + +Return the maximum value available with Sqids. +Returns `typemax(Int)` if Strict mode, or throws an `MethodError` otherwise. + +See also: [`minValue`](@ref) +""" +maxValue(::Configuration{true}) = typemax(Int) + +end # module Sqids \ No newline at end of file diff --git a/test/alphabet.jl b/test/alphabet.jl new file mode 100644 index 0000000..8ffd79c --- /dev/null +++ b/test/alphabet.jl @@ -0,0 +1,46 @@ +module AlphabetTests + +using Sqids +using Test + +@testset "Alphabet" begin + + @testset "simple" begin + config = Sqids.configure(alphabet="0123456789abcdef") + + numbers = [1, 2, 3] + id = "4d9fd2" + + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + + @testset "short alphabet" begin + config = Sqids.configure(alphabet="abcde") + + numbers = [1, 2, 3] + @test Sqids.decode(config, Sqids.encode(config, numbers)) == numbers + end + + @testset "long alphabet" begin + config = Sqids.configure(alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#\$%^&*()-_+|{}[];:\'\"/?.>,<`~") + + numbers = [1, 2, 3] + @test Sqids.decode(config, Sqids.encode(config, numbers)) == numbers + end + + @testset "repeating alphabet characters" begin + @test_throws ArgumentError begin + Sqids.configure(alphabet="aabcdefg") + end + end + + @testset "too short of an alphabet" begin + @test_throws ArgumentError begin + Sqids.configure(alphabet="abcd") + end + end + +end + +end # module AlphabetTests \ No newline at end of file diff --git a/test/blacklist.jl b/test/blacklist.jl new file mode 100644 index 0000000..af179ca --- /dev/null +++ b/test/blacklist.jl @@ -0,0 +1,57 @@ +module BlacklistTests + +using Sqids +using Test + +@testset "blacklist" begin + + @testset "if no custom blacklist param, use the default blacklist" begin + config = Sqids.configure() + @test Sqids.decode(config, "sexy") == [200044] + @test Sqids.encode(config, [200044]) == "d171vI" + end + + @testset "if an empty blacklist param passed, don't use any blacklist" begin + config = Sqids.configure(blacklist=[]) + @test Sqids.decode(config, "sexy") == [200044] + @test Sqids.encode(config, [200044]) == "sexy" + end + + @testset "if a non-empty blacklist param passed, use only that" begin + config = Sqids.configure(blacklist=["AvTg"]) + @test Sqids.decode(config, "sexy") == [200044] + @test Sqids.encode(config, [200044]) == "sexy" + @test Sqids.decode(config, "AvTg") == [100000] + @test Sqids.encode(config, [100000]) == "7T1X8k" + @test Sqids.decode(config, "7T1X8k") == [100000] + end + + @testset "blacklist" begin + config = Sqids.configure(blacklist=[ + "8QRLaD", # normal result of 1st encoding, let's block that word on purpose + "7T1cd0dL", # result of 2nd encoding + "UeIe", # result of 3rd encoding is `RA8UeIe7`, let's block a substring + "imhw", # result of 4th encoding is `WM3Limhw`, let's block the postfix + "LfUQ", # result of 4th encoding is `LfUQh4HN`, let's block the prefix + ]) + @test Sqids.encode(config, [1, 2, 3]) == "TM0x1Mxz" + @test Sqids.decode(config, "TM0x1Mxz") == [1, 2, 3] + end + + @testset "decoding blacklisted words should still work" begin + config = Sqids.configure(blacklist=["8QRLaD", "7T1cd0dL", "RA8UeIe7", "WM3Limhw", "LfUQh4HN"]) + @test Sqids.decode(config, "8QRLaD") == [1, 2, 3] + @test Sqids.decode(config, "7T1cd0dL") == [1, 2, 3] + @test Sqids.decode(config, "RA8UeIe7") == [1, 2, 3] + @test Sqids.decode(config, "WM3Limhw") == [1, 2, 3] + @test Sqids.decode(config, "LfUQh4HN") == [1, 2, 3] + end + + @testset "match against a short blacklisted word" begin + config = Sqids.configure(blacklist=["pPQ"]) + @test Sqids.decode(config, Sqids.encode(config, [1000])) == [1000] + end + +end + +end # module BlacklistTests \ No newline at end of file diff --git a/test/encoding.jl b/test/encoding.jl new file mode 100644 index 0000000..c5f3986 --- /dev/null +++ b/test/encoding.jl @@ -0,0 +1,134 @@ +module EncodingTests + +using Sqids +using Test + +@testset "Encoding" begin + + @testset "simple" begin + config = Sqids.configure() + + numbers = [1, 2, 3] + id = "8QRLaD" + + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + + @testset "different inputs" begin + config = Sqids.configure() + + # numbers = [0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, Sqids.maxValue()] + numbers = [0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, typemax(Int)] + @test Sqids.decode(config, Sqids.encode(config, numbers)) == numbers + end + + @testset "incremental numbers" begin + config = Sqids.configure() + + ids = Dict( + "bV" => [0], + "U9" => [1], + "g8" => [2], + "Ez" => [3], + "V8" => [4], + "ul" => [5], + "O3" => [6], + "AF" => [7], + "ph" => [8], + "n8" => [9] + ) + + for (id, numbers) in ids + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + end + + @testset "incremental numbers, same index 0" begin + config = Sqids.configure() + + ids = Dict( + "SrIu" => [0, 0], + "nZqE" => [0, 1], + "tJyf" => [0, 2], + "e86S" => [0, 3], + "rtC7" => [0, 4], + "sQ8R" => [0, 5], + "uz2n" => [0, 6], + "7Td9" => [0, 7], + "3nWE" => [0, 8], + "mIxM" => [0, 9] + ) + + for (id, numbers) in ids + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + end + + @testset "incremental numbers, same index 1" begin + config = Sqids.configure() + + ids = Dict( + "SrIu" => [0, 0], + "nbqh" => [1, 0], + "t4yj" => [2, 0], + "eQ6L" => [3, 0], + "r4Cc" => [4, 0], + "sL82" => [5, 0], + "uo2f" => [6, 0], + "7Zdq" => [7, 0], + "36Wf" => [8, 0], + "m4xT" => [9, 0] + ) + + for (id, numbers) in ids + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + end + + @testset "multi input" begin + config = Sqids.configure() + + numbers = collect(0:99) # == [0, 1, … , 99] + output = Sqids.decode(config, Sqids.encode(config, numbers)) + @test numbers == output + end + + @testset "multi input 2 (input range object)" begin + config = Sqids.configure() + + numbers = 0:99 # ≒ [0, 1, … , 99] + output = Sqids.decode(config, Sqids.encode(config, numbers)) + @test numbers == output + end + + @testset "encoding no numbers" begin + config = Sqids.configure() + + @test Sqids.encode(config, Int[]) == "" + end + + @testset "decoding empty string" begin + config = Sqids.configure() + + @test Sqids.decode(config, "") == Int[] + end + + @testset "decoding an ID with an invalid character" begin + config = Sqids.configure() + + @test Sqids.decode(config, "*") == Int[] + end + + @testset "encode out-of-range numbers" begin + config = Sqids.configure() + + @test_throws ArgumentError Sqids.encode(config, [Sqids.minValue(config) - 1]) + # @test_throws ArgumentError Sqids.encode(config, [Sqids.maxValue(config) + 1]) + end +end + +end # module EncodingTests \ No newline at end of file diff --git a/test/minlength.jl b/test/minlength.jl new file mode 100644 index 0000000..57fdba1 --- /dev/null +++ b/test/minlength.jl @@ -0,0 +1,69 @@ +module MinLengthTests + +using Sqids +using Test + +@testset "minLength" begin + + @testset "simple" begin + config = Sqids.configure(minLength=length(Sqids.DEFAULT_ALPHABET)) + + numbers = [1, 2, 3] + id = "75JILToVsGerOADWmHlY38xvbaNZKQ9wdFS0B6kcMEtnRpgizhjU42qT1cd0dL" + + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + + @testset "incremental numbers" begin + config = Sqids.configure(minLength=length(Sqids.DEFAULT_ALPHABET)) + + ids = Dict( + "jf26PLNeO5WbJDUV7FmMtlGXps3CoqkHnZ8cYd19yIiTAQuvKSExzhrRghBlwf" => [0, 0], + "vQLUq7zWXC6k9cNOtgJ2ZK8rbxuipBFAS10yTdYeRa3ojHwGnmMV4PDhESI2jL" => [0, 1], + "YhcpVK3COXbifmnZoLuxWgBQwtjsSaDGAdr0ReTHM16yI9vU8JNzlFq5Eu2oPp" => [0, 2], + "OTkn9daFgDZX6LbmfxI83RSKetJu0APihlsrYoz5pvQw7GyWHEUcN2jBqd4kJ9" => [0, 3], + "h2cV5eLNYj1x4ToZpfM90UlgHBOKikQFvnW36AC8zrmuJ7XdRytIGPawqYEbBe" => [0, 4], + "7Mf0HeUNkpsZOTvmcj836P9EWKaACBubInFJtwXR2DSzgYGhQV5i4lLxoT1qdU" => [0, 5], + "APVSD1ZIY4WGBK75xktMfTev8qsCJw6oyH2j3OnLcXRlhziUmpbuNEar05QCsI" => [0, 6], + "P0LUhnlT76rsWSofOeyRGQZv1cC5qu3dtaJYNEXwk8Vpx92bKiHIz4MgmiDOF7" => [0, 7], + "xAhypZMXYIGCL4uW0te6lsFHaPc3SiD1TBgw5O7bvodzjqUn89JQRfk2Nvm4JI" => [0, 8], + "94dRPIZ6irlXWvTbKywFuAhBoECQOVMjDJp53s2xeqaSzHY8nc17tmkLGwfGNl" => [0, 9], + ) + + for (id, numbers) in ids + @test Sqids.encode(config, numbers) == id + @test Sqids.decode(config, id) == numbers + end + end + + @testset "min lengths" begin + _config = Sqids.configure() + for minLength in [0, 1, 5, 10, length(Sqids.DEFAULT_ALPHABET)] + for numbers in [ + [Sqids.minValue(_config)], + [0, 0, 0, 0, 0], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + [100, 200, 300], + [1_000, 2_000, 3_000], + [1_000_000], + # [Sqids.maxValue()], + [typemax(Int)], + ] + config = Sqids.configure(minLength=minLength) + + id = Sqids.encode(config, numbers) + @test length(id) >= minLength + @test Sqids.decode(config, id) == numbers + end + end + end + + @testset "out-of-range invalid min length" begin + @test_throws ArgumentError Sqids.configure(minLength=-1) + @test_throws ArgumentError Sqids.configure(minLength=length(Sqids.DEFAULT_ALPHABET) + 1) + end + +end + +end # module MinLengthTests \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..76fac8d --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,11 @@ +using Sqids +using Test + +@testset "Sqids.jl" begin + include("encoding.jl") + include("alphabet.jl") + include("minlength.jl") + include("blacklist.jl") + include("shuffle.jl") + include("uniques.jl") +end diff --git a/test/shuffle.jl b/test/shuffle.jl new file mode 100644 index 0000000..9cb3965 --- /dev/null +++ b/test/shuffle.jl @@ -0,0 +1,76 @@ +module ShuffleTests + +using Sqids +using Test + +@testset "Shuffle" begin + + @testset "default shuffle, checking for randomness" begin + @test Sqids._shuffle(Sqids.DEFAULT_ALPHABET) == "fwjBhEY2uczNPDiloxmvISCrytaJO4d71T0W3qnMZbXVHg6eR8sAQ5KkpLUGF9" + + # In Julia, for performance reasons, we provide a `_shuffle!()` function that destructively manipulates arrays of `Char` type. + chars = collect(Sqids.DEFAULT_ALPHABET) + @assert chars isa AbstractVector{Char} + Sqids._shuffle!(chars) + @test chars == collect("fwjBhEY2uczNPDiloxmvISCrytaJO4d71T0W3qnMZbXVHg6eR8sAQ5KkpLUGF9") + end + + @testset "numbers in the front, another check for randomness" begin + i = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + o = "ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf" + @test Sqids._shuffle(i) == o + end + + @testset "swapping front 2 characters" begin + i1 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + i2 = "1023456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + o1 = "ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf" + o2 = "xI3RUayk1MSolQK7e09zYmFpVXPwHiNrdfBJ6ZAT5uCWbntgcDsEqjv4hLG28O" + + @test Sqids._shuffle(i1) == o1 + @test Sqids._shuffle(i2) == o2 + end + + @testset "swapping last 2 characters" begin + i1 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + i2 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXZY" + + o1 = "ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf" + o2 = "x038UaykZMSolIK7RzcbYmFpgXEPHiNr1d2VfGAT5uJWQetjvDswqn94hLC6BO" + + @test Sqids._shuffle(i1) == o1 + @test Sqids._shuffle(i2) == o2 + end + + @testset "short alphabet" begin + @test Sqids._shuffle("0123456789") == "4086517392" + end + + @testset "really short alphabet" begin + @test Sqids._shuffle("12345") == "24135" + end + + @testset "lowercase alphabet" begin + i = "abcdefghijklmnopqrstuvwxyz" + o = "lbfziqvscptmyxrekguohwjand" + @test Sqids._shuffle(i) == o + end + + @testset "uppercase alphabet" begin + i = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + o = "ZXBNSIJQEDMCTKOHVWFYUPLRGA" + @test Sqids._shuffle(i) == o + end + + @testset "bars" begin + @test Sqids._shuffle("▁▂▃▄▅▆▇█") == "▂▇▄▅▆▃▁█" + end + + @testset "bars with numbers" begin + @test Sqids._shuffle("▁▂▃▄▅▆▇█0123456789") == "14▅▂▇320▆75▄█96▃8▁" + end + +end + +end # module ShuffleTests \ No newline at end of file diff --git a/test/uniques.jl b/test/uniques.jl new file mode 100644 index 0000000..c5bcf41 --- /dev/null +++ b/test/uniques.jl @@ -0,0 +1,68 @@ +module UniquesTests + +using Sqids +using Test + +const upper = 1_000_000 + +@testset "uniques" begin + + @testset "uniques, with padding" begin + config = Sqids.configure(minLength=length(Sqids.DEFAULT_ALPHABET)) + set = Set{String}() + + for i = 0:upper-1 + numbers = [i] + id = Sqids.encode(config, numbers) + push!(set, id) + @test Sqids.decode(config, id) == numbers + end + + @test length(set) == upper + end + + @testset "uniques, low ranges" begin + config = Sqids.configure() + set = Set{String}() + + for i = 0:upper-1 + numbers = [i] + id = Sqids.encode(config, numbers) + push!(set, id) + @test Sqids.decode(config, id) == numbers + end + + @test length(set) == upper + end + + @testset "uniques, high ranges" begin + config = Sqids.configure() + set = Set{String}() + + for i = 100_000_000:100_000_000+upper-1 + numbers = [i] + id = Sqids.encode(config, numbers) + push!(set, id) + @test Sqids.decode(config, id) == numbers + end + + @test length(set) == upper + end + + @testset "uniques, multi" begin + config = Sqids.configure() + set = Set{String}() + + for i = 0:upper-1 + numbers = [i, i, i, i, i] + id = Sqids.encode(config, numbers) + push!(set, id) + @test Sqids.decode(config, id) == numbers + end + + @test length(set) == upper + end + +end + +end # module UniquesTests \ No newline at end of file