From c1ce3bc7bd4361bc476821b42d8a12c2a2b5ffa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Lazo?= Date: Wed, 4 Nov 2026 17:33:51 -0300 Subject: [PATCH 1/4] Platform upgrade and tests --- .DS_Store | Bin 0 -> 6148 bytes .devcontainer/devcontainer.json | 16 +++ .dockerignore | 3 + .ruby-version | 2 +- .vscode/extensions.json | 5 + CHANGELOG.md | 11 +++ CLAUDE.md | 53 ++++++++++ Dockerfile | 27 +++++ Gemfile | 30 +++++- Gemfile.lock | 168 +++++++++++++++++++++++++++++--- README.md | 32 ++++++ Rakefile | 9 ++ VERSION | 2 +- compose.yaml | 18 ++++ config.ru | 3 + speller.rb | 71 ++++++++++---- test/speller_app_test.rb | 86 ++++++++++++++++ test/speller_test.rb | 146 +++++++++++++++++++++++++++ test/test_helper.rb | 6 ++ 19 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 .DS_Store create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .vscode/extensions.json create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 Rakefile create mode 100644 compose.yaml create mode 100644 config.ru create mode 100644 test/speller_app_test.rb create mode 100644 test/speller_test.rb create mode 100644 test/test_helper.rb diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2ff644c3c50f6b641ffa8623082459ab00341e9f GIT binary patch literal 6148 zcmeHKK~BR!3>-rxin#R1aX$bOKUh`e1$+Rt;T9=~s7Ty$QUb^vj>-3q*N&zV#1*Cu!kOJowV9$12-XtnY0VyB_eiZQUL!mp? zhErmEIv8REATF2=<8#as#Nr8JZ8#+|L$jn3lWJ|mu%t6zT3u~8B_ 'utf-8'`, so input and suggestions are UTF-8 whatever the locale or `ASPELL_CONF` say. The option must stay ahead of the language: ffi-aspell applies options in hash order, and with no locale the default encoding is `none`, which raises `unknown encoding name - none`. Don't bring back the old `force_encoding('ISO-8859-1')` workaround (hotfixes 17.0.2/17.0.3). It garbled accented suggestions (`corazón`) or flagged correct accented words, depending on the environment. The accented-text tests in `test/speller_test.rb` cover both cases. + +## Releases + +Versions use `YY.minor.patch` (e.g. `17.0.4`) in `VERSION`. Hotfixes go on `hotfix/` branches, bump `VERSION`, and add an entry to `CHANGELOG.md` (date plus a `**Fix**` line linking the PR). Changelog entries and older commits are often in Spanish. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1556ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Development image for the speller service. +# Keep the Ruby version in sync with .ruby-version (the Gemfile enforces it). +FROM docker.io/library/ruby:4.0.9-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + libaspell-dev \ + aspell-en \ + aspell-es \ + aspell-pt-br \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Gemfile Gemfile.lock .ruby-version ./ +RUN bundle install + +COPY . . + +EXPOSE 3019 + +# rerun restarts the server on source changes. Polling is required: file events +# from the host do not reach the container through the podman machine mount. +CMD ["bundle", "exec", "rerun", "--background", "--force-polling", "--", \ + "rackup", "--host", "0.0.0.0", "--port", "3019"] diff --git a/Gemfile b/Gemfile index e75e865..8279dae 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,29 @@ source 'https://rubygems.org' + +ruby file: '.ruby-version' + # Classy web-development dressed in a DSL -gem 'sinatra' +gem 'sinatra', '~> 4.2' + +# Rack 3 moved the server launcher (rackup, SpellerApp.run!) out of rack +gem 'rackup', '~> 2.3' +gem 'puma', '~> 8.0' + +# Load aspell dynamically for spellcheck +gem 'ffi-aspell', '~> 1.1' -# Load aspell dinamically for spellcheck -gem 'ffi-aspell', '1.0.2' +group :development do + # Restarts the server when source files change + gem 'rerun', '~> 0.14' + # Language server and debugger for the VS Code Ruby LSP extension (run in the + # container). Kept here so the image has them and Ruby LSP installs nothing. + gem 'ruby-lsp', '~> 0.26', require: false + gem 'debug', '~> 1.11', require: false +end -# Cross Origin Request Sharing extension for Sinatra -gem 'sinatra-cross_origin', '0.3.2' +group :test do + gem 'minitest', '~> 6.0' + gem 'rake', '~> 13.4' + # Drives the Sinatra app in request tests + gem 'rack-test', '~> 2.3' +end diff --git a/Gemfile.lock b/Gemfile.lock index 97402fc..0ae386a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,26 +1,164 @@ GEM remote: https://rubygems.org/ specs: - ffi (1.9.10) - ffi-aspell (1.0.2) + base64 (0.3.0) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + erb (6.0.7) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + ffi-aspell (1.1.0) ffi - rack (1.6.4) - rack-protection (1.5.3) - rack - sinatra (1.4.7) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - sinatra-cross_origin (0.3.2) - tilt (2.0.5) + io-console (0.9.5) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + language_server-protocol (3.17.0.6) + listen (3.10.1) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + mustermann (3.1.1) + nio4r (2.7.5) + pp (0.6.4) + prettyprint + prettyprint (0.2.1) + prism (1.9.0) + puma (8.0.2) + nio4r (~> 2.0) + rack (3.2.8) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.3.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + rerun (0.14.0) + listen (~> 3.0) + ruby-lsp (0.26.11) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + tilt (2.9.0) + tsort (0.2.0) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - ffi-aspell (= 1.0.2) - sinatra - sinatra-cross_origin (= 0.3.2) + debug (~> 1.11) + ffi-aspell (~> 1.1) + minitest (~> 6.0) + puma (~> 8.0) + rack-test (~> 2.3) + rackup (~> 2.3) + rake (~> 13.4) + rerun (~> 0.14) + ruby-lsp (~> 0.26) + sinatra (~> 4.2) + +CHECKSUMS + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf + ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 + ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + ffi-aspell (1.1.0) sha256=000a29cd47076154229361d0b2fc2bbd223932c8c273a0ff7ee8cf1c8e4e54b6 + io-console (0.9.5) sha256=83dd9bbf4c4680416629245b39cd4212eb5f4d9a4ea91804870c1f6b846414d5 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + listen (3.10.1) sha256=4c3f32855657f348747da4dd2372ff3790d90a6b1d3e4f21a3706adb2d04783c + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + rack (3.2.8) sha256=1fecc85e463ebc7a2a945fff829ffcd549babeb1556384796fad946c28dc4ec7 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.3.0) sha256=b118d177cc002c572fc1b6fd8d19c9ed5881a36e8d6f689f9e618f9f861241e8 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd + ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d + sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 + tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + +RUBY VERSION + ruby 4.0.9 BUNDLED WITH - 1.12.5 + 4.0.20 diff --git a/README.md b/README.md index 87a1d77..32bf6fc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,38 @@ bundle install ruby speller.rb ``` +## Development with Podman + +The compose setup includes Ruby 4.0, Aspell and the es/en/pt_BR +dictionaries, so nothing needs to be installed on the host besides Podman. + +``` +podman compose up --build -d +curl -X POST localhost:3019/spell -d lang=en --data-urlencode 'text=Helo wrld' +``` + +The source is mounted into the container and the server restarts a few +seconds after a source file changes, so edits to `speller.rb` apply without a +manual restart. After changing the +`Gemfile`, run `podman compose run --rm speller bundle install` to update +`Gemfile.lock`, then `podman compose up --build -d`. + +### VS Code + +Ruby LSP runs inside the container, so no Ruby is needed on the host: + +1. Install the Dev Containers extension (`ms-vscode-remote.remote-containers`). +2. Add `"dev.containers.dockerPath": "podman"` to your VS Code user settings. +3. Run **Dev Containers: Reopen in Container**. + +## Tests + +The suite uses Minitest and runs against the real Aspell dictionaries: + +``` +podman compose run --rm speller bundle exec rake test +``` + ## Endpoints To consume the service, you could post ```[ROOT_URL]/spell``` endpoint, with the following params: diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d441390 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + t.warning = false +end + +task default: :test diff --git a/VERSION b/VERSION index ea2195e..1e212a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.0.4 +26.0.0 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bee5f2b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,18 @@ +# Development environment. The source tree is bind-mounted and rerun restarts +# the server when a source file changes, so edits apply without a manual restart. +# Gemfile changes need a rebuild: podman compose up --build +# +# The fixed project name lets the Dev Container (.devcontainer/) attach to the +# same container instead of starting a second one that would clash on 3019. +name: speller + +services: + speller: + build: . + image: localhost/speller:dev + ports: + - "3019:3019" + environment: + RACK_ENV: development + volumes: + - .:/app diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..ce329ab --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +require_relative 'speller' + +run SpellerApp diff --git a/speller.rb b/speller.rb index 518460a..a247126 100644 --- a/speller.rb +++ b/speller.rb @@ -1,16 +1,34 @@ require 'sinatra/base' -require 'sinatra/cross_origin' require 'ffi/aspell' require 'json' # Sinatra app for check spelling class SpellerApp < Sinatra::Base - register Sinatra::CrossOrigin + # Sent with every request that carries an Origin, which is echoed back so + # any site can call the service. + CORS_HEADERS = { + 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers' => '*, Content-Type, Accept, AUTHORIZATION, Cache-Control', + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Max-Age' => '1728000', + 'Access-Control-Expose-Headers' => + 'Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma' + }.freeze configure do - set :allow_origin, :any set :bind, '0.0.0.0' - enable :cross_origin + end + + # Sinatra 4.1+ rejects unknown Host headers in development (e.g. another + # container calling http://speller:3019). Disabled for now; other + # environments already accept any host. + configure :development do + set :host_authorization, permitted_hosts: [] + end + + before do + origin = request.env['HTTP_ORIGIN'] + headers CORS_HEADERS.merge('Access-Control-Allow-Origin' => origin) if origin end post '/spell' do @@ -24,18 +42,35 @@ class SpellerApp < Sinatra::Base # A class used to use aspell to spellcheck a text class Speller - def self.check(words, lang, max_suggestions = 10) - return if words.nil? + LEADING_PUNCTUATION = /\A[[:punct:]]*/ + TRAILING_PUNCTUATION = /[[:punct:]]*\z/ + + def self.check(text, lang, max_suggestions = 10) + return if text.nil? lang = ensure_lang(lang) - speller = FFI::Aspell::Speller.new(lang) - - pos = 0 - words.split.map do |word| - element = correct_word(speller, clean_word(word), max_suggestions, pos) - pos += (word.length + 1) - element - end.compact + # Pin aspell to UTF-8 for input and output instead of inheriting it from + # the process locale. It must be set before the language: ffi-aspell + # applies options in hash order, and with no locale the default encoding + # is "none", which crashes ffi-aspell. + speller = FFI::Aspell::Speller.new(lang, 'encoding' => 'utf-8') + + words(text).filter_map do |word, pos| + correct_word(speller, word, max_suggestions, pos) + end + end + + # Each whitespace-separated token of text with its surrounding punctuation + # removed, paired with the character offset where that cleaned word starts, + # so text[pos, word.length] == word. Punctuation-only tokens are skipped. + def self.words(text) + text.enum_for(:scan, /\S+/).filter_map do + match = Regexp.last_match + word = clean_word(match[0]) + next if word.empty? + + [word, match.begin(0) + match[0][LEADING_PUNCTUATION].length] + end end def self.ensure_lang(lang) @@ -45,17 +80,13 @@ def self.ensure_lang(lang) end def self.clean_word(word) - word.gsub(/^[[:punct:]]/,'') - .gsub(/[[:punct:]]$/,'') + word.sub(LEADING_PUNCTUATION, '').sub(TRAILING_PUNCTUATION, '') end def self.correct_word(speller, word, max_suggestions, pos) return if speller.correct?(word) suggestions = speller.suggestions(word).first(max_suggestions) - suggestions.map! do |suggestion| - suggestion.force_encoding('ISO-8859-1').encode('UTF-8') - end suggestions_to_hash(word, pos, suggestions) end @@ -64,4 +95,4 @@ def self.suggestions_to_hash(word, pos, suggestions) end end -SpellerApp.run! +SpellerApp.run! if __FILE__ == $PROGRAM_NAME diff --git a/test/speller_app_test.rb b/test/speller_app_test.rb new file mode 100644 index 0000000..28632c2 --- /dev/null +++ b/test/speller_app_test.rb @@ -0,0 +1,86 @@ +require 'test_helper' + +class SpellerAppTest < Minitest::Test + include Rack::Test::Methods + + def app + SpellerApp + end + + def json_body + JSON.parse(last_response.body) + end + + def test_post_returns_misspelled_words_as_json + post '/spell', text: 'Helo world', lang: 'en' + + assert last_response.ok? + assert_equal 1, json_body.size + assert_equal({ 'l' => 4, 'o' => 0, 's' => 1 }, json_body.first['attrs']) + assert_includes json_body.first['suggestions'], 'Hello' + end + + def test_post_accepts_params_in_query_string + post '/spell?lang=en&text=Helo' + + assert_includes json_body.first['suggestions'], 'Hello' + end + + def test_post_handles_accented_form_input + post '/spell', text: 'El corazón late, corazon', lang: 'es' + + assert_equal 1, json_body.size + assert_equal({ 'l' => 7, 'o' => 17, 's' => 1 }, json_body.first['attrs']) + assert_includes json_body.first['suggestions'], 'corazón' + end + + def test_post_offsets_count_newlines_in_form_input + post '/spell', text: "Hola\n\nprueva", lang: 'es' + + assert_equal({ 'l' => 6, 'o' => 6, 's' => 1 }, json_body.first['attrs']) + end + + def test_post_returns_null_without_text + post '/spell', lang: 'es' + + assert last_response.ok? + assert_equal 'null', last_response.body + end + + def test_post_allows_any_origin + post '/spell', { text: 'hola', lang: 'es' }, 'HTTP_ORIGIN' => 'http://example.com' + + assert_equal 'http://example.com', last_response.headers['Access-Control-Allow-Origin'] + end + + def test_options_answers_cors_preflight + options '/spell', {}, 'HTTP_ORIGIN' => 'http://example.com' + + assert last_response.ok? + assert_empty last_response.body + expected = { + 'Access-Control-Allow-Origin' => 'http://example.com', + 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers' => '*, Content-Type, Accept, AUTHORIZATION, Cache-Control', + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Max-Age' => '1728000', + 'Access-Control-Expose-Headers' => + 'Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma' + } + expected.each do |name, value| + assert_equal value, last_response.headers[name], name + end + end + + def test_no_cors_headers_without_origin + post '/spell', text: 'hola', lang: 'es' + + assert_nil last_response.headers['Access-Control-Allow-Origin'] + end + + def test_get_is_not_routed + get '/spell', text: 'hola', lang: 'es' + + assert_equal 404, last_response.status + end +end diff --git a/test/speller_test.rb b/test/speller_test.rb new file mode 100644 index 0000000..40418bb --- /dev/null +++ b/test/speller_test.rb @@ -0,0 +1,146 @@ +require 'test_helper' + +class SpellerTest < Minitest::Test + def test_ensure_lang_keeps_supported_languages + assert_equal 'es', Speller.ensure_lang('es') + assert_equal 'en', Speller.ensure_lang('en') + end + + def test_ensure_lang_maps_pt_to_pt_br_dictionary + assert_equal 'pt_BR', Speller.ensure_lang('pt') + end + + def test_ensure_lang_falls_back_to_es + assert_equal 'es', Speller.ensure_lang('fr') + assert_equal 'es', Speller.ensure_lang(nil) + end + + def test_clean_word_strips_leading_and_trailing_punctuation + assert_equal 'hola', Speller.clean_word('hola,') + assert_equal 'qué', Speller.clean_word('¿qué?') + assert_equal 'hola', Speller.clean_word('(hola)') + end + + def test_clean_word_keeps_inner_punctuation + assert_equal "don't", Speller.clean_word("don't") + end + + def test_clean_word_strips_repeated_punctuation + assert_equal 'hola', Speller.clean_word('hola...') + assert_equal 'hola', Speller.clean_word('¡¡hola!!') + assert_equal 'qué', Speller.clean_word('"¿qué?"') + end + + def test_clean_word_returns_empty_for_punctuation_only + assert_equal '', Speller.clean_word('...') + end + + def test_check_returns_nil_without_text + assert_nil Speller.check(nil, 'es') + end + + def test_check_returns_empty_list_when_every_word_is_correct + assert_equal [], Speller.check('El perro come pan', 'es') + end + + def test_check_reports_misspelled_word_with_position_and_suggestions + result = Speller.check('Esto es una prueva', 'es') + + assert_equal 1, result.size + assert_equal({ l: 6, o: 12, s: 1 }, result.first[:attrs]) + assert_includes result.first[:suggestions], 'prueba' + end + + def test_check_offsets_point_at_cleaned_word + result = Speller.check('Hola ¿cmo estás?', 'es') + + assert_equal 1, result.size + assert_equal({ l: 3, o: 6, s: 1 }, result.first[:attrs]) + assert_includes result.first[:suggestions], 'como' + end + + def test_check_offsets_survive_repeated_whitespace + text = "Hola mundo\n\nprueva\tcmo" + + assert_equal [13, 20], offsets(text) + assert_equal %w[prueva cmo], flagged(text) + end + + def test_check_offsets_survive_leading_whitespace + assert_equal [2], offsets(' prueva') + end + + def test_check_offsets_skip_surrounding_punctuation + assert_equal ['prueva'], flagged('Dijo: «prueva»,') + end + + def test_check_ignores_tokens_that_are_only_punctuation + assert_equal [], Speller.check('Hola - mundo ...', 'es') + end + + def test_check_ignores_repeated_punctuation + assert_equal [], Speller.check('Bueno... sí', 'es') + assert_equal [], Speller.check('¡¡Hola!!', 'es') + end + + def test_check_returns_empty_list_for_blank_text + assert_equal [], Speller.check('', 'es') + assert_equal [], Speller.check(" \n", 'es') + end + + def test_check_returns_ten_suggestions_by_default + assert_equal 10, Speller.check('prueva', 'es').first[:suggestions].size + end + + def test_check_accepts_custom_suggestion_limit + assert_equal 3, Speller.check('prueva', 'es', 3).first[:suggestions].size + end + + def test_check_uses_each_language_dictionary + assert_equal [], Speller.check('Hello world', 'en') + assert_equal [], Speller.check('Olá mundo', 'pt') + refute_empty Speller.check('Hello', 'es') + end + + def test_check_falls_back_to_spanish_for_unknown_languages + assert_includes Speller.check('prueva', 'fr').first[:suggestions], 'prueba' + end + + def test_check_accepts_correctly_spelled_accented_words + assert_equal [], Speller.check('El corazón late con pasión', 'es') + assert_equal [], Speller.check('O coração é um órgão', 'pt') + end + + def test_check_returns_accented_suggestions_as_valid_utf8 + spanish = Speller.check('corazon', 'es').first[:suggestions] + portuguese = Speller.check('coracao', 'pt').first[:suggestions] + + assert_includes spanish, 'corazón' + assert_includes portuguese, 'coração' + (spanish + portuguese).each do |suggestion| + assert_equal Encoding::UTF_8, suggestion.encoding + assert suggestion.valid_encoding?, "#{suggestion.inspect} is not valid UTF-8" + end + end + + def test_check_ignores_encoding_from_aspell_environment + previous = ENV['ASPELL_CONF'] + ENV['ASPELL_CONF'] = 'encoding iso-8859-1' + + assert_equal [], Speller.check('corazón', 'es') + assert_includes Speller.check('corazon', 'es').first[:suggestions], 'corazón' + ensure + ENV['ASPELL_CONF'] = previous + end + + private + + def offsets(text, lang = 'es') + Speller.check(text, lang).map { |entry| entry[:attrs][:o] } + end + + # The slice of text each result points at, as a client would highlight it + def flagged(text, lang = 'es') + Speller.check(text, lang).map { |entry| text[entry[:attrs][:o], entry[:attrs][:l]] } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..1c2bff7 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,6 @@ +ENV['RACK_ENV'] = 'test' + +require 'minitest/autorun' +require 'json' +require 'rack/test' +require_relative '../speller' From 24065410e51c8d449a5a8747b44963d2d2d18cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Lazo?= Date: Wed, 4 Nov 2026 17:38:53 -0300 Subject: [PATCH 2/4] Add testing action --- .dockerignore | 1 + .github/workflows/testing.yml | 54 +++++++++++++++++++++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 1 + CLAUDE.md | 5 ++++ Gemfile | 2 ++ Gemfile.lock | 3 ++ README.md | 9 ++++++ test/test_helper.rb | 11 +++++++ 9 files changed, 87 insertions(+) create mode 100644 .github/workflows/testing.yml create mode 100644 .gitignore diff --git a/.dockerignore b/.dockerignore index 6b3488d..5ee89f1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git .bundle .ruby-lsp +coverage diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..eb58934 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,54 @@ +name: testing + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + env: + # rerun, ruby-lsp and debug are only for the dev container + BUNDLE_WITHOUT: development + steps: + - name: Checkout code + uses: actions/checkout@v7 + + # ffi-aspell loads libaspell at runtime and the suite checks against the + # real es/en/pt_BR dictionaries, the same ones the Dockerfile installs. + - name: Install Aspell and dictionaries + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libaspell-dev aspell-en aspell-es aspell-pt-br + + # The Ruby version comes from .ruby-version + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests with coverage + env: + COVERAGE: '1' + run: bundle exec rake test + + - name: Coverage summary + run: | + echo "### Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "Line coverage: $(jq -r '.result.line' coverage/.last_run.json)%" >> "$GITHUB_STEP_SUMMARY" + + # TODO SonarQube: once the project exists, fix the paths in + # coverage/.resultset.json (sed $GITHUB_WORKSPACE -> /github/workspace/) + # and run LemontechSA/action-sonarqube-scan, as in green-mario. + - name: Upload coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage/ + include-hidden-files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 690aca1..b445500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Wednesday, November 4th * **Update** Reemplaza `sinatra-cross_origin` por headers CORS propios con los mismos valores * **New** Entorno de desarrollo con Podman compose en el puerto 3019 y Dev Container para Ruby LSP en VS Code * **New** Suite de tests con Minitest +* **New** GitHub Action que corre los tests con cobertura (SimpleCov) en cada PR y en `master` ## Hotfix 17.0.4 Monday, May 15th diff --git a/CLAUDE.md b/CLAUDE.md index 5de0c4f..91b7aaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,8 +30,13 @@ Tests use Minitest + rack-test (`test/`) and need real Aspell dictionaries, so r podman compose run --rm speller bundle exec rake test # whole suite podman compose run --rm speller bundle exec rake test TEST=test/speller_app_test.rb # one file podman compose run --rm speller bundle exec ruby -Itest test/speller_test.rb -n test_check_returns_nil_without_text # one test +podman compose run --rm -e COVERAGE=1 speller bundle exec rake test # with SimpleCov report in coverage/ ``` +Coverage is opt-in (`COVERAGE=1`, as in timeflow). SimpleCov 1.x starts at the top of `test/test_helper.rb`, before `speller.rb` is required, and only covers `speller.rb` (`cover`; 1.x deprecates `track_files`/`add_filter`). `coverage/` is gitignored. + +CI: `.github/workflows/testing.yml` runs on every PR and on push to `master`. It installs Aspell and the dictionaries with apt, uses `ruby/setup-ruby` (version from `.ruby-version`), runs the suite with `COVERAGE=1`, writes line coverage to the job summary and uploads `coverage/` as an artifact (with `.resultset.json`). SonarQube isn't set up yet because the project doesn't exist; the workflow has a TODO where the scan goes. + There is no linter config. ## Architecture diff --git a/Gemfile b/Gemfile index 8279dae..ad71395 100644 --- a/Gemfile +++ b/Gemfile @@ -26,4 +26,6 @@ group :test do gem 'rake', '~> 13.4' # Drives the Sinatra app in request tests gem 'rack-test', '~> 2.3' + # Coverage report, opt-in with COVERAGE=1 (see test/test_helper.rb) + gem 'simplecov', '~> 1.3', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 0ae386a..02bcad7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,6 +76,7 @@ GEM language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) + simplecov (1.3.2) sinatra (4.2.1) logger (>= 1.6.0) mustermann (~> 3.0) @@ -109,6 +110,7 @@ DEPENDENCIES rake (~> 13.4) rerun (~> 0.14) ruby-lsp (~> 0.26) + simplecov (~> 1.3) sinatra (~> 4.2) CHECKSUMS @@ -153,6 +155,7 @@ CHECKSUMS reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d + simplecov (1.3.2) sha256=2a22ec1759f1b8db60881a2e038758956fc69ef93ed669be0bde0678de6d0e85 sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f diff --git a/README.md b/README.md index 32bf6fc..2de737a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,15 @@ The suite uses Minitest and runs against the real Aspell dictionaries: podman compose run --rm speller bundle exec rake test ``` +To get a coverage report in `coverage/index.html`, set `COVERAGE=1`: + +``` +podman compose run --rm -e COVERAGE=1 speller bundle exec rake test +``` + +GitHub Actions runs the suite with coverage on every pull request and on +pushes to `master` (`.github/workflows/testing.yml`). + ## Endpoints To consume the service, you could post ```[ROOT_URL]/spell``` endpoint, with the following params: diff --git a/test/test_helper.rb b/test/test_helper.rb index 1c2bff7..0024eec 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,14 @@ +# Coverage is opt-in (COVERAGE=1), as in the other Lemontech services. CI turns +# it on and keeps coverage/.resultset.json, the file SonarQube will read. +# SimpleCov must start before the app is required, or speller.rb isn't tracked. +if ENV['COVERAGE'] + require 'simplecov' + + SimpleCov.start do + cover 'speller.rb' + end +end + ENV['RACK_ENV'] = 'test' require 'minitest/autorun' From 96be5d164ff11f6874517d9ce1088229dff02c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Lazo?= Date: Wed, 4 Nov 2026 17:42:06 -0300 Subject: [PATCH 3/4] Add .DS_Store. to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 404abb2..0ad224d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ coverage/ +.DS_Store From e31008f482906749f78ccba5ac5fadb0def3a6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Lazo?= Date: Wed, 4 Nov 2026 17:43:11 -0300 Subject: [PATCH 4/4] Remove .DS_Store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2ff644c3c50f6b641ffa8623082459ab00341e9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKK~BR!3>-rxin#R1aX$bOKUh`e1$+Rt;T9=~s7Ty$QUb^vj>-3q*N&zV#1*Cu!kOJowV9$12-XtnY0VyB_eiZQUL!mp? zhErmEIv8REATF2=<8#as#Nr8JZ8#+|L$jn3lWJ|mu%t6zT3u~8B_