Files
brain-of-reese/tests/unit/test_config.py
T
ducoterra d22d260b8b
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
2026-09-09 17:22:24 -04:00

506 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests: settings defaults & env overrides."""
from __future__ import annotations
import json
from typing import Any
import pytest
from pydantic import ValidationError
from pydantic_settings import SettingsError
from app.config import _DEFAULT_IMPORT_EXTENSIONS, Settings # pyright: ignore[reportPrivateUsage]
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> None:
# The test process sets BOR_RELEVANCE_THRESHOLD=0.30 for the mock-
# calibrated in-process suites (see tests/conftest.py) — the *default*
# under test is the production one.
monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False)
s = _settings()
assert s.llm_chat_model == "turbo"
assert s.llm_embed_model == "embed"
# A5 extended (phase 30): one-shot completions default to the ``lite``
# model on the same endpoint.
assert s.llm_summary_model == "lite"
assert s.embedding_dim == 768
assert s.llm_base_url.endswith("/v1")
# A8 (revised): the honesty gate input is the best cosine, default 0.62.
assert s.relevance_threshold == 0.62
# A7 (revised): hybrid retrieval — cosine top-N ∪ FTS top-N, RRF-fused.
assert s.hybrid_vector_candidates >= 1
assert s.hybrid_lexical_candidates >= 1
assert s.rrf_k >= 1
assert s.top_n_docs >= 1
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
assert s.max_output_tokens == 32_768
# Phase 17: the model's thinking streams by default (kill-switch off).
assert s.stream_thinking is True
assert len(s.suggestions) >= 3
# A9 (revised 2026-08-27): the import scope covers all seventeen
# A9 formats (original seven + quadlet family + jinja).
assert s.import_extension_set == {
".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py",
".container", ".network", ".volume", ".image", ".pod",
".kube", ".swap", ".os", ".endpoint", ".j2",
}
NEW_A9_FORMATS = (
"container", "network", "volume", "image", "pod",
"kube", "swap", "os", "endpoint", "j2",
)
def test_default_import_extensions_is_the_full_a9_family() -> None:
"""Phase 56: the built-in default is the full A9 set — the original
seven plus the ten added 2026-08-27 (quadlet family + ``j2``). It is
the default and the ``.env.example`` example, NOT a ceiling: the
validator accepts any well-formed extension beyond it."""
assert {
"md", "markdown", "txt", "yaml", "yml", "json", "py",
*NEW_A9_FORMATS,
} == _DEFAULT_IMPORT_EXTENSIONS
def test_default_import_extensions_include_the_ten_new_formats() -> None:
"""A9 revised 2026-08-27 (owner permission): the quadlet family +
``j2`` are imported by default — no env configuration needed — with
the original seven first (order is cosmetic, the set is what
matters)."""
s = _settings()
for ext in NEW_A9_FORMATS:
assert ext in s.import_extensions
assert s.import_extension_set == (
{".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"}
| {f".{ext}" for ext in NEW_A9_FORMATS}
)
def test_env_override(monkeypatch) -> None:
monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.42")
monkeypatch.setenv("BOR_LLM_CHAT_MODEL", "juggernaut")
s = _settings()
assert s.relevance_threshold == 0.42
assert s.llm_chat_model == "juggernaut"
def test_llm_summary_model_env_override(monkeypatch) -> None:
"""Phase 30: ``BOR_LLM_SUMMARY_MODEL`` overrides the ``lite`` default"""
monkeypatch.setenv("BOR_LLM_SUMMARY_MODEL", "mini")
s = _settings()
assert s.llm_summary_model == "mini"
def test_summary_max_chars_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 30: document content sent to the ``lite`` model is capped at
``BOR_SUMMARY_MAX_CHARS`` (default 12 000 chars per call)."""
monkeypatch.delenv("BOR_SUMMARY_MAX_CHARS", raising=False)
assert _settings().summary_max_chars == 12_000
monkeypatch.setenv("BOR_SUMMARY_MAX_CHARS", "5000")
assert _settings().summary_max_chars == 5000
def test_max_output_tokens_env_override(monkeypatch) -> None:
monkeypatch.setenv("BOR_MAX_OUTPUT_TOKENS", "1234")
s = _settings()
assert s.max_output_tokens == 1234
def test_llm_retry_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 67: a failed LLM request is retried by default — 3 retries
with a flat 5 s delay (the TODO-locked values, no backoff)."""
monkeypatch.delenv("BOR_LLM_RETRIES", raising=False)
monkeypatch.delenv("BOR_LLM_RETRY_DELAY", raising=False)
s = _settings()
assert s.llm_retries == 3
assert s.llm_retry_delay == 5.0
def test_llm_retry_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""``BOR_LLM_RETRIES`` / ``BOR_LLM_RETRY_DELAY`` override the defaults;
``0`` retries is the no-retry kill switch (pre-phase-67 behavior)."""
monkeypatch.setenv("BOR_LLM_RETRIES", "0")
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "1.5")
s = _settings()
assert s.llm_retries == 0
assert s.llm_retry_delay == 1.5
def test_llm_retries_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""``0`` is the kill switch — a negative value is a typo, so the
validator fails loudly at startup (the ``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_LLM_RETRIES", "-1")
with pytest.raises(ValidationError, match="llm_retries"):
_settings()
def test_llm_retry_delay_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative delay is a typo — fail loudly at startup."""
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "-0.5")
with pytest.raises(ValidationError, match="llm_retry_delay"):
_settings()
def test_history_budget_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 74 (TODO L4): the client-provided history is trimmed
newest-first against the newest 40 turns within a total of
24 000 chars (text + prior thinking, owner-locked A3)."""
monkeypatch.delenv("BOR_HISTORY_MAX_TURNS", raising=False)
monkeypatch.delenv("BOR_HISTORY_MAX_CHARS", raising=False)
s = _settings()
assert s.history_max_turns == 40
assert s.history_max_chars == 24_000
def test_history_budget_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""``BOR_HISTORY_MAX_TURNS`` / ``BOR_HISTORY_MAX_CHARS`` override the
defaults; ``0`` on either is the no-history kill switch (the
pre-phase-74 two-message requests)."""
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "12")
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "5000")
s = _settings()
assert s.history_max_turns == 12
assert s.history_max_chars == 5000
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "0")
assert _settings().history_max_turns == 0
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "0")
assert _settings().history_max_chars == 0
def test_history_max_turns_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""``0`` is the no-history kill switch — a negative value is a typo,
so the validator fails loudly at startup (the ``agent_max_rounds``
pattern)."""
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "-1")
with pytest.raises(ValidationError, match="history_max_turns"):
_settings()
def test_history_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative char budget is a typo — fail loudly at startup."""
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "-1")
with pytest.raises(ValidationError, match="history_max_chars"):
_settings()
def test_agent_max_rounds_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 45: the per-tool budgets are gone — ``BOR_AGENT_MAX_ROUNDS``
(default 10) is the single agent-loop knob; ``0`` is the no-tools
kill switch."""
monkeypatch.delenv("BOR_AGENT_MAX_ROUNDS", raising=False)
assert _settings().agent_max_rounds == 10
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "5")
assert _settings().agent_max_rounds == 5
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "0")
assert _settings().agent_max_rounds == 0
def test_agent_max_rounds_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""``0`` is the kill switch — a negative value is a typo, so the
validator fails loudly at startup."""
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "-1")
with pytest.raises(ValidationError, match="agent_max_rounds"):
_settings()
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
``0``/``false`` turn the ``thinking`` SSE frames off."""
assert _settings().stream_thinking is True
assert _settings(stream_thinking=False).stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "0")
assert _settings().stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "false")
assert _settings().stream_thinking is False
monkeypatch.setenv("BOR_STREAM_THINKING", "1")
assert _settings().stream_thinking is True
def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None:
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,yml")
s = _settings()
assert s.import_extension_set == {".md", ".yml"}
def test_import_extensions_accepts_novel_extension(monkeypatch) -> None:
"""Phase 56 (owner permission 2026-08-31): the A9 family is the
default, not the ceiling — a novel well-formed extension (``sh``) is
accepted and simply becomes importable."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh")
s = _settings()
assert s.import_extension_set == {".md", ".sh"}
def test_import_extensions_normalizes_case_and_leading_dot(monkeypatch) -> None:
"""Case and a leading dot are both tolerated (unchanged tolerance)."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "MD,.Py")
s = _settings()
assert s.import_extension_set == {".md", ".py"}
def test_import_extensions_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""A blank list would silently import nothing — fail loudly at
startup, naming the field (empty, whitespace-only, and comma-only
all parse to zero formats)."""
for value in ("", " ", ",,"):
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", value)
with pytest.raises(ValidationError, match="import_extensions"):
_settings()
def test_import_extensions_validator_accepts_new_a9_formats(monkeypatch) -> None:
"""A9 revised 2026-08-27: quadlet/jinja names are first-class default
formats — a CSV using them (a narrowing of the default family) is
accepted."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,container,j2")
s = _settings()
assert s.import_extension_set == {".md", ".container", ".j2"}
def test_import_extensions_rejects_malformed_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
"""The shape guard (``^[a-z0-9]{1,16}$``) is the typo guard — it
keeps punctuation and path-ish values out of the set, naming the
offending token(s), while any extension a file could actually be
suffixed with still goes through."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh!")
with pytest.raises(ValidationError, match="sh!"):
_settings()
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,../x")
with pytest.raises(ValidationError, match=r"/x"):
_settings()
def test_git_sources_default_empty_and_sources_dir_default() -> None:
"""Phase 28: no git sources by default (backwards-compatible with the
``--source`` / ``DEFAULT_SOURCES`` fallback); the clone location stays
a raw string (``~`` is expanded by the import script, not the setting)."""
s = _settings()
assert s.git_sources == ""
assert s.git_source_list == []
assert s.sources_dir == "~/bor-sources"
def test_git_sources_env_override_parses_comma_separated_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``BOR_GIT_SOURCES`` is a raw CSV: entries are trimmed and empty
entries dropped; URLs are stored untouched (no scheme parsing here)."""
monkeypatch.setenv(
"BOR_GIT_SOURCES",
"https://github.com/user/homelab.git, "
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git ",
)
s = _settings()
# The raw CSV string is preserved untouched (no parsing in the setting).
assert s.git_sources == (
"https://github.com/user/homelab.git, "
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git "
)
assert s.git_source_list == [
"https://github.com/user/homelab.git",
"git@github.com:user/deployments.git",
"https://git.reeseapps.com/x/y.git",
]
def test_git_sources_whitespace_only_yields_empty_list(monkeypatch) -> None:
"""A configured-but-blank value behaves the same as unset: no git
sources, so the script falls back to its legacy local defaults."""
monkeypatch.setenv("BOR_GIT_SOURCES", " , , ")
s = _settings()
assert s.git_source_list == []
def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_SOURCES_DIR", "/data/bor/sources")
s = _settings()
assert s.sources_dir == "/data/bor/sources"
def test_suggestions_default_is_three_plus_real_questions() -> None:
s = _settings()
assert len(s.suggestions) >= 3
assert all(isinstance(q, str) and q.strip() for q in s.suggestions)
# Distinct chips only — duplicates in the onboarding row are noise.
assert len({q.strip().lower() for q in s.suggestions}) == len(s.suggestions)
def test_suggestions_env_override_is_json_list(monkeypatch) -> None:
override = [
"How do I back up with Borg?",
"How is my K3S cluster set up?",
"How do I deploy a service?",
]
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
s = _settings()
assert s.suggestions == override
def test_suggestions_malformed_json_fails_loudly(monkeypatch) -> None:
monkeypatch.setenv("BOR_SUGGESTIONS", "[not valid json")
with pytest.raises(SettingsError):
_settings()
def test_effective_api_key_fallback(monkeypatch) -> None:
monkeypatch.delenv("AIPI_KEY", raising=False)
s = _settings()
assert s.effective_api_key == "not-needed"
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
s2 = _settings()
assert s2.effective_api_key == "sk-from-env"
# --- Docs push (phase 59) ---
def test_docs_push_defaults_are_inert() -> None:
"""Phase 59, D3: no docs repo by default — the feature is
inert-by-default (button hidden, push endpoint 409s — the
optional-feature pattern of the git-sources env fallback), and the
branch/base defaults + raw work-dir string are in place."""
s = _settings()
assert s.docs_repo == ""
assert s.docs_configured is False
assert s.docs_branch == "bor-docs"
assert s.docs_base_branch == "main"
# Raw string on purpose — Path.expanduser() is applied by the push
# service, not the setting (the sources_dir/upload_dir convention).
assert s.docs_work_dir == "~/bor-docs"
def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a
local path (D3: generic remote, no scheme parsing here)."""
for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"):
monkeypatch.setenv("BOR_DOCS_REPO", repo)
s = _settings()
assert s.docs_configured is True
assert s.docs_repo == repo
# Whitespace-only behaves like empty: still inert.
monkeypatch.setenv("BOR_DOCS_REPO", " ")
assert _settings().docs_configured is False
def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False)
monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False)
assert _settings().docs_branch == "bor-docs"
assert _settings().docs_base_branch == "main"
monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master")
s = _settings()
assert s.docs_branch == "docs-pr"
assert s.docs_base_branch == "master"
def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs")
s = _settings()
assert s.docs_work_dir == "/data/bor/docs"
def test_docs_branch_whitespace_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A whitespace-bearing branch would corrupt a ``git checkout``
argument — fail loud at startup, naming the field (the
``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_branch_dotdot_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``..`` is a path-traversal token, never part of a branch name.
A blank branch is rejected too (empty while a repo is set)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BRANCH", " ")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_base_branch_invalid_fails_loudly_naming_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The base branch gets the same token shape check — the error
names ``docs_base_branch``, not the sibling field."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None:
"""Repo set + well-formed branch tokens boot cleanly and the
feature is configured (dash/dot/slash branch names are legal git
refs and stay accepted)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
s = _settings() # defaults bor-docs / main
assert s.docs_configured is True
monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop")
s2 = _settings()
assert s2.docs_configured is True
assert s2.docs_branch == "feature/docs-update"
assert s2.docs_base_branch == "develop"
def test_docs_branchs_garbage_ignored_when_repo_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""All-or-nothing: while the repo is empty the feature is inert, so
the (ignored) branch values must NOT block startup — only a
configured repo makes the shape check apply."""
monkeypatch.delenv("BOR_DOCS_REPO", raising=False)
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..")
s = _settings()
assert s.docs_configured is False
assert s.docs_branch == "bor docs.." # stored verbatim, never used
# --- UI customization (phase 62, TODO L3) ---
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
phase-61 copy is the DEFAULT (composer placeholder + footer line).
Phase 91 (task 03): the retired CSS-file theme env var is gone —
``Settings`` no longer has a theme field at all (a leftover value
in a deployment's .env is ignored, not a boot failure)."""
s = _settings()
assert s.input_placeholder == "Ask me anything…"
assert s.footer_text == "Powered by self-hosted models"
assert "theme" not in type(s).model_fields
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""The two string settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
placeholder/footer accept any string (empty is legal — the brand
layer then keeps the template default)."""
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
s = _settings()
assert s.input_placeholder == "Ask the vault…"
assert s.footer_text == "Powered by my own models"
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
assert _settings().input_placeholder == "" # empty stands