mirror of
https://github.com/tzuwei-huang/random-number-generator.git
synced 2026-08-24 14:10:29 +09:00
feat: add pure random function (#1)
* - Add "Pure Random" mode with repeat detection feature - Improve UI layout and styling for compact presentation screens - Introduce auto cleanup of existing processes on port 80 before startup - Update translations and language toggle functionalities - Remove unused test dependencies - Add Apache 2.0 license and update relevant metadata * fix: Update number pooling logic and adjust test for remaining count
This commit is contained in:
parent
e1c1b79d0f
commit
24d3f49101
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that you changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of Your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
24
README.md
24
README.md
@ -10,10 +10,15 @@
|
||||
A web-based random number generator built with FastAPI. It ensures fairness by picking numbers from a pre-defined pool and removing them until the pool is empty. It features a large, easy-to-read UI suitable for presentations or events.
|
||||
|
||||
### Features
|
||||
- **Fair Picking**: Each number is drawn only once from the specified range.
|
||||
- **Two Draw Modes**:
|
||||
- **Fair Mode (No Repeat)**: Each number is drawn only once from the specified range.
|
||||
- **Pure Random Mode**: Numbers are drawn randomly and may repeat.
|
||||
- **Repeat Detection**: In Pure Random mode, a "Repeat!" badge appears if a number has been drawn before.
|
||||
- **Auto Zero-padding**: Numbers are automatically padded with leading zeros based on the maximum value (e.g., 001 to 100).
|
||||
- **Bilingual Support**: Supports both English and Traditional Chinese (Default: Traditional Chinese).
|
||||
- **Large UI**: Optimized font sizes for high visibility and full-width container.
|
||||
- **Large & Compact UI**: Optimized for high visibility while remaining compact for presentation screens.
|
||||
- **Smart Persistence**: Remembers your range and mode settings even when resetting or toggling modes.
|
||||
- **Process Management**: Automatically closes existing instances on port 80 when starting to ensure a smooth launch.
|
||||
- **Session-based**: Keeps track of drawn numbers and remaining pool within the session.
|
||||
- **Security**: Uses cryptographically strong random numbers (Python's `secrets` module) and limits range to 10,000 to prevent OOM.
|
||||
- **Fairness Verification**: Includes a script to verify the uniform distribution of results.
|
||||
@ -52,6 +57,9 @@ If you don't want to install Python, you can download the latest version from [G
|
||||
```
|
||||
2. Open your browser and navigate to `http://127.0.0.1`.
|
||||
|
||||
### License
|
||||
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 繁體中文
|
||||
@ -60,10 +68,15 @@ If you don't want to install Python, you can download the latest version from [G
|
||||
一個基於 FastAPI 開發的網頁版隨機抽號器。它透過從預定義的池中抽取號碼並移除,直到所有號碼都被抽完,從而確保公平性。具備超大字體介面,非常適合在簡報、抽獎或各種活動中使用。
|
||||
|
||||
### 功能特點
|
||||
- **公平抽籤**:在指定範圍內,每個數字只會被抽中一次。
|
||||
- **雙重抽選模式**:
|
||||
- **公平模式 (不重複)**:確保在指定範圍內,每個數字只會被抽中一次。
|
||||
- **純隨機模式**:每次抽取均為獨立隨機,數字可能會重複出現。
|
||||
- **重複偵測**:在「純隨機模式」下,若抽中已出現過的號碼,會顯示「重複!」標籤提示。
|
||||
- **自動補零**:根據最大值自動補齊前導零(例如:範圍 1-100 時顯示 001)。
|
||||
- **雙語支持**:支援英文與繁體中文(預設為繁體中文)。
|
||||
- **超大 UI**:針對高能見度優化的字體大小,以及自動適應寬度的容器。
|
||||
- **清晰緊緻 UI**:針對高能見度優化的超大字體,同時保持版面緊緻,適配各種簡報螢幕。
|
||||
- **智慧設定記憶**:在重置範圍或切換模式時,系統會自動保留您的輸入數值與模式選擇。
|
||||
- **行程自動清理**:啟動時會自動偵測並關閉舊有佔用 80 端口的行程,確保順利執行。
|
||||
- **會話管理**:在瀏覽器會話中記錄已抽出的號碼與剩餘數量。
|
||||
- **安全性**:使用密碼學等級的隨機數生成器(Python 的 `secrets` 模組),並限制範圍上限為 10,000 以防止記憶體溢出。
|
||||
- **公平性驗證**:內建驗證腳本,可測試結果的均勻分佈。
|
||||
@ -101,3 +114,6 @@ uv run python tests/verify_fairness.py
|
||||
uv run python exec.py
|
||||
```
|
||||
2. 開啟瀏覽器並造訪 `http://127.0.0.1`。
|
||||
|
||||
### 授權條款
|
||||
本專案採用 Apache License 2.0 授權。詳情請參閱 [LICENSE](LICENSE) 檔案。
|
||||
|
||||
40
exec.py
40
exec.py
@ -2,26 +2,42 @@ import webbrowser
|
||||
import uvicorn
|
||||
import sys
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
from threading import Timer
|
||||
from rng_app.app import app
|
||||
|
||||
def open_browser():
|
||||
webbrowser.open_new("http://127.0.0.1")
|
||||
|
||||
def is_already_running():
|
||||
mutex_name = "Global\\RandomPickerMutex_Unique_ID_12345"
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
mutex = kernel32.CreateMutexW(None, False, mutex_name)
|
||||
last_error = kernel32.GetLastError()
|
||||
|
||||
if last_error == 183:
|
||||
return True
|
||||
return False
|
||||
def kill_existing_process(port=80):
|
||||
try:
|
||||
# Use errors='replace' to handle potential encoding issues on Windows
|
||||
output = subprocess.check_output(f'netstat -ano | findstr :{port}', shell=True).decode(errors='replace')
|
||||
for line in output.splitlines():
|
||||
if f':{port}' in line and 'LISTENING' in line:
|
||||
pid = line.strip().split()[-1]
|
||||
if int(pid) > 0 and int(pid) != os.getpid():
|
||||
subprocess.call(f'taskkill /F /PID {pid}', shell=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
current_pid = os.getpid()
|
||||
# Use errors='replace' and handle potential exceptions
|
||||
output = subprocess.check_output('tasklist /FI "IMAGENAME eq RandomPicker.exe" /FO CSV /NH', shell=True).decode(errors='replace')
|
||||
for line in output.splitlines():
|
||||
if line.strip():
|
||||
parts = line.split(',')
|
||||
if len(parts) > 1:
|
||||
pid = parts[1].strip('"')
|
||||
if pid.isdigit() and int(pid) != current_pid:
|
||||
subprocess.call(f'taskkill /F /PID {pid}', shell=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
if is_already_running():
|
||||
open_browser()
|
||||
sys.exit(0)
|
||||
kill_existing_process(80)
|
||||
|
||||
Timer(1, open_browser).start()
|
||||
try:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
name = "random-number-generator"
|
||||
version = "1.0.0"
|
||||
description = "A fair random number generator"
|
||||
license = {text = "Apache-2.0"}
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.128.0",
|
||||
|
||||
@ -27,7 +27,12 @@ TRANSLATIONS = {
|
||||
"reset": "Reset",
|
||||
"reset_range": "Reset Range",
|
||||
"drawn_numbers": "Drawn Numbers:",
|
||||
"lang_toggle": "繁體中文"
|
||||
"lang_toggle": "繁體中文",
|
||||
"mode": "Mode:",
|
||||
"mode_fair": "Fair (No Repeat)",
|
||||
"mode_random": "Pure Random",
|
||||
"toggle_mode": "Switch Mode",
|
||||
"repeat": "Repeat!"
|
||||
},
|
||||
"zh": {
|
||||
"title": "🎲 隨機抽號器",
|
||||
@ -43,10 +48,15 @@ TRANSLATIONS = {
|
||||
"reset": "重置",
|
||||
"reset_range": "重新設定範圍",
|
||||
"drawn_numbers": "已抽出的號碼:",
|
||||
"lang_toggle": "English"
|
||||
"lang_toggle": "English",
|
||||
"mode": "模式:",
|
||||
"mode_fair": "公平模式 (不重複)",
|
||||
"mode_random": "純隨機模式",
|
||||
"toggle_mode": "切換模式",
|
||||
"repeat": "重複!"
|
||||
}
|
||||
}
|
||||
from number_generator.number_generator import pick_and_remove
|
||||
from number_generator.number_generator import pick_and_remove, random_generate_number
|
||||
|
||||
MAX_RANGE_SIZE = 10000
|
||||
|
||||
@ -64,6 +74,7 @@ def initialize_pool(request: Request, min_val: int, max_val: int):
|
||||
request.session["pool"] = updated_pool
|
||||
request.session["last_number"] = number
|
||||
request.session["drawn"] = [number]
|
||||
request.session["is_repeat"] = False
|
||||
|
||||
template_dir = get_resource_path(os.path.join("rng_app", "templates"))
|
||||
templates = Jinja2Templates(directory=template_dir)
|
||||
@ -75,12 +86,16 @@ app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY",
|
||||
async def index(request: Request):
|
||||
if "lang" not in request.session:
|
||||
request.session["lang"] = "zh"
|
||||
if "mode" not in request.session:
|
||||
request.session["mode"] = "fair"
|
||||
|
||||
number = request.session.get("last_number")
|
||||
pool = request.session.get("pool", [])
|
||||
remaining_count = len(pool)
|
||||
is_repeat = request.session.get("is_repeat", False)
|
||||
|
||||
lang = request.session["lang"]
|
||||
mode = request.session["mode"]
|
||||
|
||||
current_range = request.session.get("current_range")
|
||||
formatted_number = None
|
||||
@ -100,32 +115,63 @@ async def index(request: Request):
|
||||
formatted_number = str(number)
|
||||
formatted_drawn = [str(n) for n in request.session.get("drawn", [])]
|
||||
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
return templates.TemplateResponse(request, "index.html", {
|
||||
"number": formatted_number,
|
||||
"remaining_count": remaining_count,
|
||||
"has_range": request.session.get("is_active", False),
|
||||
"range_vals": current_range,
|
||||
"drawn": formatted_drawn,
|
||||
"t": TRANSLATIONS.get(lang, TRANSLATIONS["en"]),
|
||||
"current_lang": lang
|
||||
"current_lang": lang,
|
||||
"mode": mode,
|
||||
"is_repeat": is_repeat
|
||||
})
|
||||
|
||||
@app.post("/")
|
||||
async def handle_post(request: Request, min: Optional[int] = Form(None), max: Optional[int] = Form(None)):
|
||||
if min is not None and max is not None:
|
||||
initialize_pool(request, min, max)
|
||||
elif "pool" in request.session and request.session["pool"]:
|
||||
pool = request.session["pool"]
|
||||
number, updated_pool = pick_and_remove(pool)
|
||||
request.session["pool"] = updated_pool
|
||||
request.session["last_number"] = number
|
||||
elif request.session.get("is_active"):
|
||||
mode = request.session.get("mode", "fair")
|
||||
number = None
|
||||
is_repeat = False
|
||||
|
||||
drawn = request.session.get("drawn", [])
|
||||
if number is not None:
|
||||
drawn.insert(0, number)
|
||||
request.session["drawn"] = drawn
|
||||
if mode == "fair":
|
||||
if "pool" in request.session and request.session["pool"]:
|
||||
pool = request.session["pool"]
|
||||
number, updated_pool = pick_and_remove(pool)
|
||||
request.session["pool"] = updated_pool
|
||||
else:
|
||||
current_range = request.session.get("current_range")
|
||||
if current_range:
|
||||
number = random_generate_number(current_range[0], current_range[1])
|
||||
drawn = request.session.get("drawn", [])
|
||||
if number in drawn:
|
||||
is_repeat = True
|
||||
|
||||
pool = request.session.get("pool", [])
|
||||
if number in pool:
|
||||
pool.remove(number)
|
||||
request.session["pool"] = pool
|
||||
|
||||
if number is not None:
|
||||
request.session["last_number"] = number
|
||||
request.session["is_repeat"] = is_repeat
|
||||
drawn = request.session.get("drawn", [])
|
||||
drawn.insert(0, number)
|
||||
request.session["drawn"] = drawn
|
||||
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.get("/toggle_mode")
|
||||
@app.post("/toggle_mode")
|
||||
async def toggle_mode(request: Request, min: Optional[int] = Form(None), max: Optional[int] = Form(None)):
|
||||
current_mode = request.session.get("mode", "fair")
|
||||
request.session["mode"] = "random" if current_mode == "fair" else "fair"
|
||||
|
||||
if min is not None and max is not None:
|
||||
request.session["current_range"] = [min, max]
|
||||
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.get("/set_lang/{lang}")
|
||||
@ -144,11 +190,13 @@ async def restart(request: Request):
|
||||
@app.get("/reset")
|
||||
async def reset(request: Request):
|
||||
lang = request.session.get("lang", "zh")
|
||||
mode = request.session.get("mode", "fair")
|
||||
current_range = request.session.get("current_range")
|
||||
|
||||
request.session.clear()
|
||||
|
||||
request.session["lang"] = lang
|
||||
request.session["mode"] = mode
|
||||
request.session["is_active"] = False
|
||||
if current_range:
|
||||
request.session["current_range"] = current_range
|
||||
|
||||
@ -3,25 +3,31 @@
|
||||
<head>
|
||||
<title>Fair Random Number Generator</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; margin: 20px 0; background-color: #f4f7f6; font-size: 1.2em; }
|
||||
.container { width: 98%; max-width: 98%; margin: 0 auto; padding: 40px 20px; background: white; border: 1px solid #eee; border-radius: 15px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); position: relative; box-sizing: border-box; }
|
||||
.lang-switch { position: absolute; top: 20px; right: 20px; }
|
||||
.lang-link { text-decoration: none; color: #3498db; font-size: 1.2em; font-weight: bold; }
|
||||
h1 { color: #2c3e50; margin-bottom: 30px; font-size: 3em; }
|
||||
.result { font-size: 16em; font-weight: bold; color: #e74c3c; margin: 30px 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.1); }
|
||||
.info { font-size: 2em; color: #7f8c8d; margin-bottom: 30px; }
|
||||
button { padding: 20px 40px; font-size: 2em; cursor: pointer; transition: all 0.2s; }
|
||||
body { font-family: sans-serif; text-align: center; margin: 10px 0; background-color: #f4f7f6; font-size: 1.1em; }
|
||||
.container { width: 98%; max-width: 98%; margin: 0 auto; padding: 20px 20px; background: white; border: 1px solid #eee; border-radius: 15px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); position: relative; box-sizing: border-box; }
|
||||
.lang-switch { position: absolute; top: 15px; right: 20px; }
|
||||
.lang-link { text-decoration: none; color: #3498db; font-size: 1.1em; font-weight: bold; }
|
||||
h1 { color: #2c3e50; margin-bottom: 15px; font-size: 2.2em; margin-top: 10px; }
|
||||
.result { font-size: 12em; font-weight: bold; color: #e74c3c; margin: 15px 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.1); line-height: 1; position: relative; display: inline-block; }
|
||||
.info { font-size: 1.5em; color: #7f8c8d; margin-bottom: 15px; }
|
||||
button { padding: 15px 30px; font-size: 1.8em; cursor: pointer; transition: all 0.2s; }
|
||||
button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
|
||||
button:active { transform: translateY(0); }
|
||||
.reset-btn { background-color: #f8f9fa; border: 1px solid #dee2e6; color: #6c757d; text-decoration: none; padding: 15px 30px; border-radius: 6px; display: inline-block; margin-top: 20px; font-size: 1.3em; }
|
||||
.reset-btn { background-color: #f8f9fa; border: 1px solid #dee2e6; color: #6c757d; text-decoration: none; padding: 10px 20px; border-radius: 6px; display: inline-block; margin-top: 15px; font-size: 1.1em; }
|
||||
.reset-btn:hover { background-color: #e9ecef; }
|
||||
.generate-btn { background-color: #2ecc71; border: none; color: white; border-radius: 6px; font-weight: bold; }
|
||||
.generate-btn:hover { background-color: #27ae60; }
|
||||
input[type="number"] { padding: 15px; border: 2px solid #edeff2; border-radius: 8px; width: 150px; outline: none; font-size: 2em; text-align: center; }
|
||||
input[type="number"] { padding: 10px; border: 2px solid #edeff2; border-radius: 8px; width: 120px; outline: none; font-size: 1.8em; text-align: center; }
|
||||
input[type="number"]:focus { border-color: #3498db; }
|
||||
label { font-size: 2em; color: #34495e; margin-right: 10px; }
|
||||
.drawn-list { margin-top: 40px; text-align: left; background: #fdfdfd; padding: 25px; border: 1px dashed #dcdde1; border-radius: 10px; }
|
||||
.drawn-numbers { font-size: 3em; font-weight: bold; color: #34495e; word-wrap: break-word; line-height: 1.2; margin-top: 10px; }
|
||||
label { font-size: 1.8em; color: #34495e; margin-right: 10px; }
|
||||
.drawn-list { margin-top: 25px; text-align: left; background: #fdfdfd; padding: 20px; border: 1px dashed #dcdde1; border-radius: 10px; }
|
||||
.drawn-numbers { font-size: 2.2em; font-weight: bold; color: #34495e; word-wrap: break-word; line-height: 1.2; margin-top: 10px; }
|
||||
.mode-container { margin: 10px 0; font-size: 1.2em; color: #7f8c8d; }
|
||||
.mode-badge { padding: 5px 15px; border-radius: 20px; font-weight: bold; background-color: #ecf0f1; color: #34495e; margin: 0 10px; }
|
||||
.mode-toggle-btn { background-color: #3498db; border: none; color: white; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 1em; text-decoration: none; display: inline-block; transition: background 0.2s; }
|
||||
.mode-toggle-btn:hover { background-color: #2980b9; }
|
||||
.repeat-badge { background-color: #ff9f43; color: white; padding: 5px 15px; border-radius: 50px; font-size: 0.15em; vertical-align: middle; margin-left: 10px; box-shadow: 0 4px 10px rgba(230, 126, 34, 0.3); animation: bounce 0.5s ease infinite alternate; }
|
||||
@keyframes bounce { from { transform: translateY(0); } to { transform: translateY(-5px); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -37,6 +43,13 @@
|
||||
|
||||
{% if not has_range %}
|
||||
<form method="post">
|
||||
<div class="mode-container">
|
||||
{{ t.mode }}
|
||||
<span class="mode-badge">
|
||||
{% if mode == 'fair' %}{{ t.mode_fair }}{% else %}{{ t.mode_random }}{% endif %}
|
||||
</span>
|
||||
<button type="submit" formaction="/toggle_mode" class="mode-toggle-btn" style="border: none; cursor: pointer;">{{ t.toggle_mode }}</button>
|
||||
</div>
|
||||
<p style="font-size: 2em; color: #34495e;">{{ t.enter_range }}</p>
|
||||
<label>{{ t.min }}</label>
|
||||
<input type="number" name="min" value="{{ range_vals[0] if range_vals else 1 }}" min="0" required>
|
||||
@ -46,13 +59,25 @@
|
||||
<button type="submit" class="generate-btn">{{ t.start }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="mode-container">
|
||||
{{ t.mode }}
|
||||
<span class="mode-badge">
|
||||
{% if mode == 'fair' %}{{ t.mode_fair }}{% else %}{{ t.mode_random }}{% endif %}
|
||||
</span>
|
||||
<a href="/toggle_mode" class="mode-toggle-btn">{{ t.toggle_mode }}</a>
|
||||
</div>
|
||||
<div class="info">
|
||||
{{ t.range }} {{ range_vals[0] }} ~ {{ range_vals[1] }}<br>
|
||||
{{ t.remaining }} {{ remaining_count }}
|
||||
</div>
|
||||
|
||||
{% if number is not none %}
|
||||
<div class="result">{{ number }}</div>
|
||||
<div class="result">
|
||||
{{ number }}
|
||||
{% if is_repeat %}
|
||||
<span class="repeat-badge">{{ t.repeat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="result">?</div>
|
||||
{% endif %}
|
||||
|
||||
@ -8,26 +8,23 @@ client = TestClient(app)
|
||||
def test_index_default_lang():
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# 預設應為繁體中文
|
||||
|
||||
assert "隨機抽號器" in response.text
|
||||
assert "輸入範圍" in response.text
|
||||
|
||||
def test_language_toggle():
|
||||
# 切換到英文
|
||||
response = client.get("/set_lang/en", follow_redirects=True)
|
||||
assert "Random Picker" in response.text
|
||||
assert "Enter the range:" in response.text
|
||||
|
||||
# 切換回中文
|
||||
response = client.get("/set_lang/zh", follow_redirects=True)
|
||||
assert "隨機抽號器" in response.text
|
||||
|
||||
def test_set_range_with_padding():
|
||||
# 設定範圍 1-100 (補足 3 位)
|
||||
response = client.post("/", data={"min": 1, "max": 100}, follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert "範圍: 1 ~ 100" in response.text
|
||||
# 第一個號碼應該補足 3 位
|
||||
|
||||
match = re.search(r'<div class="result">\s*(\d{3})\s*</div>', response.text)
|
||||
assert match is not None
|
||||
print(f"Padded number: {match.group(1)}")
|
||||
@ -59,7 +56,7 @@ def test_restart_functionality():
|
||||
client.post("/", follow_redirects=True)
|
||||
|
||||
response = client.get("/restart", follow_redirects=True)
|
||||
assert "剩餘數量: 9" in response.text # 重新初始化後抽出第 1 個
|
||||
assert "剩餘數量: 9" in response.text
|
||||
assert "範圍: 1 ~ 10" in response.text
|
||||
|
||||
def test_reset_functionality():
|
||||
@ -77,6 +74,47 @@ def test_max_range_limit():
|
||||
response = client.post("/", data={"min": 1, "max": 10001}, follow_redirects=True)
|
||||
assert 'class="info"' not in response.text
|
||||
|
||||
def test_mode_toggle():
|
||||
response = client.get("/")
|
||||
assert "公平模式 (不重複)" in response.text
|
||||
|
||||
response = client.get("/toggle_mode", follow_redirects=True)
|
||||
assert "純隨機模式" in response.text
|
||||
|
||||
response = client.get("/toggle_mode", follow_redirects=True)
|
||||
assert "公平模式 (不重複)" in response.text
|
||||
|
||||
def test_pure_random_mode_draw():
|
||||
client.post("/", data={"min": 1, "max": 10}, follow_redirects=True)
|
||||
client.get("/toggle_mode")
|
||||
|
||||
client.post("/", follow_redirects=True)
|
||||
response = client.post("/", follow_redirects=True)
|
||||
|
||||
match = re.search(r'<div class="drawn-numbers">([\d, ]+)</div>', response.text)
|
||||
drawn_str = match.group(1)
|
||||
numbers = [n.strip() for n in drawn_str.split(',')]
|
||||
assert len(numbers) == 3
|
||||
for n in numbers:
|
||||
assert len(n) == 2
|
||||
|
||||
|
||||
remaining_match = re.search(r'剩餘數量: (\d+)', response.text)
|
||||
remaining = int(remaining_match.group(1))
|
||||
assert remaining < 9
|
||||
|
||||
def test_repeat_detection():
|
||||
client.get("/reset")
|
||||
response = client.get("/")
|
||||
if "純隨機模式" in response.text or "Pure Random" in response.text:
|
||||
client.get("/toggle_mode")
|
||||
|
||||
client.post("/", data={"min": 1, "max": 1}, follow_redirects=True)
|
||||
client.get("/toggle_mode")
|
||||
client.post("/", follow_redirects=True)
|
||||
response = client.post("/", follow_redirects=True)
|
||||
assert "重複!" in response.text or "Repeat!" in response.text
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
pytest.main([__file__])
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@ -407,7 +407,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "random-number-generator"
|
||||
version = "0.1.0"
|
||||
version = "1.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
@ -421,9 +421,7 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@ -438,11 +436,7 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pyinstaller", specifier = ">=6.18.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
]
|
||||
dev = [{ name = "pyinstaller", specifier = ">=6.18.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user