> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Contributing

> Guidelines for contributing to the project

## Branching

We use GitHub Flow with a naming convention:

```
[type]/[issue-id]-[description]
```

Examples:

* `feature/VOT-33-participant-view`
* `fix/VOT-501-sqlite-busy`
* `chore/setup-repo`

All branches are created from and merged back to `main`.

## Commit Messages

* Use imperative mood: "Add voting controls", not "Added voting controls"
* Include the Linear issue ID in the commit message: `VOT-33: Add participant voting controls`
* Keep the first line under 72 characters

## Pull Requests

PR titles must include the issue ID:

```
VOT-33: Implement participant view with voting controls
```

## Code Style

The project uses [rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase) — the default Rails style guide. Run the linter before committing:

```bash theme={null}
bin/rubocop
```

Auto-fix safe violations:

```bash theme={null}
bin/rubocop -a
```

## Testing

All tests live under the `test/` directory following standard Rails conventions:

| Directory | Contents |
| - | - |
| `test/models/` | Model unit tests |
| `test/controllers/` | Controller integration tests |
| `test/system/` | System tests (Capybara + Selenium) |
| `test/fixtures/` | YAML fixtures for test data |

Run all tests:

```bash theme={null}
bin/rails test
```

Run a specific test file:

```bash theme={null}
bin/rails test test/controllers/meetings/votes_controller_test.rb
```

Run the full CI check (tests + linting + security):

```bash theme={null}
bin/ci
```

### System tests

System tests live in `test/system/` and drive a real headless Chrome/Chromium
browser via Capybara + Selenium, rendering real pages with Turbo, Stimulus, and
the enforcing nonce-based CSP. They are not run by `bin/rails test`; run them
explicitly:

```bash theme={null}
bin/rails test:system
```

They need the Tailwind build output present (`bin/rails tailwindcss:build`). If
the system `chromedriver` matches Chromium rather than `google-chrome-stable`,
point Selenium at the matching binary:

```bash theme={null}
CHROME_BIN=/usr/bin/chromium bin/rails test:system
```

CI runs `test` and `test:system` as separate jobs; the `system-test` job builds
Tailwind first. Never run the unit suite and the system suite at the same time
on one checkout: they share the SQLite test databases.

#### Workers and browsers

The system suite runs on half the logical processors. `ApplicationSystemTestCase`
calls `parallelize(workers: [ Etc.nprocessors / 2, 1 ].max)`, which replaces
`test_helper.rb`'s one worker per logical processor (four workers on an
eight-thread machine). Each worker runs its own Puma server and one headless
Chrome for every Capybara session a test drives — the default session and each
`using_session(:name)`. With one worker per logical processor the browsers
starved the machine and unrelated tests missed Capybara's waits at random (seen
once VOT-445 added a five-browser test); half the workers finish the suite
sooner, and green. Set `PARALLEL_WORKERS` to choose the number of workers
yourself; it overrides the halving. Without it, a run of 50 tests or fewer (one
file, say) runs in a single process: that is Rails' parallelization threshold.

A new system test opens at most two browsers: the default session and one named
session. The existing exceptions are `projector_live_controls_test.rb` (VOT-445:
the pause-and-message test drives five browsers, the meeting-end test four) and
`meeting_closed_test.rb` (four).

#### Turbo-upgrade canaries

The secretary shell leans on Turbo 8 / turbo-rails 2.0.23 behaviour that is not
public API. These browser tests fail loudly when an upgrade changes it; run
them first after bumping `turbo-rails`:

* **SH17.2** (`test/system/secretary_shell_test.rb`, «rail visits keep the
  stream source and its connection») — the permanent
  `<turbo-cable-stream-source>` keeper in `application.js`: Turbo moves a
  `data-turbo-permanent` element into the new body (Bardo), and the keeper
  parks the element's `subscription` across that move.
* **PF11.2** (`test/system/secretary_preferences_test.rb`, «a broadcast refresh
  never flips the pane back») — the pane-state guard on
  `turbo:before-morph-attribute` in `secretary_shell_controller.js`.
* **A10** (`test/system/secretary_pages_test.rb`, the four «A10 …» tests) — the
  own-write refetch: `turbo:submit-end` / `turbo:frame-load` ordering, and
  Turbo reloading a `refresh="morph"` frame with a `src` during a page refresh
  (held off with `data-turbo-permanent` for one render).
* **VOT-474** — in `test/system/secretary_pages_test.rb` the in-page link tests
  (Turbo visits `href="#…"` itself; the shell cancels the click), the CSV sync
  preview kept through a refresh and the bot count kept through a refresh
  (`turbo:before-morph-element`); in
  `test/system/secretary_question_form_refresh_test.rb` «a rail preview
  superseded mid-body logs no console error» (the unawaited body read of a
  request Turbo aborted, consumed in `application.js`).

#### Screenshots

Two opt-in switches save screenshots. With neither set, the tests run exactly
the same and write nothing.

