diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..3da8d8167 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,41 @@ +# Sudoku Project Instructions + +This project is a Flask-based Sudoku application. + +Code Style: + +- Prefer readability over clever implementations. +- Use modern Python features and type hints where appropriate. +- Keep functions small and focused. +- Avoid duplicated code. +- Follow consistent naming conventions. +- Add comments only when they improve understanding. + +Architecture: + +- Separate game logic from Flask routes. +- Keep UI, business logic, and persistence concerns isolated. +- Prefer reusable helper functions and classes. + +Sudoku Requirements: + +- Every generated puzzle must have exactly one solution. +- Support Easy, Medium, and Hard difficulties. +- Prefilled cells must remain locked. +- Invalid moves should provide immediate visual feedback. +- Hint cells should become locked after being filled. +- Maintain a Top 10 leaderboard. +- Persist leaderboard data between sessions. + +Frontend Requirements: + +- Mobile responsive layout. +- Support light and dark themes. +- Alternate colors for 3x3 Sudoku regions. +- Maintain accessibility and readability. + +Testing: + +- Use pytest. +- Preserve existing behavior during refactoring. +- Add tests for Sudoku generation and validation logic.=[]p \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..1bd2c9ba7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.pythonProjects": [] +} diff --git "a/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png" new file mode 100644 index 000000000..3b2fd8a20 Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.17.37\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png" new file mode 100644 index 000000000..0fd756677 Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.17.52\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png" new file mode 100644 index 000000000..8f50a9619 Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.41.08\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png" new file mode 100644 index 000000000..4a7da95cf Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.44.25\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png" "b/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png" new file mode 100644 index 000000000..ea3e0df40 Binary files /dev/null and "b/Screenshot 2026-09-04 at 12.44.39\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png" new file mode 100644 index 000000000..490253aca Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.02.25\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png" new file mode 100644 index 000000000..06e85f1bf Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.02.33\342\200\257PM.png" differ diff --git "a/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png" "b/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png" new file mode 100644 index 000000000..83efd7018 Binary files /dev/null and "b/Screenshot 2026-09-04 at 4.11.23\342\200\257PM.png" differ diff --git a/starter/__pycache__/app.cpython-313.pyc b/starter/__pycache__/app.cpython-313.pyc new file mode 100644 index 000000000..3e78c18e5 Binary files /dev/null and b/starter/__pycache__/app.cpython-313.pyc differ diff --git a/starter/__pycache__/sudoku_logic.cpython-313.pyc b/starter/__pycache__/sudoku_logic.cpython-313.pyc new file mode 100644 index 000000000..147b382a9 Binary files /dev/null and b/starter/__pycache__/sudoku_logic.cpython-313.pyc differ diff --git a/starter/app.py b/starter/app.py index 0f526b757..213fd2628 100644 --- a/starter/app.py +++ b/starter/app.py @@ -15,11 +15,38 @@ def index(): @app.route('/new') def new_game(): - clues = int(request.args.get('clues', 35)) + difficulty = request.args.get('difficulty', 'medium') + + try: + clues = sudoku_logic.clue_count_for_difficulty(difficulty) + except ValueError as error: + return jsonify({'error': str(error)}), 400 + puzzle, solution = sudoku_logic.generate_puzzle(clues) CURRENT['puzzle'] = puzzle CURRENT['solution'] = solution + return jsonify({'puzzle': puzzle}) +@app.route('/hint', methods=['POST']) +def get_hint(): + data = request.json or {} + board = data.get('board') + puzzle = CURRENT.get('puzzle') + solution = CURRENT.get('solution') + + if puzzle is None or solution is None: + return jsonify({'error': 'No game in progress'}), 400 + + for row in range(sudoku_logic.SIZE): + for col in range(sudoku_logic.SIZE): + if puzzle[row][col] == sudoku_logic.EMPTY and board[row][col] == sudoku_logic.EMPTY: + return jsonify({ + 'row': row, + 'col': col, + 'value': solution[row][col], + }) + + return jsonify({'error': 'No empty cells available'}), 400 @app.route('/check', methods=['POST']) def check_solution(): diff --git a/starter/requirements.txt b/starter/requirements.txt index 3ab9a28ba..cf3289aa0 100644 --- a/starter/requirements.txt +++ b/starter/requirements.txt @@ -1 +1,2 @@ Flask>=2.0 +pytest>=8.0 diff --git a/starter/static/main.js b/starter/static/main.js index 2028e1026..00e85eafe 100644 --- a/starter/static/main.js +++ b/starter/static/main.js @@ -1,23 +1,136 @@ +let gameCompleted = false; // Client-side rendering and interaction for the Flask-backed Sudoku +let timerInterval = null; +let timerStart = null; const SIZE = 9; let puzzle = []; +let hintsUsed = 0; +let currentDifficulty = 'medium'; +const LEADERBOARD_KEY = 'sudokuLeaderboard'; +const THEME_KEY = 'sudokuTheme'; + +function applyTheme(theme) { + const isDark = theme === 'dark'; + + document.documentElement.dataset.theme = isDark ? 'dark' : 'light'; + + const toggle = document.getElementById('theme-toggle'); + toggle.setAttribute('aria-pressed', String(isDark)); + toggle.textContent = isDark ? 'Light mode' : 'Dark mode'; +} + +function initializeTheme() { + const savedTheme = localStorage.getItem(THEME_KEY); + const theme = savedTheme === 'dark' ? 'dark' : 'light'; + + applyTheme(theme); + + document.getElementById('theme-toggle').addEventListener('click', () => { + const nextTheme = + document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + + localStorage.setItem(THEME_KEY, nextTheme); + applyTheme(nextTheme); + }); +} +function loadLeaderboard() { + try { + const scores = JSON.parse(localStorage.getItem(LEADERBOARD_KEY) || '[]'); + return Array.isArray(scores) ? scores : []; + } catch { + return []; + } +} + +function saveScore(score) { + const scores = [...loadLeaderboard(), score]; + + scores.sort( + (left, right) => + left.timeSeconds - right.timeSeconds || left.hintsUsed - right.hintsUsed, + ); + + localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(scores.slice(0, 10))); +} + +function formatScoreTime(seconds) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + return `${String(minutes).padStart(2, '0')}:${String( + remainingSeconds, + ).padStart(2, '0')}`; +} + +function renderLeaderboard() { + const leaderboardBody = document.getElementById('leaderboard-body'); + + if (!leaderboardBody) { + return; + } + + leaderboardBody.innerHTML = ''; + + loadLeaderboard().forEach((score, index) => { + const row = document.createElement('tr'); + + [ + index + 1, + score.name, + formatScoreTime(score.timeSeconds), + score.difficulty, + score.hintsUsed, + ].forEach((value) => { + const cell = document.createElement('td'); + cell.textContent = value; + row.appendChild(cell); + }); + + leaderboardBody.appendChild(row); + }); +} + +function recordScore() { + const nameInput = document.getElementById('player-name'); + const name = nameInput.value.trim() || 'Anonymous'; + const timeSeconds = Math.floor((Date.now() - timerStart) / 1000); + + saveScore({ + name, + timeSeconds, + difficulty: currentDifficulty, + hintsUsed, + completedAt: new Date().toISOString(), + }); + + renderLeaderboard(); +} function createBoardElement() { const boardDiv = document.getElementById('sudoku-board'); + boardDiv.innerHTML = ''; for (let i = 0; i < SIZE; i++) { const rowDiv = document.createElement('div'); rowDiv.className = 'sudoku-row'; for (let j = 0; j < SIZE; j++) { const input = document.createElement('input'); + input.dataset.region = + (Math.floor(i / 3) + Math.floor(j / 3)) % 2 === 0 + ? 'base' + : 'alternate'; input.type = 'text'; input.maxLength = 1; input.className = 'sudoku-cell'; input.dataset.row = i; input.dataset.col = j; - input.addEventListener('input', (e) => { - const val = e.target.value.replace(/[^1-9]/g, ''); - e.target.value = val; + input.addEventListener('input', (event) => { + if (gameCompleted) { + return; + } + const value = event.target.value.replace(/[^1-9]/g, ''); + event.target.value = value; + validateCell(event.target); }); rowDiv.appendChild(input); } @@ -25,7 +138,30 @@ function createBoardElement() { } } +function stopTimer() { + clearInterval(timerInterval); + timerInterval = null; +} + +function finishGame() { + gameCompleted = true; + stopTimer(); + + const inputs = document + .getElementById('sudoku-board') + .getElementsByTagName('input'); + + for (const input of inputs) { + input.disabled = true; + } + + const message = document.getElementById('message'); + message.className = 'message-success'; + message.innerText = 'Congratulations! You solved it!'; +} + function renderPuzzle(puz) { + gameCompleted = false; puzzle = puz; createBoardElement(); const boardDiv = document.getElementById('sudoku-board'); @@ -47,14 +183,147 @@ function renderPuzzle(puz) { } } +function getCurrentBoard() { + const inputs = document + .getElementById('sudoku-board') + .getElementsByTagName('input'); + + const board = []; + + for (let row = 0; row < SIZE; row++) { + board[row] = []; + + for (let col = 0; col < SIZE; col++) { + const input = inputs[row * SIZE + col]; + board[row][col] = input.value ? parseInt(input.value, 10) : 0; + } + } + + return board; +} + +async function requestHint() { + const res = await fetch('/hint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: getCurrentBoard() }), + }); + + const data = await res.json(); + const message = document.getElementById('message'); + + if (data.error) { + message.style.color = '#d32f2f'; + message.innerText = data.error; + return; + } + + const input = document.querySelector( + `.sudoku-cell[data-row="${data.row}"][data-col="${data.col}"]`, + ); + + input.value = data.value; + input.disabled = true; + input.className = 'sudoku-cell hinted'; + hintsUsed += 1; + + message.className = 'message-success'; + message.innerText = 'Hint added.'; +} + +function isValidMove(board, row, col, value) { + for (let index = 0; index < SIZE; index++) { + if (index !== col && board[row][index] === value) { + return false; + } + + if (index !== row && board[index][col] === value) { + return false; + } + } + + const boxRow = row - (row % 3); + const boxCol = col - (col % 3); + + for (let boxRowIndex = boxRow; boxRowIndex < boxRow + 3; boxRowIndex++) { + for (let boxColIndex = boxCol; boxColIndex < boxCol + 3; boxColIndex++) { + if ( + (boxRowIndex !== row || boxColIndex !== col) && + board[boxRowIndex][boxColIndex] === value + ) { + return false; + } + } + } + + return true; +} + +function validateCell(input) { + if (gameCompleted) { + return; + } + const value = input.value ? parseInt(input.value, 10) : 0; + + input.classList.remove('invalid'); + + if (!value) { + return; + } + + const board = getCurrentBoard(); + const row = Number(input.dataset.row); + const col = Number(input.dataset.col); + + if (!isValidMove(board, row, col, value)) { + input.classList.add('invalid'); + } +} + +function formatElapsedTime(milliseconds) { + const totalSeconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function startTimer() { + clearInterval(timerInterval); + + timerStart = Date.now(); + document.getElementById('timer').innerText = '00:00'; + + timerInterval = setInterval(() => { + const elapsed = Date.now() - timerStart; + document.getElementById('timer').innerText = formatElapsedTime(elapsed); + }, 1000); +} + async function newGame() { - const res = await fetch('/new'); + const difficulty = document.getElementById('difficulty').value; + const res = await fetch(`/new?difficulty=${encodeURIComponent(difficulty)}`); const data = await res.json(); + + if (data.error) { + document.getElementById('message').innerText = data.error; + return; + } + + currentDifficulty = difficulty; + hintsUsed = 0; + renderPuzzle(data.puzzle); - document.getElementById('message').innerText = ''; + startTimer(); + const message = document.getElementById('message'); + message.className = ''; + message.innerText = ''; } async function checkSolution() { + if (gameCompleted) { + return; + } const boardDiv = document.getElementById('sudoku-board'); const inputs = boardDiv.getElementsByTagName('input'); const board = []; @@ -68,17 +337,17 @@ async function checkSolution() { } const res = await fetch('/check', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({board}) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board }), }); const data = await res.json(); const msg = document.getElementById('message'); if (data.error) { - msg.style.color = '#d32f2f'; - msg.innerText = data.error; + msg.className = 'message-error'; + msg.innerText = 'Some cells are incorrect.'; return; } - const incorrect = new Set(data.incorrect.map(x => x[0]*SIZE + x[1])); + const incorrect = new Set(data.incorrect.map((x) => x[0] * SIZE + x[1])); for (let idx = 0; idx < inputs.length; idx++) { const inp = inputs[idx]; if (inp.disabled) continue; @@ -88,8 +357,8 @@ async function checkSolution() { } } if (incorrect.size === 0) { - msg.style.color = '#388e3c'; - msg.innerText = 'Congratulations! You solved it!'; + finishGame(); + recordScore(); } else { msg.style.color = '#d32f2f'; msg.innerText = 'Some cells are incorrect.'; @@ -98,8 +367,15 @@ async function checkSolution() { // Wire buttons window.addEventListener('load', () => { + initializeTheme(); + document.getElementById('new-game').addEventListener('click', newGame); - document.getElementById('check-solution').addEventListener('click', checkSolution); - // initialize + document.getElementById('difficulty').addEventListener('change', newGame); + document + .getElementById('check-solution') + .addEventListener('click', checkSolution); + document.getElementById('hint').addEventListener('click', requestHint); + + renderLeaderboard(); newGame(); -}); \ No newline at end of file +}); diff --git a/starter/static/styles.css b/starter/static/styles.css index 1a6218ff9..7e0c0a71e 100644 --- a/starter/static/styles.css +++ b/starter/static/styles.css @@ -1,85 +1,269 @@ +:root { + color-scheme: light; + --page-bg: #f4f4f4; + --text: #333; + --surface: #fff; + --cell-bg: #fafafa; + --border: #bbb; + --strong-border: #333; + --prefilled-bg: #e0e0e0; + --focus-bg: #e0f7fa; + --hint-bg: #fff3cd; + --hint-text: #856404; + --invalid-bg: #ffcdd2; + --invalid-border: #d32f2f; + --accent: #1976d2; + --accent-hover: #1565c0; + --region-base-bg: #fafafa; + --region-alternate-bg: #eef5ff; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --page-bg: #121212; + --text: #f1f1f1; + --surface: #1e1e1e; + --cell-bg: #252525; + --border: #666; + --strong-border: #d6d6d6; + --prefilled-bg: #3a3a3a; + --focus-bg: #164e63; + --hint-bg: #66551c; + --hint-text: #fff3a3; + --invalid-bg: #6b2525; + --invalid-border: #ef5350; + --accent: #42a5f5; + --accent-hover: #64b5f6; + --region-base-bg: #252525; + --region-alternate-bg: #303b46; +} + body { - font-family: Arial, sans-serif; - background: #f4f4f4; - text-align: center; - margin: 0; - padding: 0; + font-family: Arial, sans-serif; + background: var(--page-bg); + color: var(--text); + text-align: center; + margin: 0; + padding: 0; } h1 { - margin-top: 30px; - color: #333; + margin-top: 30px; + font-size: clamp(1.5rem, 8vw, 2rem); + margin: 20px 0; + color: var(--text); } #sudoku-board { - display: inline-block; - margin: 30px auto; - border: 4px solid #333; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + display: inline-block; + margin: 30px auto; + border: 4px solid var(--strong-border); + background: var(--surface); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + width: min(calc(100vw - 24px), 386px); + box-sizing: border-box; } .sudoku-row { - display: flex; + display: flex; + width: 100%; } .sudoku-cell { - width: 40px; - height: 40px; - border: 1px solid #bbb; - text-align: center; - font-size: 20px; - outline: none; - background: #fafafa; - transition: background 0.2s; + min-width: 0; + flex: 1 1 0; + display: block; + box-sizing: border-box; + width: auto; + height: auto; + aspect-ratio: 1; + padding: 0; + text-align: center; + line-height: 1; + font-family: inherit; + font-family: 'Trebuchet MS', sans-serif; + font-size: clamp(1rem, 5vw, 1.35rem); + font-weight: 700; + line-height: 1; + letter-spacing: 0; + text-align: center; + color: var(--text); + text-indent: 0; +} + +#sudoku-board { + max-width: 100%; + overflow: hidden; +} + +.sudoku-cell[data-region='alternate'] { + background: var(--region-alternate-bg); } .sudoku-cell:focus { - background: #e0f7fa; + background: var(--focus-bg); } .sudoku-cell.prefilled { - background: #e0e0e0; - font-weight: bold; - color: #333; + background: var(--prefilled-bg); + font-weight: bold; + color: var(--text); } -.sudoku-cell.incorrect { - background: #ffcdd2; +.sudoku-cell.incorrect, +.sudoku-cell.invalid { + background: var(--invalid-bg); + border-color: var(--invalid-border); +} + +.sudoku-cell.hinted { + background: var(--hint-bg); + font-weight: bold; + color: var(--hint-text); } .sudoku-cell:nth-child(3), .sudoku-cell:nth-child(6) { - border-right: 3px solid #333; + border-right: 3px solid var(--strong-border); } .sudoku-row:nth-child(3) .sudoku-cell, .sudoku-row:nth-child(6) .sudoku-cell { - border-bottom: 3px solid #333; + border-bottom: 3px solid var(--strong-border); } .controls { - margin: 20px auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px; + padding-inline: 12px; +} + +.controls button { + margin: 0; +} + +.controls input, +.controls select, +.controls button { + box-sizing: border-box; + height: 38px; + margin: 0; +} + +.controls label { + white-space: nowrap; +} + +#timer { + min-width: 86px; } button { - padding: 8px 18px; - margin: 0 8px; - font-size: 16px; - border: none; - background: #1976d2; - color: #fff; - border-radius: 4px; - cursor: pointer; - transition: background 0.2s; + min-height: 38px; + padding: 8px 16px; + margin: 0; + border: 1px solid var(--accent); + border-radius: 5px; + font-family: inherit; + font-size: 15px; + font-weight: 600; + line-height: 1; + background: var(--accent); + color: #fff; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: + background 0.2s, + transform 0.2s, + box-shadow 0.2s; } button:hover { - background: #1565c0; + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.25); +} + +button:focus-visible { + outline: 3px solid var(--focus-bg); + outline-offset: 2px; } #message { - margin-left: 20px; - font-size: 16px; - color: #d32f2f; + flex-basis: 100%; + margin-left: 0; + font-size: 16px; +} + +.message-success { + color: #388e3c; +} + +.message-error { + color: #ef5350; +} + +#leaderboard { + width: min(90%, 700px); + margin: 40px auto; + overflow-x: auto; +} + +#leaderboard table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + min-width: 520px; +} + +#leaderboard th, +#leaderboard td { + padding: 10px; + border: 1px solid var(--border); + text-align: center; + padding: 8px 6px; + font-size: clamp(0.75rem, 2.5vw, 1rem); + overflow-wrap: anywhere; +} + +#leaderboard th { + background: var(--accent); + color: #fff; +} + +#leaderboard td { + color: var(--text); +} + +#leaderboard tbody tr:nth-child(even) { + background: var(--page-bg); +} + +#player-name, +#difficulty { + padding: 8px; + font-size: 16px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + +#player-name { + max-width: 180px; +} + +#theme-toggle { + position: absolute; + top: 16px; + right: 16px; +} + +@media (max-width: 480px) { + #theme-toggle { + position: static; + margin-bottom: 8px; + } } diff --git a/starter/sudoku_logic.py b/starter/sudoku_logic.py index 443b24524..f3497424e 100644 --- a/starter/sudoku_logic.py +++ b/starter/sudoku_logic.py @@ -4,54 +4,121 @@ SIZE = 9 EMPTY = 0 + def deep_copy(board): return copy.deepcopy(board) + def create_empty_board(): return [[EMPTY for _ in range(SIZE)] for _ in range(SIZE)] + def is_safe(board, row, col, num): - # Check row and column for x in range(SIZE): if board[row][x] == num or board[x][col] == num: return False - # Check 3x3 box + start_row = row - row % 3 start_col = col - col % 3 + for i in range(3): for j in range(3): if board[start_row + i][start_col + j] == num: return False + return True + def fill_board(board): for row in range(SIZE): for col in range(SIZE): if board[row][col] == EMPTY: possible = list(range(1, SIZE + 1)) random.shuffle(possible) + for candidate in possible: if is_safe(board, row, col, candidate): board[row][col] = candidate + if fill_board(board): return True + board[row][col] = EMPTY + return False + return True + +def count_solutions(board, limit=2): + """Return the number of solutions, capped at limit.""" + solutions = 0 + + for row in range(SIZE): + for col in range(SIZE): + if board[row][col] == EMPTY: + for candidate in range(1, SIZE + 1): + if is_safe(board, row, col, candidate): + board[row][col] = candidate + solutions += count_solutions(board, limit) + + board[row][col] = EMPTY + + if solutions >= limit: + return solutions + + return solutions + + return 1 + + def remove_cells(board, clues): - attempts = SIZE * SIZE - clues - while attempts > 0: - row = random.randrange(SIZE) - col = random.randrange(SIZE) - if board[row][col] != EMPTY: - board[row][col] = EMPTY - attempts -= 1 + if not 0 <= clues <= SIZE * SIZE: + raise ValueError("clues must be between 0 and 81") + + positions = [ + (row, col) + for row in range(SIZE) + for col in range(SIZE) + if board[row][col] != EMPTY + ] + random.shuffle(positions) + + for row, col in positions: + if sum( + cell != EMPTY + for current_row in board + for cell in current_row + ) <= clues: + break + + value = board[row][col] + board[row][col] = EMPTY + + if count_solutions(board) != 1: + board[row][col] = value + +DIFFICULTY_CLUES = { + "easy": 45, + "medium": 35, + "hard": 25, +} + + +def clue_count_for_difficulty(difficulty): + try: + return DIFFICULTY_CLUES[difficulty.lower()] + except (AttributeError, KeyError): + raise ValueError( + "difficulty must be easy, medium, or hard" + ) def generate_puzzle(clues=35): board = create_empty_board() fill_board(board) + solution = deep_copy(board) remove_cells(board, clues) + puzzle = deep_copy(board) - return puzzle, solution + return puzzle, solution \ No newline at end of file diff --git a/starter/templates/index.html b/starter/templates/index.html index e42ad04da..8f618b5e6 100644 --- a/starter/templates/index.html +++ b/starter/templates/index.html @@ -1,18 +1,61 @@ - - - - Sudoku Game - - - -

