init
This commit is contained in:
commit
97f9a95415
21 changed files with 2963 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
tags
|
||||||
|
tags.lock
|
||||||
|
tags.temp
|
||||||
|
tests/__pycache__
|
||||||
|
infra/.terraform
|
||||||
|
infra/.terraform.lock.hcl
|
||||||
|
venv
|
140
Makefile
Normal file
140
Makefile
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
.PHONY: help init plan apply destroy output clean backend-setup setup-backend deploy-frontend dev test test-local install-deps lint format
|
||||||
|
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo "Available targets:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# ===== Infrastructure =====
|
||||||
|
backend-setup: ## Set up S3 backend infrastructure (run this first)
|
||||||
|
cd infra && terraform init
|
||||||
|
cd infra && terraform apply -target=aws_s3_bucket.terraform_state -target=aws_dynamodb_table.terraform_state_lock -auto-approve
|
||||||
|
|
||||||
|
setup-backend: ## Configure Terraform to use S3 backend (run after backend-setup)
|
||||||
|
cd infra && sed -i 's/# backend "s3" {/backend "s3" {/' versions.tf
|
||||||
|
cd infra && sed -i 's/# bucket/ bucket/' versions.tf
|
||||||
|
cd infra && sed -i 's/# key/ key/' versions.tf
|
||||||
|
cd infra && sed -i 's/# region/ region/' versions.tf
|
||||||
|
cd infra && sed -i 's/# dynamodb_table/ dynamodb_table/' versions.tf
|
||||||
|
cd infra && sed -i 's/# encrypt/ encrypt/' versions.tf
|
||||||
|
cd infra && sed -i 's/# }/}/' versions.tf
|
||||||
|
cd infra && terraform init -migrate-state
|
||||||
|
|
||||||
|
init: ## Initialize Terraform
|
||||||
|
cd infra && terraform init
|
||||||
|
|
||||||
|
plan: ## Show Terraform plan
|
||||||
|
cd infra && terraform plan
|
||||||
|
|
||||||
|
apply: ## Apply Terraform changes
|
||||||
|
cd infra && terraform apply -auto-approve
|
||||||
|
|
||||||
|
destroy: ## Destroy Terraform infrastructure
|
||||||
|
cd infra && terraform destroy -auto-approve
|
||||||
|
|
||||||
|
output: ## Show Terraform outputs
|
||||||
|
cd infra && terraform output
|
||||||
|
|
||||||
|
clean: ## Clean up Terraform state and lock files
|
||||||
|
cd infra && rm -f .terraform.lock.hcl
|
||||||
|
cd infra && rm -rf .terraform/
|
||||||
|
|
||||||
|
deploy-frontend: ## Deploy frontend files to S3 bucket
|
||||||
|
aws s3 sync public/ s3://calculator-127local-net --delete --exclude "*.DS_Store" --exclude "*.tmp"
|
||||||
|
aws cloudfront create-invalidation --distribution-id E35YHG58GE55V5 --paths "/*"
|
||||||
|
|
||||||
|
deploy: backend-setup setup-backend plan apply output ## Full deployment pipeline with backend setup
|
||||||
|
|
||||||
|
# ===== Local Development =====
|
||||||
|
dev: ## Start local development server
|
||||||
|
PORT=8008 python dev_server.py
|
||||||
|
|
||||||
|
dev-server: dev ## Alias for dev target
|
||||||
|
|
||||||
|
# ===== Testing =====
|
||||||
|
test: ## Run all tests (requires local dev server running)
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
test-local: ## Run tests with local dev server (starts server, runs tests, stops server)
|
||||||
|
@echo "Starting local development server..."
|
||||||
|
@python dev_server.py &
|
||||||
|
@echo "Waiting for server to start..."
|
||||||
|
@sleep 3
|
||||||
|
@echo "Running tests..."
|
||||||
|
@pytest tests/ -v
|
||||||
|
@echo "Stopping server..."
|
||||||
|
@pkill -f "python dev_server.py" || true
|
||||||
|
|
||||||
|
test-watch: ## Run tests in watch mode (requires local dev server)
|
||||||
|
pytest tests/ -v -f --tb=short
|
||||||
|
|
||||||
|
# ===== Dependencies =====
|
||||||
|
venv: ## Create virtual environment
|
||||||
|
python3 -m venv venv
|
||||||
|
@echo "Virtual environment created. Activate with: source venv/bin/activate"
|
||||||
|
|
||||||
|
install-deps: venv ## Install Python development dependencies in virtual environment
|
||||||
|
@echo "Installing dependencies in virtual environment..."
|
||||||
|
venv/bin/pip install -r requirements.txt
|
||||||
|
@echo "Dependencies installed. Activate virtual environment with: source venv/bin/activate"
|
||||||
|
|
||||||
|
install-test-deps: install-deps ## Alias for install-deps
|
||||||
|
|
||||||
|
activate: ## Show activation command
|
||||||
|
@echo "To activate virtual environment, run:"
|
||||||
|
@echo "source venv/bin/activate"
|
||||||
|
@echo ""
|
||||||
|
@echo "Then you can run:"
|
||||||
|
@echo "make test"
|
||||||
|
@echo "make dev"
|
||||||
|
|
||||||
|
# ===== Code Quality =====
|
||||||
|
lint: ## Run code linting and style checks
|
||||||
|
@echo "Checking Python files..."
|
||||||
|
@python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true
|
||||||
|
@echo "Checking JavaScript files..."
|
||||||
|
@echo "Note: Consider adding ESLint for JavaScript linting"
|
||||||
|
|
||||||
|
format: ## Format Python code (requires black)
|
||||||
|
@echo "Formatting Python files..."
|
||||||
|
@python -m black . --check || echo "Black not installed. Install with: pip install black"
|
||||||
|
|
||||||
|
format-fix: ## Format Python code and fix issues (requires black)
|
||||||
|
@echo "Formatting Python files..."
|
||||||
|
@python -m black . || echo "Black not installed. Install with: pip install black"
|
||||||
|
|
||||||
|
# ===== Utility =====
|
||||||
|
clean-all: clean ## Clean all generated files
|
||||||
|
@echo "Cleaning Python cache..."
|
||||||
|
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
@echo "Cleaning test cache..."
|
||||||
|
@rm -rf .pytest_cache/ || true
|
||||||
|
|
||||||
|
status: ## Show current project status
|
||||||
|
@echo "=== Project Status ==="
|
||||||
|
@echo "Python version: $(shell python --version)"
|
||||||
|
@echo "Pip packages: $(shell pip list | grep -E "(pytest|selenium)" || echo "Test dependencies not installed")"
|
||||||
|
@echo "Local server: $(shell pgrep -f 'python dev_server.py' >/dev/null && echo 'Running' || echo 'Not running')"
|
||||||
|
@echo "Terraform: $(shell cd infra && terraform version 2>/dev/null | head -1 || echo 'Not initialized')"
|
||||||
|
|
||||||
|
# ===== Quick Development Workflow =====
|
||||||
|
dev-test: dev test ## Start dev server and run tests (in separate terminals)
|
||||||
|
@echo "Development server started. Run 'make test' in another terminal to run tests."
|
||||||
|
|
||||||
|
quick-test: ## Quick test run (assumes server is running)
|
||||||
|
pytest tests/ -v -x --tb=short
|
||||||
|
|
||||||
|
# ===== Documentation =====
|
||||||
|
docs: ## Generate documentation (placeholder)
|
||||||
|
@echo "Documentation generation not yet implemented"
|
||||||
|
@echo "Consider adding Sphinx or similar for documentation"
|
||||||
|
|
||||||
|
help-dev: ## Show development-specific help
|
||||||
|
@echo "=== Development Commands ==="
|
||||||
|
@echo "make dev - Start local development server"
|
||||||
|
@echo "make test - Run tests (requires server running)"
|
||||||
|
@echo "make test-local - Start server, run tests, stop server"
|
||||||
|
@echo "make install-deps - Install test dependencies"
|
||||||
|
@echo "make lint - Run code quality checks"
|
||||||
|
@echo "make format - Check code formatting"
|
||||||
|
@echo "make status - Show project status"
|
24
dev_server.py
Executable file
24
dev_server.py
Executable file
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import http.server, socketserver, os, pathlib
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).parent / 'public'
|
||||||
|
PORT = int(os.environ.get('PORT', '8008'))
|
||||||
|
|
||||||
|
class SPAHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
path_only = self.path.split('?',1)[0].split('#',1)[0]
|
||||||
|
fs_path = self.translate_path(path_only)
|
||||||
|
if os.path.exists(fs_path) and os.path.isfile(fs_path):
|
||||||
|
return super().do_GET()
|
||||||
|
self.path = '/index.html'
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
os.chdir(ROOT) # serve from /public
|
||||||
|
with socketserver.TCPServer(('127.0.0.1', PORT), SPAHandler) as httpd:
|
||||||
|
print(f"Serving {ROOT} at http://127.0.0.1:{PORT}")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
45
infra/backend.tf
Normal file
45
infra/backend.tf
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# Backend infrastructure for Terraform state management
|
||||||
|
# This should be run first, before the main configuration
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "terraform_state" {
|
||||||
|
bucket = "calculator-127local-net-terraform-state"
|
||||||
|
tags = var.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_dynamodb_table" "terraform_state_lock" {
|
||||||
|
name = "terraform-state-lock"
|
||||||
|
billing_mode = "PAY_PER_REQUEST"
|
||||||
|
hash_key = "LockID"
|
||||||
|
|
||||||
|
attribute {
|
||||||
|
name = "LockID"
|
||||||
|
type = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = var.tags
|
||||||
|
}
|
171
infra/main.tf
Normal file
171
infra/main.tf
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
locals {
|
||||||
|
origin_id = "s3-calculator-origin"
|
||||||
|
bucket_name = replace(var.primary_domain, ".", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "site" {
|
||||||
|
bucket = local.bucket_name
|
||||||
|
tags = var.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
rule { object_ownership = "BucketOwnerPreferred" }
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "site" {
|
||||||
|
bucket = aws_s3_bucket.site.id
|
||||||
|
policy = data.aws_iam_policy_document.s3_policy.json
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "s3_policy" {
|
||||||
|
statement {
|
||||||
|
sid = "AllowCloudFrontRead"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:GetObject"]
|
||||||
|
resources = ["${aws_s3_bucket.site.arn}/*"]
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["cloudfront.amazonaws.com"]
|
||||||
|
}
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "AWS:SourceArn"
|
||||||
|
values = [aws_cloudfront_distribution.site.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# CloudFront OAC (origin access control)
|
||||||
|
resource "aws_cloudfront_origin_access_control" "oac" {
|
||||||
|
name = "${local.bucket_name}-oac"
|
||||||
|
description = "OAC for ${local.bucket_name}"
|
||||||
|
origin_access_control_origin_type = "s3"
|
||||||
|
signing_behavior = "always"
|
||||||
|
signing_protocol = "sigv4"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ACM certificate in us-east-1 for both domains
|
||||||
|
resource "aws_acm_certificate" "cert" {
|
||||||
|
provider = aws.us_east_1
|
||||||
|
domain_name = var.primary_domain
|
||||||
|
subject_alternative_names = [var.secondary_domain]
|
||||||
|
validation_method = "DNS"
|
||||||
|
tags = var.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route53_record" "cert_validation" {
|
||||||
|
for_each = { for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
|
||||||
|
name = dvo.resource_record_name
|
||||||
|
type = dvo.resource_record_type
|
||||||
|
value = dvo.resource_record_value
|
||||||
|
} }
|
||||||
|
zone_id = data.aws_route53_zone.main.zone_id
|
||||||
|
name = each.value.name
|
||||||
|
type = each.value.type
|
||||||
|
ttl = 60
|
||||||
|
records = [each.value.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_acm_certificate_validation" "cert" {
|
||||||
|
provider = aws.us_east_1
|
||||||
|
certificate_arn = aws_acm_certificate.cert.arn
|
||||||
|
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the hosted zone
|
||||||
|
data "aws_route53_zone" "main" {
|
||||||
|
name = var.hosted_zone
|
||||||
|
}
|
||||||
|
|
||||||
|
# CloudFront distribution
|
||||||
|
resource "aws_cloudfront_distribution" "site" {
|
||||||
|
enabled = true
|
||||||
|
is_ipv6_enabled = true
|
||||||
|
comment = "Calculator site for ${var.primary_domain} and ${var.secondary_domain}"
|
||||||
|
default_root_object = "index.html"
|
||||||
|
aliases = [var.primary_domain, var.secondary_domain]
|
||||||
|
|
||||||
|
origin {
|
||||||
|
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
|
||||||
|
origin_id = local.origin_id
|
||||||
|
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
|
||||||
|
}
|
||||||
|
|
||||||
|
default_cache_behavior {
|
||||||
|
allowed_methods = ["GET", "HEAD"]
|
||||||
|
cached_methods = ["GET", "HEAD"]
|
||||||
|
target_origin_id = local.origin_id
|
||||||
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
compress = true
|
||||||
|
min_ttl = 0
|
||||||
|
default_ttl = 3600
|
||||||
|
max_ttl = 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions {
|
||||||
|
geo_restriction {
|
||||||
|
restriction_type = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_certificate {
|
||||||
|
acm_certificate_arn = aws_acm_certificate_validation.cert.certificate_arn
|
||||||
|
ssl_support_method = "sni-only"
|
||||||
|
minimum_protocol_version = "TLSv1.2_2021"
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 403
|
||||||
|
response_code = 200
|
||||||
|
response_page_path = "/index.html"
|
||||||
|
}
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 404
|
||||||
|
response_code = 200
|
||||||
|
response_page_path = "/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = var.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route53 alias for primary domain
|
||||||
|
resource "aws_route53_record" "primary_alias" {
|
||||||
|
zone_id = data.aws_route53_zone.main.zone_id
|
||||||
|
name = var.primary_domain
|
||||||
|
type = "A"
|
||||||
|
alias {
|
||||||
|
name = aws_cloudfront_distribution.site.domain_name
|
||||||
|
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||||
|
evaluate_target_health = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route53 alias for secondary domain
|
||||||
|
resource "aws_route53_record" "secondary_alias" {
|
||||||
|
zone_id = data.aws_route53_zone.main.zone_id
|
||||||
|
name = var.secondary_domain
|
||||||
|
type = "A"
|
||||||
|
alias {
|
||||||
|
name = aws_cloudfront_distribution.site.domain_name
|
||||||
|
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
|
||||||
|
evaluate_target_health = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "bucket_name" { value = aws_s3_bucket.site.bucket }
|
||||||
|
output "distribution_id" { value = aws_cloudfront_distribution.site.id }
|
||||||
|
output "primary_domain" { value = var.primary_domain }
|
||||||
|
output "secondary_domain" { value = var.secondary_domain }
|
||||||
|
output "cloudfront_domain" { value = aws_cloudfront_distribution.site.domain_name }
|
||||||
|
|
4
infra/terraform.tfvars
Normal file
4
infra/terraform.tfvars
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
primary_domain = "calculator.127local.net"
|
||||||
|
secondary_domain = "calc.127local.net"
|
||||||
|
hosted_zone = "127local.net"
|
||||||
|
aws_region = "us-west-2"
|
24
infra/variables.tf
Normal file
24
infra/variables.tf
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
variable "primary_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Primary domain name (e.g., calculator.127local.net)"
|
||||||
|
}
|
||||||
|
variable "secondary_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Secondary domain name (e.g., calc.127local.net)"
|
||||||
|
}
|
||||||
|
variable "hosted_zone" {
|
||||||
|
type = string
|
||||||
|
description = "Hosted zone name (e.g., 127local.net)"
|
||||||
|
}
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
default = "us-west-2"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "tags" {
|
||||||
|
type = map(string)
|
||||||
|
default = {
|
||||||
|
Application = "calculator.127local"
|
||||||
|
Project = "calc-127local-net"
|
||||||
|
}
|
||||||
|
}
|
28
infra/versions.tf
Normal file
28
infra/versions.tf
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.7.0"
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = ">= 5.55"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# S3 backend configuration - uncomment after backend infrastructure is created
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "calculator-127local-net-terraform-state"
|
||||||
|
key = "terraform.tfstate"
|
||||||
|
region = "us-west-2"
|
||||||
|
dynamodb_table = "terraform-state-lock"
|
||||||
|
encrypt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
}
|
||||||
|
|
||||||
|
# ACM cert for CloudFront must be in us-east-1
|
||||||
|
provider "aws" {
|
||||||
|
alias = "us_east_1"
|
||||||
|
region = "us-east-1"
|
||||||
|
}
|
21
public/calculators/bandwidth.js
Normal file
21
public/calculators/bandwidth.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js';
|
||||||
|
export default {
|
||||||
|
id:'bandwidth', name:'Bandwidth / Bytes Converter', about:'Convert bits↔bytes and SI↔IEC units',
|
||||||
|
render(root){
|
||||||
|
const key='calc_bandwidth_v1';
|
||||||
|
const s = revive(key,{value:100, unit:'Mbps'});
|
||||||
|
const units = ['bps','Kbps','Mbps','Gbps','Tbps','B/s','KB/s','MB/s','GB/s','TB/s','KiB/s','MiB/s','GiB/s','TiB/s'];
|
||||||
|
const factors = {'bps':1,'Kbps':1e3,'Mbps':1e6,'Gbps':1e9,'Tbps':1e12,'B/s':8,'KB/s':8e3,'MB/s':8e6,'GB/s':8e9,'TB/s':8e12,'KiB/s':8*1024,'MiB/s':8*1024**2,'GiB/s':8*1024**3,'TiB/s':8*1024**4};
|
||||||
|
const ui = document.createElement('div');
|
||||||
|
ui.append(labelInput('Value','number','value', s.value,{step:'0.000001',min:'0'}), labelSelect('Unit','unit', s.unit, units.map(u=>[u,u])));
|
||||||
|
const out = document.createElement('div'); out.className='result'; ui.append(out);
|
||||||
|
function calc(){
|
||||||
|
const v = +ui.querySelector('[name=value]').value||0;
|
||||||
|
const unit = ui.querySelector('[name=unit]').value;
|
||||||
|
const bps = v * factors[unit];
|
||||||
|
out.innerHTML = units.map(u=>`<div><strong>${u}</strong>: ${fmt.format(bps / factors[u])}</div>`).join('');
|
||||||
|
persist(key,{value:v, unit});
|
||||||
|
}
|
||||||
|
ui.addEventListener('input', calc); calc(); root.append(ui);
|
||||||
|
}
|
||||||
|
}
|
399
public/calculators/currency.js
Normal file
399
public/calculators/currency.js
Normal file
|
@ -0,0 +1,399 @@
|
||||||
|
import {revive, persist, labelInput, labelSelect} from '/js/util.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id:'currency', name:'Currency Converter', about:'Convert between currencies using real-time exchange rates.',
|
||||||
|
render(root){
|
||||||
|
const key='calc_currency_v1';
|
||||||
|
const s = revive(key,{amount:100, from:'USD', to:'EUR', manualRate: null});
|
||||||
|
const ui = document.createElement('div');
|
||||||
|
|
||||||
|
// Popular currencies with better formatting
|
||||||
|
const currencies = [
|
||||||
|
['USD', 'US Dollar'],
|
||||||
|
['EUR', 'Euro'],
|
||||||
|
['GBP', 'British Pound'],
|
||||||
|
['JPY', 'Japanese Yen'],
|
||||||
|
['CAD', 'Canadian Dollar'],
|
||||||
|
['AUD', 'Australian Dollar'],
|
||||||
|
['CHF', 'Swiss Franc'],
|
||||||
|
['CNY', 'Chinese Yuan'],
|
||||||
|
['INR', 'Indian Rupee'],
|
||||||
|
['BRL', 'Brazilian Real']
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a more user-friendly form layout
|
||||||
|
const formContainer = document.createElement('div');
|
||||||
|
formContainer.style.cssText = `
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Amount input (full width)
|
||||||
|
const amountDiv = document.createElement('div');
|
||||||
|
amountDiv.style.gridColumn = '1 / -1';
|
||||||
|
amountDiv.innerHTML = `
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
|
||||||
|
Amount to Convert
|
||||||
|
</label>
|
||||||
|
<input type="number" name="amount" value="${s.amount}" step="0.01" min="0"
|
||||||
|
style="width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 16px;"
|
||||||
|
placeholder="Enter amount">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// From currency (left column) - using Select Lite
|
||||||
|
const fromDiv = document.createElement('div');
|
||||||
|
fromDiv.innerHTML = `
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
|
||||||
|
From Currency
|
||||||
|
</label>
|
||||||
|
<select name="from" data-ui="lite" style="width: 100%;">
|
||||||
|
${currencies.map(([code, name]) =>
|
||||||
|
`<option value="${code}" ${code === s.from ? 'selected' : ''}>${code} - ${name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// To currency (right column) - using Select Lite
|
||||||
|
const toDiv = document.createElement('div');
|
||||||
|
toDiv.innerHTML = `
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
|
||||||
|
To Currency
|
||||||
|
</label>
|
||||||
|
<select name="to" data-ui="lite" style="width: 100%;">
|
||||||
|
${currencies.map(([code, name]) =>
|
||||||
|
`<option value="${code}" ${code === s.to ? 'selected' : ''}>${code} - ${name}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
|
||||||
|
formContainer.append(amountDiv, fromDiv, toDiv);
|
||||||
|
ui.append(formContainer);
|
||||||
|
|
||||||
|
// Manual rate input option
|
||||||
|
const manualRateDiv = document.createElement('div');
|
||||||
|
manualRateDiv.innerHTML = `
|
||||||
|
<div style="margin: 15px 0; padding: 15px; border: 1px solid var(--border); border-radius: 8px; background: var(--k-bg);">
|
||||||
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
|
||||||
|
Custom Rate (Optional)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="manualRate" placeholder="Enter custom conversion rate" step="0.0001" min="0"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px;">
|
||||||
|
<div style="font-size: 12px; color: var(--muted); margin-top: 8px; line-height: 1.4;">
|
||||||
|
<strong>Format:</strong> 1 ${s.from} = X ${s.to}<br>
|
||||||
|
<strong>Example:</strong> If 1 USD = 0.85 EUR, enter 0.85<br>
|
||||||
|
<strong>Leave empty</strong> to use current market rates
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
ui.append(manualRateDiv);
|
||||||
|
|
||||||
|
// Fetch rates button with better styling
|
||||||
|
const fetchBtn = document.createElement('button');
|
||||||
|
fetchBtn.type = 'button';
|
||||||
|
fetchBtn.textContent = 'Update Exchange Rates';
|
||||||
|
fetchBtn.className = 'btn';
|
||||||
|
fetchBtn.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 15px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
`;
|
||||||
|
fetchBtn.addEventListener('mouseenter', () => fetchBtn.style.background = 'var(--accent-hover)');
|
||||||
|
fetchBtn.addEventListener('mouseleave', () => fetchBtn.style.background = 'var(--accent)');
|
||||||
|
ui.append(fetchBtn);
|
||||||
|
|
||||||
|
const out = document.createElement('div');
|
||||||
|
out.className = 'result';
|
||||||
|
out.style.cssText = `
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: var(--k-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
`;
|
||||||
|
ui.append(out);
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className='status';
|
||||||
|
ui.append(status);
|
||||||
|
|
||||||
|
// Static rates from today (2025-09-01) - no prefetching
|
||||||
|
const staticRates = {
|
||||||
|
USD: 1,
|
||||||
|
EUR: 0.856,
|
||||||
|
GBP: 0.741,
|
||||||
|
JPY: 147.15,
|
||||||
|
CAD: 1.37,
|
||||||
|
AUD: 1.53,
|
||||||
|
CHF: 0.801,
|
||||||
|
CNY: 7.13,
|
||||||
|
INR: 88.19,
|
||||||
|
BRL: 5.43
|
||||||
|
};
|
||||||
|
|
||||||
|
let rates = staticRates;
|
||||||
|
let lastFetch = 0;
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Function to get current static rate for currency pair
|
||||||
|
function getCurrentStaticRate(from, to) {
|
||||||
|
if (from === to) return 1;
|
||||||
|
if (rates && rates[from] && rates[to]) {
|
||||||
|
return rates[to] / rates[from];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update custom rate with current static rate
|
||||||
|
function updateCustomRateWithStatic() {
|
||||||
|
const from = ui.querySelector('[name=from]').value;
|
||||||
|
const to = ui.querySelector('[name=to]').value;
|
||||||
|
const staticRate = getCurrentStaticRate(from, to);
|
||||||
|
|
||||||
|
if (staticRate !== null) {
|
||||||
|
const manualRateInput = ui.querySelector('[name=manualRate]');
|
||||||
|
// Update manual rate to current market rate when fetching new rates
|
||||||
|
manualRateInput.value = staticRate.toFixed(4);
|
||||||
|
manualRateInput.placeholder = `Current rate: ${staticRate.toFixed(4)}`;
|
||||||
|
// Trigger calculation with the new rates
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRates(){
|
||||||
|
const now = Date.now();
|
||||||
|
if (rates && (now - lastFetch) < CACHE_DURATION) {
|
||||||
|
status.textContent = 'Using cached rates (updated within 5 minutes)';
|
||||||
|
status.className = 'status success';
|
||||||
|
status.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
status.textContent = '';
|
||||||
|
status.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
return rates;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
status.textContent = 'Fetching latest exchange rates...';
|
||||||
|
status.className = 'status loading';
|
||||||
|
|
||||||
|
const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch rates');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
rates = data.rates;
|
||||||
|
rates.USD = 1; // Base currency
|
||||||
|
lastFetch = now;
|
||||||
|
|
||||||
|
status.textContent = 'Rates updated successfully!';
|
||||||
|
status.className = 'status success';
|
||||||
|
status.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
status.textContent = '';
|
||||||
|
status.style.display = 'none';
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// Update custom rate with new rates and recalculate
|
||||||
|
updateCustomRateWithStatic();
|
||||||
|
|
||||||
|
return rates;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch exchange rates:', error);
|
||||||
|
status.textContent = 'Failed to fetch rates. Using static rates from today.';
|
||||||
|
status.className = 'status error';
|
||||||
|
// Keep using static rates
|
||||||
|
return rates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc(){
|
||||||
|
const amount = +ui.querySelector('[name=amount]').value || 0;
|
||||||
|
const from = ui.querySelector('[name=from]').value;
|
||||||
|
const to = ui.querySelector('[name=to]').value;
|
||||||
|
const manualRate = +ui.querySelector('[name=manualRate]').value || null;
|
||||||
|
|
||||||
|
console.log('Calc function called with:', { amount, from, to, manualRate });
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
out.innerHTML = '<div class="muted">Enter a positive amount to convert</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from === to) {
|
||||||
|
out.innerHTML = `
|
||||||
|
<div style="font-size: 18px; font-weight: 600; color: var(--text);">
|
||||||
|
${amount.toFixed(2)} ${from}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--muted); margin-top: 5px;">
|
||||||
|
Same currency - no conversion needed
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversionRate;
|
||||||
|
let rateSource;
|
||||||
|
let rateDate;
|
||||||
|
|
||||||
|
if (manualRate && manualRate > 0) {
|
||||||
|
console.log('Using manual rate:', manualRate);
|
||||||
|
// Use manual rate
|
||||||
|
conversionRate = manualRate;
|
||||||
|
|
||||||
|
// Determine if this is user-entered or default custom rate
|
||||||
|
const staticRate = staticRates[to] / staticRates[from];
|
||||||
|
const isUserEntered = Math.abs(manualRate - staticRate) > 0.0001; // Allow for floating point precision
|
||||||
|
|
||||||
|
rateSource = isUserEntered ? 'Custom rate' : 'Static rates';
|
||||||
|
rateDate = isUserEntered ? 'Manual input' : '2025-09-01';
|
||||||
|
|
||||||
|
out.innerHTML = `
|
||||||
|
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
|
||||||
|
${(amount * conversionRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--text); margin-bottom: 5px;">
|
||||||
|
<strong>Conversion:</strong> ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--muted); font-size: 14px;">
|
||||||
|
<strong>Rate:</strong> 1 ${from} = ${conversionRate.toFixed(4)} ${to}<br>
|
||||||
|
<strong>Source:</strong> ${rateSource}<br>
|
||||||
|
<strong>Date:</strong> ${rateDate}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (rates) {
|
||||||
|
console.log('Using market rates, manual rate was:', manualRate);
|
||||||
|
// Use fetched or static rates (fallback when no manual rate)
|
||||||
|
conversionRate = rates[to] / rates[from];
|
||||||
|
rateSource = lastFetch > 0 ? 'Live rates' : 'Static rates';
|
||||||
|
rateDate = lastFetch > 0 ? new Date(lastFetch).toLocaleString() : '2025-09-01';
|
||||||
|
|
||||||
|
out.innerHTML = `
|
||||||
|
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
|
||||||
|
${(amount * conversionRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--text); margin-bottom: 5px;">
|
||||||
|
<strong>Conversion:</strong> ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--muted); font-size: 14px;">
|
||||||
|
<strong>Rate:</strong> 1 ${from} = ${conversionRate.toFixed(4)} ${to}<br>
|
||||||
|
<strong>Rate:</strong> 1 ${to} = ${(1/conversionRate).toFixed(4)} ${from}<br>
|
||||||
|
<strong>Source:</strong> ${rateSource}<br>
|
||||||
|
<strong>Rates accurate as of:</strong> ${rateDate}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
out.innerHTML = '<div class="muted">Unable to calculate conversion</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persist(key, {amount, from, to, manualRate});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual rate input handler
|
||||||
|
const manualRateInput = manualRateDiv.querySelector('[name="manualRate"]');
|
||||||
|
manualRateInput.value = s.manualRate || '';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Setting up manual rate input handler');
|
||||||
|
|
||||||
|
// Set up the event listener
|
||||||
|
manualRateInput.addEventListener('input', (e) => {
|
||||||
|
console.log('Manual rate input event fired:', e.target.value);
|
||||||
|
|
||||||
|
// Clear any existing status messages
|
||||||
|
status.textContent = '';
|
||||||
|
status.style.display = 'none';
|
||||||
|
|
||||||
|
// Update the stored manual rate
|
||||||
|
s.manualRate = +e.target.value || null;
|
||||||
|
console.log('Updated manual rate to:', s.manualRate);
|
||||||
|
|
||||||
|
// Simple test - just update the display immediately
|
||||||
|
if (s.manualRate && s.manualRate > 0) {
|
||||||
|
const amount = +ui.querySelector('[name=amount]').value || 0;
|
||||||
|
const from = ui.querySelector('[name=from]').value;
|
||||||
|
const to = ui.querySelector('[name=to]').value;
|
||||||
|
|
||||||
|
console.log('Immediate calculation test:', { amount, from, to, manualRate: s.manualRate });
|
||||||
|
|
||||||
|
// Determine if this is user-entered or default custom rate
|
||||||
|
const staticRate = staticRates[to] / staticRates[from];
|
||||||
|
const isUserEntered = Math.abs(s.manualRate - staticRate) > 0.0001; // Allow for floating point precision
|
||||||
|
|
||||||
|
const rateSource = isUserEntered ? 'Custom rate' : 'Static rates';
|
||||||
|
|
||||||
|
// Show immediate result
|
||||||
|
out.innerHTML = `
|
||||||
|
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
|
||||||
|
${(amount * s.manualRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--text); margin-bottom: 5px;">
|
||||||
|
<strong>Conversion:</strong> ${amount} ${from} × ${s.manualRate.toFixed(4)} = ${(amount * s.manualRate).toFixed(2)} ${to}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--muted); font-size: 14px;">
|
||||||
|
<strong>Rate:</strong> 1 ${from} = ${s.manualRate.toFixed(4)} ${to}<br>
|
||||||
|
<strong>Source:</strong> ${rateSource}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Fall back to normal calculation
|
||||||
|
calc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also add change event as backup
|
||||||
|
manualRateInput.addEventListener('change', (e) => {
|
||||||
|
console.log('Manual rate change event fired:', e.target.value);
|
||||||
|
s.manualRate = +e.target.value || null;
|
||||||
|
calc();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Currency change handlers
|
||||||
|
const fromSelect = fromDiv.querySelector('[name=from]');
|
||||||
|
const toSelect = toDiv.querySelector('[name=to]');
|
||||||
|
|
||||||
|
fromSelect.addEventListener('change', () => {
|
||||||
|
s.from = fromSelect.value;
|
||||||
|
// Update manual rate description and custom rate
|
||||||
|
const desc = manualRateDiv.querySelector('div');
|
||||||
|
desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`);
|
||||||
|
updateCustomRateWithStatic();
|
||||||
|
calc();
|
||||||
|
});
|
||||||
|
|
||||||
|
toSelect.addEventListener('change', () => {
|
||||||
|
s.to = toSelect.value;
|
||||||
|
// Update manual rate description and custom rate
|
||||||
|
const desc = manualRateDiv.querySelector('div');
|
||||||
|
desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`);
|
||||||
|
updateCustomRateWithStatic();
|
||||||
|
calc();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Amount input handler
|
||||||
|
const amountInput = amountDiv.querySelector('[name="amount"]');
|
||||||
|
amountInput.addEventListener('input', calc);
|
||||||
|
|
||||||
|
// Fetch rates button click handler
|
||||||
|
fetchBtn.addEventListener('click', async () => {
|
||||||
|
await fetchRates();
|
||||||
|
calc(); // Recalculate with new rates
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize custom rate with current static rate
|
||||||
|
updateCustomRateWithStatic();
|
||||||
|
|
||||||
|
// Initial calculation
|
||||||
|
calc();
|
||||||
|
root.append(ui);
|
||||||
|
}
|
||||||
|
}
|
50
public/calculators/interest.js
Normal file
50
public/calculators/interest.js
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import {currency, revive, persist, labelInput, labelSelect} from '/js/util.js';
|
||||||
|
export default {
|
||||||
|
id:'interest', name:'Interest (Simple & Compound)', about:'Compute simple or compound interest with flexible compounding and contributions.',
|
||||||
|
render(root){
|
||||||
|
const key='calc_interest_v1';
|
||||||
|
const s = revive(key,{principal:1000, rate:5, years:3, compound:'12', contrib:0, contribFreq:'12'});
|
||||||
|
const ui = document.createElement('div');
|
||||||
|
ui.append(
|
||||||
|
labelInput('Principal','number','principal', s.principal,{step:'0.01',min:'0'}),
|
||||||
|
labelInput('Annual rate (%)','number','rate', s.rate,{step:'0.0001',min:'0'}),
|
||||||
|
labelSelect('Compounding','compound', s.compound, [['1','Yearly'],['4','Quarterly'],['12','Monthly'],['365','Daily'],['0','Simple (no compounding)']]),
|
||||||
|
labelInput('Years','number','years', s.years,{step:'0.1',min:'0'}),
|
||||||
|
labelInput('Recurring contribution (per period below)','number','contrib', s.contrib,{step:'0.01',min:'0'}),
|
||||||
|
labelSelect('Contribution frequency','contribFreq', s.contribFreq, [['1','Yearly'],['4','Quarterly'],['12','Monthly']])
|
||||||
|
);
|
||||||
|
const out = document.createElement('div'); out.className='result'; ui.append(out);
|
||||||
|
|
||||||
|
function calc(){
|
||||||
|
const P = +ui.querySelector('[name=principal]').value||0;
|
||||||
|
const r = (+ui.querySelector('[name=rate]').value||0)/100;
|
||||||
|
const years = +ui.querySelector('[name=years]').value||0;
|
||||||
|
const n = +ui.querySelector('[name=compound]').value; // 0 => simple
|
||||||
|
const A = +ui.querySelector('[name=contrib]').value||0;
|
||||||
|
const f = +ui.querySelector('[name=contribFreq]').value||1;
|
||||||
|
|
||||||
|
let future=0, interest=0;
|
||||||
|
if(n===0){
|
||||||
|
interest = P * r * years;
|
||||||
|
const contribs = A * f * years;
|
||||||
|
future = P + interest + contribs;
|
||||||
|
}else{
|
||||||
|
const periods = n * years;
|
||||||
|
const i = r / n;
|
||||||
|
future = P * Math.pow(1+i, periods);
|
||||||
|
if(A>0){
|
||||||
|
const eff = Math.pow(1+i, n/f) - 1;
|
||||||
|
const m = Math.round(periods * f / n);
|
||||||
|
future += A * ((Math.pow(1+eff, m) - 1) / eff);
|
||||||
|
}
|
||||||
|
interest = future - P - (A>0?A*Math.round(n*years * f / n):0);
|
||||||
|
}
|
||||||
|
out.innerHTML = `
|
||||||
|
<div><strong>Future value:</strong> ${currency(future)}</div>
|
||||||
|
<div class="muted">Estimated interest earned: ${currency(Math.max(0,interest))}</div>
|
||||||
|
`;
|
||||||
|
persist(key,{principal:P, rate:r*100, years, compound:String(n), contrib:A, contribFreq:String(f)});
|
||||||
|
}
|
||||||
|
ui.addEventListener('input', calc); calc(); root.append(ui);
|
||||||
|
}
|
||||||
|
}
|
22
public/calculators/nmea.js
Normal file
22
public/calculators/nmea.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import {revive, persist, labelInput} from '/js/util.js';
|
||||||
|
export default {
|
||||||
|
id:'nmea', name:'NMEA 0183 Checksum', about:'Paste without the leading $ and without *XX.',
|
||||||
|
render(root){
|
||||||
|
const key='calc_nmea_v1';
|
||||||
|
const s = revive(key,{sentence:'GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,', expected:''});
|
||||||
|
const ui = document.createElement('div');
|
||||||
|
ui.append(labelInput('Sentence (no $ and no *XX)','text','sentence', s.sentence), labelInput('Expected checksum (optional, hex)','text','expected', s.expected,{placeholder:'e.g. 47'}));
|
||||||
|
const out = document.createElement('div'); out.className='result'; ui.append(out);
|
||||||
|
function checksum(str){ let x=0; for(const ch of str) x ^= ch.charCodeAt(0); return x; }
|
||||||
|
function calc(){
|
||||||
|
const sentence = ui.querySelector('[name=sentence]').value.trim();
|
||||||
|
const exp = ui.querySelector('[name=expected]').value.trim();
|
||||||
|
const sum = checksum(sentence);
|
||||||
|
const hex = sum.toString(16).toUpperCase().padStart(2,'0');
|
||||||
|
let html = `<div><strong>Computed:</strong> <code class="k">*${hex}</code></div>`;
|
||||||
|
if(exp){ const ok = hex === exp.toUpperCase(); html += `<div>${ok ? 'Match' : 'Mismatch'} (expected <code class="k">*${exp.toUpperCase()}</code>)</div>`; }
|
||||||
|
out.innerHTML = html; persist(key,{sentence, expected:exp});
|
||||||
|
}
|
||||||
|
ui.addEventListener('input', calc); calc(); root.append(ui);
|
||||||
|
}
|
||||||
|
}
|
32
public/calculators/raid.js
Normal file
32
public/calculators/raid.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js';
|
||||||
|
export default {
|
||||||
|
id:'raid', name:'RAID Usable Capacity', about:'Estimates only; assumes identical drives. Controller/FS overhead not included.',
|
||||||
|
render(root){
|
||||||
|
const key='calc_raid_v1';
|
||||||
|
const s = revive(key,{level:'5', drives:6, size:18});
|
||||||
|
const ui = document.createElement('div');
|
||||||
|
ui.append(
|
||||||
|
labelSelect('RAID level','level', s.level, [['0','RAID 0 (stripe)'],['1','RAID 1 (mirror)'],['5','RAID 5 (1 disk parity)'],['6','RAID 6 (2 disk parity)'],['10','RAID 10 (mirror+stripe)']]),
|
||||||
|
labelInput('Number of drives','number','drives', s.drives,{min:'1',step:'1'}),
|
||||||
|
labelInput('Drive size (TB, decimal)','number','size', s.size,{min:'0',step:'0.01'})
|
||||||
|
);
|
||||||
|
const out = document.createElement('div'); out.className='result'; ui.append(out);
|
||||||
|
|
||||||
|
function calc(){
|
||||||
|
const L = ui.querySelector('[name=level]').value;
|
||||||
|
const N = Math.max(1, parseInt(ui.querySelector('[name=drives]').value||0,10));
|
||||||
|
const S = (+ui.querySelector('[name=size]').value||0) * 1e12;
|
||||||
|
let usable=0, ft='';
|
||||||
|
if(L==='0'){ usable = N*S; ft='0 disk'; }
|
||||||
|
else if(L==='1'){ usable = Math.floor(N/2)*S; ft='can lose all but 1 in each mirror pair'; }
|
||||||
|
else if(L==='5'){ usable = (N-1)*S; ft='1 disk'; }
|
||||||
|
else if(L==='6'){ usable = (N-2)*S; ft='2 disks'; }
|
||||||
|
else if(L==='10'){ usable = Math.floor(N/2)*S; ft='1 disk per mirror pair'; }
|
||||||
|
const teb = usable / (1024**4);
|
||||||
|
const tb = usable / 1e12;
|
||||||
|
out.innerHTML = `<div><strong>Usable:</strong> ${fmt.format(tb)} TB · ${fmt.format(teb)} TiB</div><div class="muted">Fault tolerance: ${ft}</div>`;
|
||||||
|
persist(key,{level:L, drives:N, size:+ui.querySelector('[name=size]').value});
|
||||||
|
}
|
||||||
|
ui.addEventListener('input', calc); calc(); root.append(ui);
|
||||||
|
}
|
||||||
|
}
|
207
public/css/styles.css
Normal file
207
public/css/styles.css
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
/* ---- Color System ---- */
|
||||||
|
:root{
|
||||||
|
/* light defaults */
|
||||||
|
--bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473;
|
||||||
|
--border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed;
|
||||||
|
--k-bg:#f1f5ff; --k-border:#cdd9ff;
|
||||||
|
--br:16px; --gap:14px; --shadow:0 6px 20px rgba(0,0,0,.08); --max:1100px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root{
|
||||||
|
--bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2;
|
||||||
|
--border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa;
|
||||||
|
--k-bg:rgba(125,211,252,.06); --k-border:#2a3357;
|
||||||
|
--shadow:0 8px 30px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* explicit overrides win over system */
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473;
|
||||||
|
--border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed;
|
||||||
|
--k-bg:#f1f5ff; --k-border:#cdd9ff; --shadow:0 6px 20px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2;
|
||||||
|
--border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa;
|
||||||
|
--k-bg:rgba(125,211,252,.06); --k-border:#2a3357;
|
||||||
|
--shadow:0 8px 30px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
|
input, select, textarea { color-scheme: inherit; }
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
select{
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 2.25rem 10px 10px; /* room for arrow */
|
||||||
|
background-image: var(--select-arrow);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 12px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus{
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent2);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled{ opacity:.55; cursor:not-allowed; }
|
||||||
|
|
||||||
|
/* ---- Base ---- */
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,sans-serif}
|
||||||
|
.wrap{max-width:var(--max);margin:0 auto;padding:16px}
|
||||||
|
|
||||||
|
.bar{position:sticky;top:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(0,0,0,0));backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10}
|
||||||
|
.bar__inner{display:flex;align-items:center;gap:12px;justify-content:space-between}
|
||||||
|
.brand{font-weight:700}
|
||||||
|
|
||||||
|
.btn{background:transparent;border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:999px;cursor:pointer}
|
||||||
|
|
||||||
|
/* ---- Layout ---- */
|
||||||
|
.layout{display:grid;grid-template-columns:240px 1fr;gap:16px}
|
||||||
|
@media (max-width: 820px){
|
||||||
|
.layout{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Vertical nav ---- */
|
||||||
|
.sidenav{border:1px solid var(--border);border-radius:var(--br);padding:10px;background:var(--card);box-shadow:var(--shadow);position:sticky;top:70px;height:fit-content}
|
||||||
|
.sidenav .navlist{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
|
||||||
|
.sidenav .navlist button{width:100%;text-align:left;background:transparent;border:1px solid var(--border);color:var(--text);padding:10px;border-radius:10px;cursor:pointer}
|
||||||
|
.sidenav .navlist button[aria-current="page"]{border-color:var(--accent2);box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset}
|
||||||
|
|
||||||
|
.sidenav .navlist a{
|
||||||
|
display:block; text-decoration:none;
|
||||||
|
background:transparent; border:1px solid var(--border);
|
||||||
|
color:var(--text); padding:10px; border-radius:10px;
|
||||||
|
}
|
||||||
|
.sidenav .navlist a[aria-current="page"]{
|
||||||
|
border-color:var(--accent2);
|
||||||
|
box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select Lite (themed, accessible, tiny) */
|
||||||
|
.select-lite{ position:relative; }
|
||||||
|
.select-lite > select{
|
||||||
|
position:absolute; inset:auto auto auto auto; /* keep in DOM but invisible */
|
||||||
|
opacity:0; pointer-events:none; width:0; height:0;
|
||||||
|
}
|
||||||
|
.select-lite__button{
|
||||||
|
width:100%;
|
||||||
|
text-align:left;
|
||||||
|
background:var(--card);
|
||||||
|
color:var(--text);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:10px;
|
||||||
|
padding:10px 2.25rem 10px 10px;
|
||||||
|
cursor:pointer;
|
||||||
|
background-image: var(--select-arrow, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E"));
|
||||||
|
background-repeat:no-repeat; background-position:right 10px center; background-size:12px auto;
|
||||||
|
}
|
||||||
|
.select-lite__button:focus{
|
||||||
|
outline:none;
|
||||||
|
border-color:var(--accent2);
|
||||||
|
box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent);
|
||||||
|
}
|
||||||
|
.select-lite__menu{
|
||||||
|
position:absolute; z-index:1000;
|
||||||
|
top:calc(100% + 6px); left:0; right:0;
|
||||||
|
background:var(--card);
|
||||||
|
color:var(--text);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:10px;
|
||||||
|
box-shadow:var(--shadow);
|
||||||
|
max-height:260px; overflow:auto; padding:6px;
|
||||||
|
}
|
||||||
|
.select-lite__option{
|
||||||
|
padding:8px 10px; border-radius:8px; outline:none;
|
||||||
|
}
|
||||||
|
.select-lite__option[aria-selected="true"],
|
||||||
|
.select-lite__option:hover,
|
||||||
|
.select-lite__option:focus{ background:var(--accent); color:white; }
|
||||||
|
|
||||||
|
.select-lite__option.search-match {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---- Main content ---- */
|
||||||
|
.content{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--gap)}
|
||||||
|
.card{background:var(--card);padding:14px;border-radius:var(--br);box-shadow:var(--shadow);border:1px solid var(--border)}
|
||||||
|
.card h2{margin:0 0 6px 0;font-size:18px}
|
||||||
|
.muted{color:var(--muted);font-size:14px}
|
||||||
|
label{display:block;margin-top:10px;margin-bottom:4px;color:var(--muted);font-size:14px}
|
||||||
|
input,select,textarea{width:100%;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:10px}
|
||||||
|
.result{margin-top:12px;padding:10px;border:1px dashed var(--k-border);border-radius:10px;background:var(--k-bg)}
|
||||||
|
.k{padding:2px 6px;border-radius:6px;border:1px solid var(--k-border);background:var(--k-bg)}
|
||||||
|
.foot{color:var(--muted);font-size:13px;margin-top:20px}
|
||||||
|
|
||||||
|
/* ---- Status indicators ---- */
|
||||||
|
.status {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.loading {
|
||||||
|
background-color: var(--k-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--k-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background-color: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme status adjustments */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.status.success {
|
||||||
|
background-color: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
border: 1px solid #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background-color: #450a0a;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .status.success {
|
||||||
|
background-color: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
border: 1px solid #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .status.error {
|
||||||
|
background-color: #450a0a;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
}
|
||||||
|
|
30
public/index.html
Normal file
30
public/index.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>calculator.127local.net</title>
|
||||||
|
<meta name="description" content="Framework-free calculators. All compute happens locally." />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<!-- Preload just the tiny core -->
|
||||||
|
<link rel="modulepreload" href="/js/app.js">
|
||||||
|
<base href="/">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bar">
|
||||||
|
<div class="wrap bar__inner">
|
||||||
|
<div class="brand">calculator.127local.net</div>
|
||||||
|
<button id="themeToggle" class="btn" aria-label="Toggle color scheme">Auto</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="wrap layout">
|
||||||
|
<aside id="nav" class="sidenav"></aside>
|
||||||
|
<main id="view" class="content"></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="wrap foot">No tracking. No server. Everything runs in your browser.</footer>
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
163
public/js/app.js
Normal file
163
public/js/app.js
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import {el, initTheme, enhanceSelects} from './util.js';
|
||||||
|
|
||||||
|
const CALCS = [
|
||||||
|
{ id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' },
|
||||||
|
{ id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' },
|
||||||
|
{ id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' },
|
||||||
|
{ id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' },
|
||||||
|
{ id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const navEl = document.getElementById('nav');
|
||||||
|
const viewEl = document.getElementById('view');
|
||||||
|
const themeBtn= document.getElementById('themeToggle');
|
||||||
|
initTheme(themeBtn);
|
||||||
|
|
||||||
|
const moduleCache = new Map();
|
||||||
|
const viewCache = new Map();
|
||||||
|
|
||||||
|
function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] }
|
||||||
|
|
||||||
|
/* ---------- History API routing (path + query) ---------- */
|
||||||
|
function normalize(id, params){
|
||||||
|
const qs = params && [...params].length ? `?${params.toString()}` : '';
|
||||||
|
return `/${id}${qs}`;
|
||||||
|
}
|
||||||
|
function parseRoute(){
|
||||||
|
const path = location.pathname.replace(/^\/+/, '');
|
||||||
|
const id = path || 'interest';
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return { id, params };
|
||||||
|
}
|
||||||
|
function setRoute(id, params, {replace=true}={}){
|
||||||
|
const target = normalize(id, params || new URLSearchParams());
|
||||||
|
const current = `${location.pathname}${location.search}`;
|
||||||
|
if (target === current) return; // no-op if unchanged
|
||||||
|
if (replace) history.replaceState(null, '', target);
|
||||||
|
else history.pushState(null, '', target);
|
||||||
|
mount(); // re-render
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToParams(card){
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
|
||||||
|
let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value;
|
||||||
|
if (v !== '') p.set(el.name, v);
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function paramsToForm(card, params){
|
||||||
|
let touched = false;
|
||||||
|
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
|
||||||
|
const k = el.name;
|
||||||
|
if (!params.has(k)) return;
|
||||||
|
const v = params.get(k);
|
||||||
|
if (el.type === 'checkbox'){
|
||||||
|
const val = (v==='1' || v==='true');
|
||||||
|
if(el.checked !== val){ el.checked = val; touched = true; }
|
||||||
|
}else{
|
||||||
|
if(el.value !== v){ el.value = v; touched = true; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (touched) card.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Nav: anchors + minimal history ---------- */
|
||||||
|
function renderNav(active){
|
||||||
|
navEl.innerHTML = '';
|
||||||
|
const ul = el('ul',{class:'navlist'});
|
||||||
|
for(const c of CALCS){
|
||||||
|
const a = el('a', {
|
||||||
|
href: `/${c.id}`,
|
||||||
|
'data-calc': c.id,
|
||||||
|
'aria-current': c.id===active ? 'page' : null
|
||||||
|
}, c.name);
|
||||||
|
ul.append(el('li',{}, a));
|
||||||
|
}
|
||||||
|
navEl.append(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
// delegate clicks so we can decide push vs replace
|
||||||
|
navEl.addEventListener('click', (e)=>{
|
||||||
|
const a = e.target.closest('a[data-calc]');
|
||||||
|
if(!a) return;
|
||||||
|
// allow new tab/window/defaults
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = a.dataset.calc;
|
||||||
|
// shift-click = commit to history (push); otherwise replace (minimal history)
|
||||||
|
setRoute(id, null, {replace: !e.shiftKey});
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadModule(spec){
|
||||||
|
if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec));
|
||||||
|
return moduleCache.get(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMounted(id){
|
||||||
|
if(viewCache.has(id)) return viewCache.get(id);
|
||||||
|
const meta = metaById(id);
|
||||||
|
const card = el('section',{class:'card'});
|
||||||
|
viewCache.set(id, card);
|
||||||
|
|
||||||
|
// placeholder heading
|
||||||
|
card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||''));
|
||||||
|
try{
|
||||||
|
const mod = await loadModule(meta.path);
|
||||||
|
const calc = mod.default;
|
||||||
|
card.innerHTML = '';
|
||||||
|
card.append(el('h2',{}, calc.name || meta.name));
|
||||||
|
if(calc.about) card.append(el('div',{class:'muted'}, calc.about));
|
||||||
|
calc.render(card);
|
||||||
|
enhanceSelects(card);
|
||||||
|
}catch(e){
|
||||||
|
console.error(e);
|
||||||
|
card.innerHTML = `<div class="muted">Failed to load calculator. Check console.</div>`;
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachUrlSync(card, id){
|
||||||
|
// Debounced replaceState on input changes (never pushes to history)
|
||||||
|
let t;
|
||||||
|
card.addEventListener('input', ()=>{
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(()=>{
|
||||||
|
const newParams = formToParams(card);
|
||||||
|
const {id:curId, params:curParams} = parseRoute();
|
||||||
|
const sameId = (curId === id);
|
||||||
|
const sameQs = sameId && newParams.toString() === curParams.toString();
|
||||||
|
if(!sameQs) setRoute(id, newParams, {replace:true});
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(id, params){
|
||||||
|
renderNav(id);
|
||||||
|
for(const [cid, card] of viewCache.entries()){
|
||||||
|
card.style.display = (cid===id) ? '' : 'none';
|
||||||
|
}
|
||||||
|
if(!viewCache.has(id)){
|
||||||
|
const card = await ensureMounted(id);
|
||||||
|
viewEl.append(card);
|
||||||
|
attachUrlSync(card, id);
|
||||||
|
}
|
||||||
|
paramsToForm(viewCache.get(id), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mount(){
|
||||||
|
const {id, params} = parseRoute();
|
||||||
|
// normalize root to /interest once
|
||||||
|
if(!location.pathname || location.pathname === '/' ){
|
||||||
|
setRoute('interest', params, {replace:true});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await show(id, params);
|
||||||
|
|
||||||
|
// idle prefetch
|
||||||
|
const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300));
|
||||||
|
idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{})));
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('popstate', mount); // back/forward
|
||||||
|
mount();
|
206
public/js/util.js
Normal file
206
public/js/util.js
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
// public/js/util.js
|
||||||
|
export const fmt = new Intl.NumberFormat(undefined,{maximumFractionDigits:6});
|
||||||
|
export const currency = (v,cur='USD')=> new Intl.NumberFormat(undefined,{style:'currency',currency:cur,maximumFractionDigits:2}).format(v);
|
||||||
|
export const persist = (key, obj) => localStorage.setItem(key, JSON.stringify(obj));
|
||||||
|
export const revive = (key, fallback={}) => { try { return JSON.parse(localStorage.getItem(key)) || fallback } catch { return fallback } };
|
||||||
|
|
||||||
|
export function el(tag, attrs={}, children=[]){
|
||||||
|
const x = document.createElement(tag);
|
||||||
|
for (const [k,v] of Object.entries(attrs||{})){
|
||||||
|
if (k === 'class') x.className = v ?? '';
|
||||||
|
else if (k === 'html') x.innerHTML = v ?? '';
|
||||||
|
else if (k === 'on' && v && typeof v === 'object'){
|
||||||
|
for (const [evt, fn] of Object.entries(v)){ if (typeof fn === 'function') x.addEventListener(evt, fn, false); }
|
||||||
|
} else {
|
||||||
|
if (v !== null && v !== undefined && v !== false){
|
||||||
|
x.setAttribute(k, v === true ? '' : String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Array.isArray(children) ? children : [children]).forEach(ch => { if (ch != null) x.append(ch) });
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelInput(labelText, type, name, value, attrs={}){
|
||||||
|
const w = el('div');
|
||||||
|
w.append(el('label',{html:labelText}));
|
||||||
|
const input = el('input',{type, name, value: String(value ?? ''), ...attrs});
|
||||||
|
w.append(input); return w;
|
||||||
|
}
|
||||||
|
export function labelSelect(labelText, name, value, options){
|
||||||
|
const w = el('div');
|
||||||
|
w.append(el('label',{html:labelText}));
|
||||||
|
const sel = el('select',{name, 'data-ui':'lite'}); // opt into Select Lite
|
||||||
|
options.forEach(([val, text])=>{
|
||||||
|
const opt = el('option',{value:val}); opt.textContent = text;
|
||||||
|
if (String(val) === String(value)) opt.selected = true;
|
||||||
|
sel.append(opt);
|
||||||
|
});
|
||||||
|
w.append(sel); return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme helpers ---- */
|
||||||
|
export function applyTheme(mode){
|
||||||
|
if(mode === 'auto'){ document.documentElement.removeAttribute('data-theme'); }
|
||||||
|
else{ document.documentElement.setAttribute('data-theme', mode); }
|
||||||
|
localStorage.setItem('theme', mode);
|
||||||
|
}
|
||||||
|
export function initTheme(toggleBtn){
|
||||||
|
const saved = localStorage.getItem('theme') || 'auto';
|
||||||
|
applyTheme(saved);
|
||||||
|
if(toggleBtn){
|
||||||
|
toggleBtn.textContent = titleForTheme(saved);
|
||||||
|
toggleBtn.addEventListener('click', ()=>{
|
||||||
|
const next = nextTheme(localStorage.getItem('theme')||'auto');
|
||||||
|
applyTheme(next);
|
||||||
|
toggleBtn.textContent = titleForTheme(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nextTheme(mode){ return mode==='auto' ? 'light' : mode==='light' ? 'dark' : 'auto' }
|
||||||
|
function titleForTheme(mode){ return mode==='auto' ? 'Auto' : mode==='light' ? 'Light' : 'Dark' }
|
||||||
|
|
||||||
|
/* ---- Select Lite: progressively enhance <select> with a themed list ---- */
|
||||||
|
export function enhanceSelects(scope=document){
|
||||||
|
scope.querySelectorAll('select[data-ui="lite"]').forEach(sel=>{
|
||||||
|
if (sel.dataset.enhanced) return;
|
||||||
|
sel.dataset.enhanced = '1';
|
||||||
|
|
||||||
|
// wrapper
|
||||||
|
const wrap = el('div', {class:'select-lite'});
|
||||||
|
sel.parentNode.insertBefore(wrap, sel);
|
||||||
|
wrap.append(sel);
|
||||||
|
|
||||||
|
// button showing current value
|
||||||
|
const btn = el('button', {type:'button', class:'select-lite__button', 'aria-haspopup':'listbox', 'aria-expanded':'false'});
|
||||||
|
const label = ()=> sel.options[sel.selectedIndex]?.text || '';
|
||||||
|
btn.textContent = label();
|
||||||
|
wrap.append(btn);
|
||||||
|
|
||||||
|
// menu
|
||||||
|
const menu = el('div', {class:'select-lite__menu', role:'listbox', tabindex:'-1', hidden:true});
|
||||||
|
const mkOption = (opt, idx)=>{
|
||||||
|
const item = el('div', {class:'select-lite__option', role:'option', 'data-value':opt.value, 'data-idx':idx, 'aria-selected': String(opt.selected)});
|
||||||
|
item.textContent = opt.text;
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
[...sel.options].forEach((opt,i)=> menu.append(mkOption(opt,i)));
|
||||||
|
wrap.append(menu);
|
||||||
|
|
||||||
|
// open/close helpers
|
||||||
|
const open = ()=>{
|
||||||
|
menu.hidden = false; wrap.classList.add('is-open');
|
||||||
|
btn.setAttribute('aria-expanded','true');
|
||||||
|
// focus selected and ensure menu is focusable
|
||||||
|
const current = menu.querySelector(`[data-idx="${sel.selectedIndex}"]`) || menu.firstElementChild;
|
||||||
|
current?.focus();
|
||||||
|
// Ensure menu can receive focus for search
|
||||||
|
menu.focus();
|
||||||
|
// outside click to close
|
||||||
|
setTimeout(()=>{ document.addEventListener('pointerdown', onDocDown, {capture:true, once:true}); }, 0);
|
||||||
|
};
|
||||||
|
const close = ()=>{
|
||||||
|
menu.hidden = true; wrap.classList.remove('is-open');
|
||||||
|
btn.setAttribute('aria-expanded','false');
|
||||||
|
btn.focus();
|
||||||
|
};
|
||||||
|
const onDocDown = (e)=>{ if(!wrap.contains(e.target)) close(); };
|
||||||
|
|
||||||
|
// button events
|
||||||
|
btn.addEventListener('click', ()=> menu.hidden ? open() : close());
|
||||||
|
btn.addEventListener('keydown', (e)=>{
|
||||||
|
if(e.key==='ArrowDown' || e.key==='Enter' || e.key===' '){ e.preventDefault(); open(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add search functionality
|
||||||
|
let searchBuffer = '';
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
const handleSearch = (key) => {
|
||||||
|
if (key.length === 1 && key.match(/[a-zA-Z0-9]/)) {
|
||||||
|
searchBuffer += key.toLowerCase();
|
||||||
|
console.log('Search buffer:', searchBuffer); // Debug log
|
||||||
|
|
||||||
|
// Clear search buffer after 500ms of no typing (faster response)
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchBuffer = '';
|
||||||
|
console.log('Search buffer cleared'); // Debug log
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Find and highlight matching option
|
||||||
|
const options = [...menu.querySelectorAll('.select-lite__option')];
|
||||||
|
const matchingOption = options.find(opt =>
|
||||||
|
opt.textContent.toLowerCase().startsWith(searchBuffer)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingOption) {
|
||||||
|
console.log('Found match:', matchingOption.textContent); // Debug log
|
||||||
|
// Remove previous highlights and search matches
|
||||||
|
options.forEach(o => {
|
||||||
|
o.classList.remove('search-match');
|
||||||
|
o.style.background = '';
|
||||||
|
o.style.color = '';
|
||||||
|
o.style.fontWeight = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight matching option with strong visual feedback
|
||||||
|
matchingOption.classList.add('search-match');
|
||||||
|
matchingOption.style.background = 'var(--accent)';
|
||||||
|
matchingOption.style.color = 'white';
|
||||||
|
matchingOption.style.fontWeight = 'bold';
|
||||||
|
|
||||||
|
// Focus and scroll to the matching option
|
||||||
|
matchingOption.focus();
|
||||||
|
matchingOption.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
console.log('No match found for:', searchBuffer); // Debug log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// menu interactions
|
||||||
|
menu.addEventListener('click', (e)=>{
|
||||||
|
const opt = e.target.closest('.select-lite__option'); if(!opt) return;
|
||||||
|
commit(opt.dataset.value);
|
||||||
|
});
|
||||||
|
menu.addEventListener('keydown', (e)=>{
|
||||||
|
console.log('Menu keydown:', e.key); // Debug log
|
||||||
|
|
||||||
|
// Handle search typing
|
||||||
|
if (e.key.length === 1 && e.key.match(/[a-zA-Z0-9]/)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearch(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = [...menu.querySelectorAll('.select-lite__option')];
|
||||||
|
const i = opts.indexOf(document.activeElement);
|
||||||
|
if(e.key==='ArrowDown'){ e.preventDefault(); (opts[i+1]||opts[0]).focus(); }
|
||||||
|
else if(e.key==='ArrowUp'){ e.preventDefault(); (opts[i-1]||opts[opts.length-1]).focus(); }
|
||||||
|
else if(e.key==='Home'){ e.preventDefault(); opts[0].focus(); }
|
||||||
|
else if(e.key==='End'){ e.preventDefault(); opts[opts.length-1].focus(); }
|
||||||
|
else if(e.key==='Enter' || e.key===' '){
|
||||||
|
e.preventDefault();
|
||||||
|
const t = document.activeElement;
|
||||||
|
if(t?.classList.contains('select-lite__option')) {
|
||||||
|
commit(t.dataset.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(e.key==='Escape'){ e.preventDefault(); close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit(value){
|
||||||
|
if (sel.value !== value){
|
||||||
|
sel.value = value;
|
||||||
|
btn.textContent = label();
|
||||||
|
// update aria-selected
|
||||||
|
menu.querySelectorAll('.select-lite__option').forEach(o=> o.setAttribute('aria-selected', String(o.dataset.value===value)));
|
||||||
|
// bubble change to app
|
||||||
|
sel.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
|
sel.dispatchEvent(new Event('change', {bubbles:true}));
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
12
requirements.txt
Normal file
12
requirements.txt
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Test dependencies
|
||||||
|
pytest>=7.0.0
|
||||||
|
selenium>=4.0.0
|
||||||
|
pytest-xdist>=3.0.0
|
||||||
|
|
||||||
|
# Code quality (optional)
|
||||||
|
black>=23.0.0
|
||||||
|
flake8>=6.0.0
|
||||||
|
requests
|
||||||
|
|
||||||
|
# Development server
|
||||||
|
# Note: dev_server.py uses only standard library modules
|
111
tests/conftest.py
Normal file
111
tests/conftest.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import contextlib, http.server, os, socket, threading, time
|
||||||
|
import pytest
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DOCROOT = os.path.abspath(os.path.join(ROOT, '..', 'public'))
|
||||||
|
|
||||||
|
class SpaHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/' or self.path.split('?',1)[0].split('#',1)[0] == '/':
|
||||||
|
return http.server.SimpleHTTPRequestHandler.do_GET(self)
|
||||||
|
try:
|
||||||
|
return http.server.SimpleHTTPRequestHandler.do_GET(self)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.path = '/index.html'
|
||||||
|
return http.server.SimpleHTTPRequestHandler.do_GET(self)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def static_server():
|
||||||
|
os.chdir(DOCROOT)
|
||||||
|
httpd = None
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(('127.0.0.1', 0))
|
||||||
|
host, port = s.getsockname()
|
||||||
|
handler = SpaHandler
|
||||||
|
httpd = http.server.ThreadingHTTPServer(('127.0.0.1', port), handler)
|
||||||
|
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url():
|
||||||
|
with static_server() as url:
|
||||||
|
yield url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def dev_server():
|
||||||
|
"""Start the local development server for testing using the same logic as dev_server.py"""
|
||||||
|
# Check if server is already running
|
||||||
|
try:
|
||||||
|
response = requests.get("http://localhost:8008", timeout=1)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("Development server already running on port 8008")
|
||||||
|
yield "http://localhost:8008"
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use the same server logic as dev_server.py
|
||||||
|
print("Starting development server...")
|
||||||
|
|
||||||
|
# Import the handler class from dev_server
|
||||||
|
sys.path.append(os.path.dirname(DOCROOT))
|
||||||
|
from dev_server import SPAHandler
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
os.chdir(DOCROOT) # serve from /public
|
||||||
|
httpd = http.server.ThreadingHTTPServer(('127.0.0.1', 8008), SPAHandler)
|
||||||
|
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
max_wait = 30
|
||||||
|
for i in range(max_wait):
|
||||||
|
try:
|
||||||
|
response = requests.get("http://localhost:8008", timeout=1)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f"Development server started successfully after {i+1} seconds")
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
if i == max_wait - 1:
|
||||||
|
print("Failed to start development server")
|
||||||
|
httpd.shutdown()
|
||||||
|
raise
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield "http://localhost:8008"
|
||||||
|
finally:
|
||||||
|
print("Stopping development server...")
|
||||||
|
httpd.shutdown()
|
||||||
|
t.join()
|
||||||
|
print("Development server stopped")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def calculator_page(driver, dev_server):
|
||||||
|
"""Navigate to the calculator page using the development server"""
|
||||||
|
driver.get(f"{dev_server}")
|
||||||
|
# Wait for the page to load and JavaScript to populate navigation
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "sidenav"))
|
||||||
|
)
|
||||||
|
# Wait for JavaScript to populate the navigation with calculator links
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//a[contains(text(), 'Interest')]"))
|
||||||
|
)
|
||||||
|
return driver
|
404
tests/test_calculators.py
Normal file
404
tests/test_calculators.py
Normal file
|
@ -0,0 +1,404 @@
|
||||||
|
import pytest
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
import time
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def driver():
|
||||||
|
"""Create a new browser instance for each test"""
|
||||||
|
chrome_options = Options()
|
||||||
|
chrome_options.add_argument("--headless")
|
||||||
|
chrome_options.add_argument("--no-sandbox")
|
||||||
|
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||||
|
chrome_options.add_argument("--disable-gpu")
|
||||||
|
|
||||||
|
driver = webdriver.Chrome(options=chrome_options)
|
||||||
|
driver.implicitly_wait(10)
|
||||||
|
yield driver
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
# calculator_page fixture is now defined in conftest.py
|
||||||
|
|
||||||
|
class TestCalculatorNavigation:
|
||||||
|
"""Test calculator navigation and basic functionality"""
|
||||||
|
|
||||||
|
def test_debug_page_content(self, driver):
|
||||||
|
"""Debug test to see what's actually on the page"""
|
||||||
|
driver.get("http://localhost:8008")
|
||||||
|
|
||||||
|
# Wait for basic page structure
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "sidenav"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print page title and basic info
|
||||||
|
print(f"\nPage title: {driver.title}")
|
||||||
|
print(f"Current URL: {driver.current_url}")
|
||||||
|
|
||||||
|
# Check if sidenav has any content
|
||||||
|
sidenav = driver.find_element(By.CLASS_NAME, "sidenav")
|
||||||
|
print(f"Sidenav HTML: {sidenav.get_attribute('innerHTML')}")
|
||||||
|
|
||||||
|
# Check for JavaScript errors in console
|
||||||
|
logs = driver.get_log('browser')
|
||||||
|
if logs:
|
||||||
|
print(f"\nBrowser console logs:")
|
||||||
|
for log in logs:
|
||||||
|
print(f" {log['level']}: {log['message']}")
|
||||||
|
|
||||||
|
# Check if app.js loaded
|
||||||
|
try:
|
||||||
|
app_js = driver.find_element(By.XPATH, "//script[@src='/js/app.js']")
|
||||||
|
print(f"App.js script tag found: {app_js.get_attribute('outerHTML')}")
|
||||||
|
except:
|
||||||
|
print("App.js script tag NOT found!")
|
||||||
|
|
||||||
|
# Wait a bit longer and check again
|
||||||
|
time.sleep(3)
|
||||||
|
sidenav_after_wait = driver.find_element(By.CLASS_NAME, "sidenav")
|
||||||
|
print(f"Sidenav HTML after 3s wait: {sidenav_after_wait.get_attribute('innerHTML')}")
|
||||||
|
|
||||||
|
# This test should always pass for debugging
|
||||||
|
assert True
|
||||||
|
|
||||||
|
def test_page_loads(self, calculator_page):
|
||||||
|
"""Test that the calculator page loads correctly"""
|
||||||
|
assert "calculator.127local.net" in calculator_page.title
|
||||||
|
assert calculator_page.find_element(By.CLASS_NAME, "sidenav")
|
||||||
|
assert calculator_page.find_element(By.CLASS_NAME, "content")
|
||||||
|
|
||||||
|
def test_calculator_list(self, calculator_page):
|
||||||
|
"""Test that all calculators are listed in navigation"""
|
||||||
|
nav_items = calculator_page.find_elements(By.CSS_SELECTOR, ".sidenav a")
|
||||||
|
calculator_names = [item.text for item in nav_items]
|
||||||
|
|
||||||
|
expected_calculators = [
|
||||||
|
"Interest (Simple & Compound)",
|
||||||
|
"Bandwidth",
|
||||||
|
"NMEA",
|
||||||
|
"RAID",
|
||||||
|
"Currency Converter"
|
||||||
|
]
|
||||||
|
|
||||||
|
for expected in expected_calculators:
|
||||||
|
assert expected in calculator_names, f"Calculator {expected} not found in navigation"
|
||||||
|
|
||||||
|
class TestInterestCalculator:
|
||||||
|
"""Test the Interest calculator functionality"""
|
||||||
|
|
||||||
|
def test_interest_calculator_loads(self, calculator_page):
|
||||||
|
"""Test that interest calculator loads and displays correctly"""
|
||||||
|
# Click on interest calculator
|
||||||
|
interest_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Interest')]")
|
||||||
|
interest_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "principal"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that all inputs are present
|
||||||
|
assert calculator_page.find_element(By.NAME, "principal")
|
||||||
|
assert calculator_page.find_element(By.NAME, "rate")
|
||||||
|
assert calculator_page.find_element(By.NAME, "compound")
|
||||||
|
assert calculator_page.find_element(By.NAME, "years")
|
||||||
|
assert calculator_page.find_element(By.NAME, "contrib")
|
||||||
|
assert calculator_page.find_element(By.NAME, "contribFreq")
|
||||||
|
|
||||||
|
def test_simple_interest_calculation(self, calculator_page):
|
||||||
|
"""Test simple interest calculation"""
|
||||||
|
# Load interest calculator
|
||||||
|
interest_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Interest')]")
|
||||||
|
interest_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "principal"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set values for simple interest
|
||||||
|
principal_input = calculator_page.find_element(By.NAME, "principal")
|
||||||
|
rate_input = calculator_page.find_element(By.NAME, "rate")
|
||||||
|
years_input = calculator_page.find_element(By.NAME, "years")
|
||||||
|
compound_select = calculator_page.find_element(By.NAME, "compound")
|
||||||
|
|
||||||
|
principal_input.clear()
|
||||||
|
principal_input.send_keys("1000")
|
||||||
|
|
||||||
|
rate_input.clear()
|
||||||
|
rate_input.send_keys("5")
|
||||||
|
|
||||||
|
years_input.clear()
|
||||||
|
years_input.send_keys("3")
|
||||||
|
|
||||||
|
# Select simple interest (no compounding)
|
||||||
|
# Click on the Select Lite button to open dropdown
|
||||||
|
compound_button = calculator_page.find_element(By.CSS_SELECTOR, '[name="compound"] + .select-lite__button')
|
||||||
|
compound_button.click()
|
||||||
|
|
||||||
|
# Wait for dropdown to appear and select Simple option
|
||||||
|
simple_option = WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'Simple')]"))
|
||||||
|
)
|
||||||
|
simple_option.click()
|
||||||
|
|
||||||
|
# Wait for calculation
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Check result
|
||||||
|
result = calculator_page.find_element(By.CLASS_NAME, "result")
|
||||||
|
assert "Future value:" in result.text
|
||||||
|
assert "1,150.00" in result.text # 1000 + (1000 * 0.05 * 3)
|
||||||
|
|
||||||
|
def test_compound_interest_calculation(self, calculator_page):
|
||||||
|
"""Test compound interest calculation"""
|
||||||
|
# Load interest calculator
|
||||||
|
interest_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Interest')]")
|
||||||
|
interest_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "principal"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set values for compound interest
|
||||||
|
principal_input = calculator_page.find_element(By.NAME, "principal")
|
||||||
|
rate_input = calculator_page.find_element(By.NAME, "rate")
|
||||||
|
years_input = calculator_page.find_element(By.NAME, "years")
|
||||||
|
|
||||||
|
principal_input.clear()
|
||||||
|
principal_input.send_keys("1000")
|
||||||
|
|
||||||
|
rate_input.clear()
|
||||||
|
rate_input.send_keys("5")
|
||||||
|
|
||||||
|
years_input.clear()
|
||||||
|
years_input.send_keys("3")
|
||||||
|
|
||||||
|
# Wait for calculation
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Check result (should be higher than simple interest)
|
||||||
|
result = calculator_page.find_element(By.CLASS_NAME, "result")
|
||||||
|
assert "Future value:" in result.text
|
||||||
|
# Compound interest should be > 1150 (simple interest result)
|
||||||
|
assert "1,150.00" not in result.text or "1,157.63" in result.text
|
||||||
|
|
||||||
|
class TestCurrencyConverter:
|
||||||
|
"""Test the Currency Converter functionality"""
|
||||||
|
|
||||||
|
def test_currency_converter_loads(self, calculator_page):
|
||||||
|
"""Test that currency converter loads and displays correctly"""
|
||||||
|
# Click on currency converter
|
||||||
|
currency_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Currency Converter')]")
|
||||||
|
currency_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that all inputs are present
|
||||||
|
assert calculator_page.find_element(By.NAME, "amount")
|
||||||
|
assert calculator_page.find_element(By.NAME, "from")
|
||||||
|
assert calculator_page.find_element(By.NAME, "to")
|
||||||
|
|
||||||
|
def test_currency_conversion_same_currency(self, calculator_page):
|
||||||
|
"""Test conversion when from and to currencies are the same"""
|
||||||
|
# Load currency converter
|
||||||
|
currency_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Currency Converter')]")
|
||||||
|
currency_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debug: verify we're on the currency converter
|
||||||
|
print(f"Current URL: {calculator_page.current_url}")
|
||||||
|
print(f"Page title: {calculator_page.title}")
|
||||||
|
|
||||||
|
# Check for currency converter specific elements
|
||||||
|
try:
|
||||||
|
from_select = calculator_page.find_element(By.NAME, "from")
|
||||||
|
to_select = calculator_page.find_element(By.NAME, "to")
|
||||||
|
print(f"From select value: {from_select.get_attribute('value')}")
|
||||||
|
print(f"To select value: {to_select.get_attribute('value')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error finding currency elements: {e}")
|
||||||
|
|
||||||
|
# Set amount
|
||||||
|
amount_input = calculator_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("100")
|
||||||
|
|
||||||
|
# Trigger calculation by changing the amount
|
||||||
|
amount_input.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
# Wait for calculation to complete
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit more for the currency converter to fully load and replace the old result
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Find all result elements and get the one that's actually visible/contains currency content
|
||||||
|
result_elements = calculator_page.find_elements(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
# Look for the result element that contains currency-related content
|
||||||
|
currency_result = None
|
||||||
|
for elem in result_elements:
|
||||||
|
html = elem.get_attribute('innerHTML')
|
||||||
|
if 'USD' in html or 'EUR' in html or 'Currency' in html or 'conversion' in html.lower():
|
||||||
|
currency_result = elem
|
||||||
|
break
|
||||||
|
|
||||||
|
if not currency_result:
|
||||||
|
# If no currency result found, use the last result element (most recent)
|
||||||
|
currency_result = result_elements[-1]
|
||||||
|
|
||||||
|
# Check result
|
||||||
|
assert "100 USD" in currency_result.text or "100 EUR" in currency_result.text
|
||||||
|
|
||||||
|
def test_currency_conversion_different_currencies(self, calculator_page):
|
||||||
|
"""Test conversion between different currencies"""
|
||||||
|
# Load currency converter
|
||||||
|
currency_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Currency Converter')]")
|
||||||
|
currency_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set amount
|
||||||
|
amount_input = calculator_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("100")
|
||||||
|
|
||||||
|
# Trigger calculation by changing the amount
|
||||||
|
amount_input.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
# Wait for calculation and API response
|
||||||
|
WebDriverWait(calculator_page, 15).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit more for the currency converter to fully load and replace the old result
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Find all result elements and get the one that's actually visible/contains currency content
|
||||||
|
result_elements = calculator_page.find_elements(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
# Look for the result element that contains currency-related content
|
||||||
|
currency_result = None
|
||||||
|
for elem in result_elements:
|
||||||
|
html = elem.get_attribute('innerHTML')
|
||||||
|
if 'USD' in html or 'EUR' in html or 'Currency' in html or 'conversion' in html.lower():
|
||||||
|
currency_result = elem
|
||||||
|
break
|
||||||
|
|
||||||
|
if not currency_result:
|
||||||
|
# If no currency result found, use the last result element (most recent)
|
||||||
|
currency_result = result_elements[-1]
|
||||||
|
|
||||||
|
# Check that some result is displayed
|
||||||
|
assert currency_result.text.strip() != ""
|
||||||
|
# Check for either conversion result or static rates message
|
||||||
|
assert any(text in currency_result.text for text in ["USD", "EUR", "Static rates", "Live rates"])
|
||||||
|
|
||||||
|
class TestBandwidthCalculator:
|
||||||
|
"""Test the Bandwidth calculator functionality"""
|
||||||
|
|
||||||
|
def test_bandwidth_calculator_loads(self, calculator_page):
|
||||||
|
"""Test that bandwidth calculator loads"""
|
||||||
|
bandwidth_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Bandwidth')]")
|
||||||
|
bandwidth_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that result area is present
|
||||||
|
assert calculator_page.find_element(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
class TestNMEACalculator:
|
||||||
|
"""Test the NMEA calculator functionality"""
|
||||||
|
|
||||||
|
def test_nmea_calculator_loads(self, calculator_page):
|
||||||
|
"""Test that NMEA calculator loads"""
|
||||||
|
nmea_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'NMEA')]")
|
||||||
|
nmea_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that result area is present
|
||||||
|
assert calculator_page.find_element(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
class TestRAIDCalculator:
|
||||||
|
"""Test the RAID calculator functionality"""
|
||||||
|
|
||||||
|
def test_raid_calculator_loads(self, calculator_page):
|
||||||
|
"""Test that RAID calculator loads"""
|
||||||
|
raid_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'RAID')]")
|
||||||
|
raid_btn.click()
|
||||||
|
|
||||||
|
# Wait for calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that result area is present
|
||||||
|
assert calculator_page.find_element(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
class TestCalculatorResponsiveness:
|
||||||
|
"""Test calculator responsiveness and UI behavior"""
|
||||||
|
|
||||||
|
def test_theme_toggle(self, calculator_page):
|
||||||
|
"""Test that theme toggle button works"""
|
||||||
|
theme_btn = calculator_page.find_element(By.ID, "themeToggle")
|
||||||
|
initial_text = theme_btn.text
|
||||||
|
|
||||||
|
# Click theme toggle
|
||||||
|
theme_btn.click()
|
||||||
|
|
||||||
|
# Wait for theme change
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Check that text changed
|
||||||
|
assert theme_btn.text != initial_text
|
||||||
|
|
||||||
|
def test_calculator_switching(self, calculator_page):
|
||||||
|
"""Test switching between different calculators"""
|
||||||
|
# Start with interest calculator
|
||||||
|
interest_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Interest')]")
|
||||||
|
interest_btn.click()
|
||||||
|
|
||||||
|
# Wait for interest calculator to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "principal"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switch to currency converter
|
||||||
|
currency_btn = calculator_page.find_element(By.XPATH, "//a[contains(text(), 'Currency Converter')]")
|
||||||
|
currency_btn.click()
|
||||||
|
|
||||||
|
# Wait for currency converter to load
|
||||||
|
WebDriverWait(calculator_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we're on currency converter
|
||||||
|
assert calculator_page.find_element(By.NAME, "amount")
|
||||||
|
assert calculator_page.find_element(By.NAME, "from")
|
||||||
|
assert calculator_page.find_element(By.NAME, "to")
|
||||||
|
|
863
tests/test_converter.py
Normal file
863
tests/test_converter.py
Normal file
|
@ -0,0 +1,863 @@
|
||||||
|
import pytest
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
import time
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def driver():
|
||||||
|
"""Set up Chrome driver with options"""
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
options.add_argument('--headless')
|
||||||
|
options.add_argument('--no-sandbox')
|
||||||
|
options.add_argument('--disable-dev-shm-usage')
|
||||||
|
driver = webdriver.Chrome(options=options)
|
||||||
|
driver.implicitly_wait(2)
|
||||||
|
yield driver
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def converter_page(driver, dev_server):
|
||||||
|
"""Navigate to currency converter page"""
|
||||||
|
driver.get(f"{dev_server}")
|
||||||
|
|
||||||
|
# Wait for page to load and JavaScript to populate navigation
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "sidenav"))
|
||||||
|
)
|
||||||
|
# Wait for JavaScript to populate the navigation with calculator links
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//a[contains(text(), 'Currency Converter')]"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click on Currency Converter
|
||||||
|
currency_btn = driver.find_element(By.XPATH, "//a[contains(text(), 'Currency Converter')]")
|
||||||
|
currency_btn.click()
|
||||||
|
|
||||||
|
# Wait for currency converter to load
|
||||||
|
WebDriverWait(driver, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
return driver
|
||||||
|
|
||||||
|
class TestCurrencyConverter:
|
||||||
|
"""Test the Currency Converter functionality"""
|
||||||
|
|
||||||
|
def _ensure_calculation(self, converter_page):
|
||||||
|
"""Helper to trigger calculation and wait for result"""
|
||||||
|
# Trigger calculation by sending TAB to the amount input
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
# Wait for calculation to complete
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit more for the currency converter to fully load and replace the old result
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def _get_currency_result(self, converter_page):
|
||||||
|
"""Helper to get the correct currency result element"""
|
||||||
|
# Find all result elements and get the one that's actually visible/contains currency content
|
||||||
|
result_elements = converter_page.find_elements(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
# Look for the result element that contains currency-related content
|
||||||
|
currency_result = None
|
||||||
|
for elem in result_elements:
|
||||||
|
html = elem.get_attribute('innerHTML')
|
||||||
|
if 'USD' in html or 'EUR' in html or 'GBP' in html or 'Currency' in html or 'conversion' in html.lower():
|
||||||
|
currency_result = elem
|
||||||
|
break
|
||||||
|
|
||||||
|
if not currency_result:
|
||||||
|
# If no currency result found, use the last result element (most recent)
|
||||||
|
currency_result = result_elements[-1]
|
||||||
|
|
||||||
|
return currency_result
|
||||||
|
|
||||||
|
def test_page_loads_with_default_values(self, converter_page):
|
||||||
|
"""Test that the page loads with default values and shows initial calculation"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result contains expected content
|
||||||
|
assert "USD" in result.text
|
||||||
|
assert "EUR" in result.text
|
||||||
|
assert "100" in result.text
|
||||||
|
|
||||||
|
def test_currency_selection_works(self, converter_page):
|
||||||
|
"""Test that currency selection works and updates calculation"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Change from currency to GBP using Select Lite
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
from_button.click()
|
||||||
|
|
||||||
|
# Wait for and click on GBP option
|
||||||
|
gbp_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'GBP')]"))
|
||||||
|
)
|
||||||
|
gbp_option.click()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result contains GBP
|
||||||
|
assert "GBP" in result.text
|
||||||
|
|
||||||
|
def test_manual_rate_input_updates_calculation(self, converter_page):
|
||||||
|
"""Test that manual rate input updates calculation immediately"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find and update manual rate input
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("2.0")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows the custom rate
|
||||||
|
assert "2.0" in result.text
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
|
||||||
|
def test_manual_rate_overrides_market_rates(self, converter_page):
|
||||||
|
"""Test that manual rate overrides fetched market rates"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set a manual rate
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("1.5")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows the custom rate
|
||||||
|
assert "1.5" in result.text
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
|
||||||
|
def test_currency_switching_clears_manual_rate(self, converter_page):
|
||||||
|
"""Test that switching currencies clears manual rate input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set a manual rate
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("1.5")
|
||||||
|
|
||||||
|
# Change to currency using Select Lite
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
from_button.click()
|
||||||
|
|
||||||
|
# Wait for and click on GBP option
|
||||||
|
gbp_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'GBP')]"))
|
||||||
|
)
|
||||||
|
gbp_option.click()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows GBP and uses static rates (not custom rate)
|
||||||
|
assert "GBP" in result.text
|
||||||
|
assert "Static rates" in result.text
|
||||||
|
|
||||||
|
def test_fetch_rates_updates_manual_rate(self, converter_page):
|
||||||
|
"""Test that fetching rates updates the manual rate input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set a manual rate first
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("1.0")
|
||||||
|
|
||||||
|
# Click the fetch rates button
|
||||||
|
fetch_button = converter_page.find_element(By.XPATH, "//button[contains(text(), 'Update Exchange Rates')]")
|
||||||
|
# Use JavaScript to click to avoid interception
|
||||||
|
converter_page.execute_script("arguments[0].click();", fetch_button)
|
||||||
|
|
||||||
|
# Wait for the status to show success
|
||||||
|
WebDriverWait(converter_page, 15).until(
|
||||||
|
EC.text_to_be_present_in_element((By.CLASS_NAME, "status"), "Rates updated successfully!")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear the manual rate so it will use the fetched rates
|
||||||
|
manual_rate_input.clear()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows live rates (not custom rate)
|
||||||
|
assert "Live rates" in result.text
|
||||||
|
|
||||||
|
def test_same_currency_conversion(self, converter_page):
|
||||||
|
"""Test conversion when from and to currencies are the same"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Change from currency to EUR using Select Lite (since to is already EUR)
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
from_button.click()
|
||||||
|
|
||||||
|
# Wait for and click on EUR option
|
||||||
|
eur_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'EUR')]"))
|
||||||
|
)
|
||||||
|
eur_option.click()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows same currency message
|
||||||
|
assert "Same currency - no conversion needed" in result.text
|
||||||
|
assert "100.00 EUR" in result.text
|
||||||
|
|
||||||
|
def test_amount_input_updates_calculation(self, converter_page):
|
||||||
|
"""Test that changing amount updates calculation immediately"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Change amount
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("50")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows the new amount
|
||||||
|
assert "50" in result.text
|
||||||
|
|
||||||
|
def test_negative_amount_handling(self, converter_page):
|
||||||
|
"""Test that negative amounts are handled appropriately"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to enter negative amount
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("-100")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows the validation message
|
||||||
|
assert "Enter a positive amount to convert" in result.text
|
||||||
|
|
||||||
|
def test_zero_amount_handling(self, converter_page):
|
||||||
|
"""Test that zero amounts are handled appropriately"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to enter zero amount
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("0")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows the validation message
|
||||||
|
assert "Enter a positive amount to convert" in result.text
|
||||||
|
|
||||||
|
def test_status_message_behavior(self, converter_page):
|
||||||
|
"""Test that status messages appear and disappear appropriately"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click the fetch rates button to trigger a status message
|
||||||
|
fetch_button = converter_page.find_element(By.XPATH, "//button[contains(text(), 'Update Exchange Rates')]")
|
||||||
|
# Use JavaScript to click to avoid interception
|
||||||
|
converter_page.execute_script("arguments[0].click();", fetch_button)
|
||||||
|
|
||||||
|
# Wait for the status to show success
|
||||||
|
WebDriverWait(converter_page, 15).until(
|
||||||
|
EC.text_to_be_present_in_element((By.CLASS_NAME, "status"), "Rates updated successfully!")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the status message to disappear (should auto-hide after 1.5 seconds)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check that the status element is either hidden or empty
|
||||||
|
status = converter_page.find_element(By.CLASS_NAME, "status")
|
||||||
|
assert status.text.strip() == "" or status.get_attribute("style").find("display: none") != -1
|
||||||
|
|
||||||
|
def test_select_lite_search_functionality(self, converter_page):
|
||||||
|
"""Test that Select Lite search works for currency selection"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click on the from currency Select Lite button
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
from_button.click()
|
||||||
|
|
||||||
|
# Wait for the dropdown to open
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "select-lite__menu"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type 'g' to search for GBP
|
||||||
|
from_button.send_keys("g")
|
||||||
|
|
||||||
|
# Wait for and verify that GBP option is highlighted/filtered
|
||||||
|
gbp_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'GBP')]"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click on GBP option
|
||||||
|
gbp_option.click()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Check that the result shows GBP
|
||||||
|
assert "GBP" in result.text
|
||||||
|
|
||||||
|
class TestCurrencyConverterEdgeCases:
|
||||||
|
"""Test edge cases and error conditions for the Currency Converter"""
|
||||||
|
|
||||||
|
def _ensure_calculation(self, converter_page):
|
||||||
|
"""Helper to trigger calculation and wait for result"""
|
||||||
|
# Trigger calculation by sending TAB to the amount input
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
# Wait for calculation to complete
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit more for the currency converter to fully load and replace the old result
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def _get_currency_result(self, converter_page):
|
||||||
|
"""Helper to get the correct currency result element"""
|
||||||
|
# Find all result elements and get the one that's actually visible/contains currency content
|
||||||
|
result_elements = converter_page.find_elements(By.CLASS_NAME, "result")
|
||||||
|
|
||||||
|
# Look for the result element that contains currency-related content
|
||||||
|
currency_result = None
|
||||||
|
for elem in result_elements:
|
||||||
|
html = elem.get_attribute('innerHTML')
|
||||||
|
if 'USD' in html or 'EUR' in html or 'GBP' in html or 'JPY' in html or 'Currency' in html or 'conversion' in html.lower():
|
||||||
|
currency_result = elem
|
||||||
|
break
|
||||||
|
|
||||||
|
if not currency_result:
|
||||||
|
# If no currency result found, use the last result element (most recent)
|
||||||
|
currency_result = result_elements[-1]
|
||||||
|
|
||||||
|
return currency_result
|
||||||
|
|
||||||
|
def test_extremely_large_amounts(self, converter_page):
|
||||||
|
"""Test handling of extremely large amounts (overflow protection)"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with a very large number
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("999999999999999999")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle large numbers gracefully (not crash or show error)
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "Unable to calculate conversion" not in result.text
|
||||||
|
|
||||||
|
def test_extremely_small_amounts(self, converter_page):
|
||||||
|
"""Test handling of extremely small amounts (precision limits)"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with a very small number
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("0.0000000001")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle small numbers gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "Unable to calculate conversion" not in result.text
|
||||||
|
|
||||||
|
def test_decimal_precision_limits(self, converter_page):
|
||||||
|
"""Test handling of many decimal places"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with many decimal places
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("100.12345678901234567890")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle precision gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "100.12" in result.text # Should round appropriately
|
||||||
|
|
||||||
|
def test_empty_amount_input(self, converter_page):
|
||||||
|
"""Test handling of empty amount input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear amount input completely
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
|
||||||
|
# Send a TAB to trigger calculation
|
||||||
|
amount_input.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
# Wait for calculation to complete
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "result"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit more for the result to update
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle empty input gracefully (either show validation message or fall back to previous result)
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
# Note: The currency converter may not properly validate empty input, so we just ensure it doesn't crash
|
||||||
|
|
||||||
|
def test_whitespace_only_input(self, converter_page):
|
||||||
|
"""Test handling of whitespace-only input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with whitespace
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys(" ")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle whitespace gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
|
||||||
|
def test_manual_rate_edge_cases(self, converter_page):
|
||||||
|
"""Test edge cases for manual rate input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with zero manual rate
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("0")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should fall back to market/static rates when manual rate is 0
|
||||||
|
assert "Static rates" in result.text or "Live rates" in result.text
|
||||||
|
|
||||||
|
# Test with negative manual rate
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("-1.5")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should fall back to market/static rates when manual rate is negative
|
||||||
|
assert "Static rates" in result.text or "Live rates" in result.text
|
||||||
|
|
||||||
|
def test_extremely_small_manual_rate(self, converter_page):
|
||||||
|
"""Test handling of extremely small manual rates"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with very small rate
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("0.0000000001")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle small rates gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
|
||||||
|
def test_extremely_large_manual_rate(self, converter_page):
|
||||||
|
"""Test handling of extremely large manual rates"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with very large rate
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("999999999999999999")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle large rates gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
|
||||||
|
def test_rapid_currency_switching(self, converter_page):
|
||||||
|
"""Test rapid switching between currencies to ensure state consistency"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rapidly switch currencies multiple times
|
||||||
|
for i in range(3):
|
||||||
|
# Change from currency using JavaScript to avoid interception
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
converter_page.execute_script("arguments[0].click();", from_button)
|
||||||
|
|
||||||
|
# Wait for dropdown to open
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "select-lite__menu"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for and click on GBP option
|
||||||
|
gbp_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'GBP')]"))
|
||||||
|
)
|
||||||
|
converter_page.execute_script("arguments[0].click();", gbp_option)
|
||||||
|
|
||||||
|
# Wait for selection to complete
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Change back to USD
|
||||||
|
from_button = converter_page.find_element(By.CSS_SELECTOR, '[name="from"] + .select-lite__button')
|
||||||
|
converter_page.execute_script("arguments[0].click();", from_button)
|
||||||
|
|
||||||
|
# Wait for dropdown to open
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "select-lite__menu"))
|
||||||
|
)
|
||||||
|
|
||||||
|
usd_option = WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, "//div[contains(@class, 'select-lite__option') and contains(text(), 'USD')]"))
|
||||||
|
)
|
||||||
|
converter_page.execute_script("arguments[0].click();", usd_option)
|
||||||
|
|
||||||
|
# Wait for selection to complete
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should still work correctly after rapid switching
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "USD" in result.text
|
||||||
|
assert "EUR" in result.text
|
||||||
|
|
||||||
|
def test_rapid_amount_changes(self, converter_page):
|
||||||
|
"""Test rapid changes to amount input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rapidly change amounts
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
for amount in ["100", "200", "50", "75", "100"]:
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys(amount)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should show the final amount correctly
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "100" in result.text
|
||||||
|
|
||||||
|
def test_concurrent_rate_fetching(self, converter_page):
|
||||||
|
"""Test multiple rapid clicks on fetch rates button"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Click fetch rates button multiple times rapidly
|
||||||
|
fetch_button = converter_page.find_element(By.XPATH, "//button[contains(text(), 'Update Exchange Rates')]")
|
||||||
|
for _ in range(3):
|
||||||
|
converter_page.execute_script("arguments[0].click();", fetch_button)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Wait for any status message
|
||||||
|
try:
|
||||||
|
WebDriverWait(converter_page, 15).until(
|
||||||
|
EC.presence_of_element_located((By.CLASS_NAME, "status"))
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass # Status might not appear if requests are cancelled
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should still work correctly after multiple clicks
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
|
||||||
|
def test_invalid_manual_rate_input(self, converter_page):
|
||||||
|
"""Test handling of invalid manual rate input"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with invalid input (letters)
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("abc")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should fall back to market/static rates when manual rate is invalid
|
||||||
|
assert "Static rates" in result.text or "Live rates" in result.text
|
||||||
|
|
||||||
|
def test_manual_rate_precision_limits(self, converter_page):
|
||||||
|
"""Test handling of manual rate with many decimal places"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with many decimal places
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("1.234567890123456789")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle precision gracefully
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
assert "1.2346" in result.text # Should round appropriately
|
||||||
|
|
||||||
|
def test_cache_expiration_edge_case(self, converter_page):
|
||||||
|
"""Test behavior when cache is about to expire"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# This test would require mocking time, but we can test the basic functionality
|
||||||
|
# by ensuring the cache duration logic doesn't break normal operation
|
||||||
|
|
||||||
|
# Click fetch rates button
|
||||||
|
fetch_button = converter_page.find_element(By.XPATH, "//button[contains(text(), 'Update Exchange Rates')]")
|
||||||
|
converter_page.execute_script("arguments[0].click();", fetch_button)
|
||||||
|
|
||||||
|
# Wait for status to show success
|
||||||
|
try:
|
||||||
|
WebDriverWait(converter_page, 15).until(
|
||||||
|
EC.text_to_be_present_in_element((By.CLASS_NAME, "status"), "Rates updated successfully!")
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass # API might fail, that's okay for this test
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should still work regardless of cache status
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
|
||||||
|
def test_currency_pair_edge_cases(self, converter_page):
|
||||||
|
"""Test edge cases with specific currency pairs"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with a very small amount to test precision handling
|
||||||
|
amount_input = converter_page.find_element(By.NAME, "amount")
|
||||||
|
amount_input.clear()
|
||||||
|
amount_input.send_keys("0.01")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should handle small amounts correctly
|
||||||
|
assert result.text.strip() != ""
|
||||||
|
assert "0.01" in result.text # Should show the small amount
|
||||||
|
assert "USD" in result.text
|
||||||
|
assert "EUR" in result.text
|
||||||
|
|
||||||
|
def test_empty_manual_rate_after_setting(self, converter_page):
|
||||||
|
"""Test behavior when manual rate is cleared after being set"""
|
||||||
|
# Wait for the page to load
|
||||||
|
WebDriverWait(converter_page, 10).until(
|
||||||
|
EC.presence_of_element_located((By.NAME, "amount"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set a manual rate first
|
||||||
|
manual_rate_input = converter_page.find_element(By.NAME, "manualRate")
|
||||||
|
manual_rate_input.clear()
|
||||||
|
manual_rate_input.send_keys("2.0")
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should show custom rate
|
||||||
|
assert "Custom rate" in result.text
|
||||||
|
|
||||||
|
# Now clear the manual rate
|
||||||
|
manual_rate_input.clear()
|
||||||
|
|
||||||
|
# Ensure calculation is triggered
|
||||||
|
self._ensure_calculation(converter_page)
|
||||||
|
|
||||||
|
# Get the currency result element
|
||||||
|
result = self._get_currency_result(converter_page)
|
||||||
|
|
||||||
|
# Should fall back to market/static rates
|
||||||
|
assert "Static rates" in result.text or "Live rates" in result.text
|
||||||
|
assert "Custom rate" not in result.text
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
Loading…
Add table
Add a link
Reference in a new issue