* `PROJECTOR_SCREENSHOTS` — development screenshots from the projector system
  tests (`save_projector_screenshot` in `test/support/projector_system_helper.rb`).
  `1` writes to the test's own folder under `Mockup/`, relative to the checkout
  (`/Mockup/` is git-ignored, so these are never committed); any other value is
  the target directory — from a worktree, pass the main checkout's absolute
  path. Files are numbered after the highest `NN-` prefix already in the folder,
  so earlier screenshots are never overwritten.
* `DOCS_SCREENSHOTS` — regenerates the projector guide's images in
  `docs/images/projektor/`, which are committed with the guide. The test that
  captures them, `test/system/docs_screenshots_test.rb`, is skipped unless the
  switch is set, so every normal system run reports it as one skip:

```bash theme={null}
DOCS_SCREENSHOTS=1 CHROME_BIN=/usr/bin/chromium bin/rails test test/system/docs_screenshots_test.rb
```

#### Flakes

* A run whose failures are all `Selenium::WebDriver::Error::InvalidSessionIdError`,
  with zero assertion failures, is a Chromium crash, not a regression: re-run it
  once before calling it one.
* Assert a flash toast with `assert_toast` (`ApplicationSystemTestCase`), not
  `assert_text`. `flash_controller.js` fades a toast five seconds after it
  appears (`.flash-dismissed`, `opacity: 0`), which Selenium reports as not
  displayed, so an `assert_text` that reaches the page late in a busy run misses
  a notice that was there all along. `assert_toast` matches
  `#flash-messages .flash-toast` with `visible: :all`, faded or not.

### Code coverage

Coverage is measured by [SimpleCov](https://github.com/simplecov-ruby/simplecov)
and is opt-in via the `COVERAGE=1` flag (kept off by default so the normal test
run stays fast). Results from all parallel workers are merged automatically:

```bash theme={null}
COVERAGE=1 bin/rails test   # writes coverage/index.html
```

Baseline at introduction (VOT-377): \~89% line coverage, \~75% branch coverage. No
minimum is enforced yet, so coverage never fails the build.

### N+1 query guard

The [`bullet`](https://github.com/flyerhzm/bullet) gem watches the four meeting
hot paths — participant, chair, secretary, and projector. In development it is
non-blocking and logs offenders to `log/bullet.log`. In the test suite,
`test/integration/n1_hot_paths_test.rb` seeds a rich meeting and renders every
hot path with `Bullet.raise` enabled (see `test/support/bullet_helper.rb`), so a
newly introduced N+1 fails the build. Only `n_plus_one_query` is enforced;
`UnusedEagerLoading` and `CounterCache` are advisory and stay disabled in the
guard.

Every request runs in its own Bullet window. `BulletHelper` (included by
`n1_hot_paths_test.rb` and `chair_podium_contract_test.rb`) wraps the
integration request methods — `get`, `post`, `patch`, `put`, `head`, `delete`
and `follow_redirect!`: before a request the window is emptied, after it the
request's own N+1s raise `Bullet::Notification::UnoptimizedQueryError`, and a
fresh window opens for the next request. Teardown discards whatever the test did
after its last request. So seeding between requests is never reported, and a
request is never excused by rows the test loaded or created before it: Bullet
does not report an N+1 on rows already loaded or created in its window, which is
how one window for the whole test (the guard's first setup) hid the requests
that followed the seeding. Giving each request its own window (VOT-447) found an
N+1 the guard had missed: «Start møte» purging rehearsal quick polls loaded each
option's votes on its own (`MeetingTestDataPurge` now preloads them). Seed freely
between requests; no reset call is needed. To check a block that is not a
request, wrap it in `bullet_request_window { ... }` (see
`test/integration/bullet_helper_test.rb`).

When a view needs poll or agenda data, reuse the existing eager-loading scopes
instead of adding ad-hoc `includes`:

| Scope | Preloads |
| - | - |
| `Poll.for_display` | Options, `poll_result_snapshot`, and the parent `AgendaItem` with its polls, meeting, and exclusions |
| `AgendaItem.with_ancestry` | `:parent` — needed by `display_number` |
| `AgendaItem.with_tree_relations` | Adds `:children`; used by the secretary's Saksliste pane |

## Security

Run security checks before submitting:

```bash theme={null}
bin/brakeman        # Static analysis for vulnerabilities
bin/bundler-audit   # Audit gems for known CVEs
```

## Language

* **Code, comments, and technical docs**: English
* **UI text and user guides**: Norwegian (Bokmål)
* Locale file: `config/locales/nb.yml` — the app ships Norwegian only (VOT-475). The locale setup stays: every UI string goes through `t()`, never a hardcoded text, and `config.i18n.available_locales` is derived from the files in `config/locales`, so adding `config/locales/<locale>.yml` makes that language available again. The profile's language setting and the meeting's «Språk på projektorskjermene» show themselves once more than one locale is available (`LocaleHelper#locale_choice_available?`), with no code change

## Project Tracking

We use [Linear](https://linear.app) for issue tracking. The team is **Voteringsapp** and issues use the `VOT-` prefix.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.