Sudoku Game

-
-
- - - -
- - - \ No newline at end of file + + + + + Sudoku Game + + + +
+

Sudoku Game

+ + +
+ +
+ + + + + + + + + Time: 00:00 + + +
+ +
+

Top 10 Leaderboard

+ + + + + + + + + + + + +
RankNameTimeDifficultyHints
+
+
+ + + + diff --git a/starter/tests/__init__.py b/starter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/starter/tests/__pycache__/__init__.cpython-313.pyc b/starter/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 000000000..1d9ca5852 Binary files /dev/null and b/starter/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 000000000..89a798104 Binary files /dev/null and b/starter/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc differ diff --git a/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 000000000..b92c5e1cf Binary files /dev/null and b/starter/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc differ diff --git a/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 000000000..b92c5e1cf Binary files /dev/null and b/starter/tests/__pycache__/test_app.cpython-313-pytest-9.1.1.pyc differ diff --git a/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 000000000..4dba858c4 Binary files /dev/null and b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-8.4.2.pyc differ diff --git a/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 000000000..4dba858c4 Binary files /dev/null and b/starter/tests/__pycache__/test_sudoku_logic.cpython-313-pytest-9.1.1.pyc differ diff --git a/starter/tests/conftest.py b/starter/tests/conftest.py new file mode 100644 index 000000000..87c62a506 --- /dev/null +++ b/starter/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +# Add parent directory (starter/) to sys.path so tests can import app and sudoku_logic +STARTER_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(STARTER_DIR)) \ No newline at end of file diff --git a/starter/tests/test_app.py b/starter/tests/test_app.py new file mode 100644 index 000000000..c20e7739f --- /dev/null +++ b/starter/tests/test_app.py @@ -0,0 +1,101 @@ +import app as app_module +import sudoku_logic +import pytest + + +@pytest.fixture +def client(): + app_module.app.config["TESTING"] = True + app_module.CURRENT["puzzle"] = None + app_module.CURRENT["solution"] = None + + with app_module.app.test_client() as test_client: + yield test_client + + app_module.CURRENT["puzzle"] = None + app_module.CURRENT["solution"] = None + + +def test_index_returns_game_page(client): + response = client.get("/") + + assert response.status_code == 200 + assert b"Sudoku Game" in response.data + + +def test_new_game_returns_puzzle(client): + response = client.get("/new?clues=35") + + assert response.status_code == 200 + data = response.get_json() + + assert "puzzle" in data + assert len(data["puzzle"]) == 9 + assert all(len(row) == 9 for row in data["puzzle"]) + + +def test_check_requires_game_in_progress(client): + response = client.post("/check", json={"board": []}) + + assert response.status_code == 400 + assert response.get_json() == {"error": "No game in progress"} + + +def test_check_accepts_current_solution(client): + puzzle, solution = sudoku_logic.generate_puzzle(clues=35) + app_module.CURRENT["puzzle"] = puzzle + app_module.CURRENT["solution"] = solution + + response = client.post("/check", json={"board": solution}) + + assert response.status_code == 200 + assert response.get_json() == {"incorrect": []} + + +def test_check_reports_incorrect_cells(client): + puzzle, solution = sudoku_logic.generate_puzzle(clues=35) + app_module.CURRENT["puzzle"] = puzzle + app_module.CURRENT["solution"] = solution + + board = [row[:] for row in solution] + board[0][0] = 0 + + response = client.post("/check", json={"board": board}) + + assert response.status_code == 200 + assert response.get_json()["incorrect"] == [[0, 0]] + +@pytest.mark.parametrize( + ("difficulty", "expected_clues"), + [ + ("easy", 45), + ("medium", 35), + ("hard", 25), + ], +) +def test_new_game_uses_selected_difficulty( + client, + difficulty, + expected_clues, +): + response = client.get(f"/new?difficulty={difficulty}") + + assert response.status_code == 200 + + puzzle = response.get_json()["puzzle"] + clues = sum( + cell != sudoku_logic.EMPTY + for row in puzzle + for cell in row + ) + + assert clues == expected_clues + + +def test_new_game_rejects_invalid_difficulty(client): + response = client.get("/new?difficulty=expert") + + assert response.status_code == 400 + assert response.get_json() == { + "error": "difficulty must be easy, medium, or hard" + } \ No newline at end of file diff --git a/starter/tests/test_sudoku_logic.py b/starter/tests/test_sudoku_logic.py new file mode 100644 index 000000000..af7a59eba --- /dev/null +++ b/starter/tests/test_sudoku_logic.py @@ -0,0 +1,133 @@ +import sudoku_logic + + +def is_valid_solution(board): + expected = set(range(1, sudoku_logic.SIZE + 1)) + + rows_valid = all(set(row) == expected for row in board) + columns_valid = all( + {board[row][column] for row in range(sudoku_logic.SIZE)} == expected + for column in range(sudoku_logic.SIZE) + ) + + boxes_valid = all( + { + board[row][column] + for row in range(box_row, box_row + 3) + for column in range(box_column, box_column + 3) + } + == expected + for box_row in range(0, sudoku_logic.SIZE, 3) + for box_column in range(0, sudoku_logic.SIZE, 3) + ) + + return rows_valid and columns_valid and boxes_valid + + +def test_create_empty_board(): + board = sudoku_logic.create_empty_board() + + assert len(board) == 9 + assert all(len(row) == 9 for row in board) + assert all(cell == sudoku_logic.EMPTY for row in board for cell in row) + + +def test_is_safe_rejects_row_conflict(): + board = sudoku_logic.create_empty_board() + board[0][0] = 5 + + assert not sudoku_logic.is_safe(board, 0, 1, 5) + + +def test_is_safe_rejects_column_conflict(): + board = sudoku_logic.create_empty_board() + board[1][1] = 6 + + assert not sudoku_logic.is_safe(board, 0, 1, 6) + + +def test_is_safe_rejects_box_conflict(): + board = sudoku_logic.create_empty_board() + board[1][1] = 7 + + assert not sudoku_logic.is_safe(board, 0, 0, 7) + + +def test_is_safe_accepts_valid_candidate(): + board = sudoku_logic.create_empty_board() + + assert sudoku_logic.is_safe(board, 0, 0, 1) + + +def test_fill_board_fills_board_in_place(): + board = sudoku_logic.create_empty_board() + + result = sudoku_logic.fill_board(board) + + assert result is True + assert is_valid_solution(board) + + +def test_generate_puzzle_returns_puzzle_and_solution(): + puzzle, solution = sudoku_logic.generate_puzzle(clues=35) + + assert len(puzzle) == 9 + assert len(solution) == 9 + assert is_valid_solution(solution) + + assert sum( + cell != sudoku_logic.EMPTY + for row in puzzle + for cell in row + ) == 35 + + for row in range(9): + for column in range(9): + if puzzle[row][column] != sudoku_logic.EMPTY: + assert puzzle[row][column] == solution[row][column] +def test_count_solutions_returns_one_for_generated_puzzle(): + puzzle, _ = sudoku_logic.generate_puzzle(clues=35) + + assert sudoku_logic.count_solutions(puzzle) == 1 + + +def test_count_solutions_stops_at_two_for_empty_board(): + board = sudoku_logic.create_empty_board() + + assert sudoku_logic.count_solutions(board) == 2 + + +def test_remove_cells_preserves_requested_clue_count_and_uniqueness(): + board = sudoku_logic.create_empty_board() + sudoku_logic.fill_board(board) + + sudoku_logic.remove_cells(board, clues=35) + + assert sum( + cell != sudoku_logic.EMPTY + for row in board + for cell in row + ) == 35 + assert sudoku_logic.count_solutions(board) == 1 + +import pytest + + +@pytest.mark.parametrize( + ("difficulty", "expected_clues"), + [ + ("easy", 45), + ("medium", 35), + ("hard", 25), + ], +) +def test_clue_count_for_difficulty(difficulty, expected_clues): + assert ( + sudoku_logic.clue_count_for_difficulty(difficulty) + == expected_clues + ) + + +def test_clue_count_for_difficulty_rejects_invalid_value(): + with pytest.raises(ValueError): + sudoku_logic.clue_count_for_difficulty("expert") \ No newline at end of file