feat: implement main application structure with UI and layout management

- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs.
- Created module structure for UI components in `src/ui/mod.rs`.
- Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`.
- Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files.
- Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts.
- Created result display screen in `src/ui/screens/resultado.rs` to show analysis results.
- Introduced modular organization for screens in `src/ui/screens/mod.rs`.
This commit is contained in:
2026-03-02 22:14:02 -03:00
parent 31cd94907a
commit e64988a139
36 changed files with 9166 additions and 2 deletions
+90
View File
@@ -0,0 +1,90 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use encoding_rs::WINDOWS_1252;
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Resultado da leitura de um arquivo CSV.
pub struct ResultadoCsv {
/// Linhas de dados (já sem o cabeçalho), cada linha é um vetor de strings.
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lê um arquivo CSV e retorna as linhas de dados (sem o cabeçalho).
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `delimitador`: caractere delimitador (`,`, `;` ou `\t`)
/// - `encoding`: "utf-8" ou "windows-1252"
/// - `linha_cabecalho`: número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
pub fn ler_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
linha_cabecalho: usize,
) -> Result<ResultadoCsv, ErroArquivo> {
// Verificar tamanho
let metadata = std::fs::metadata(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
// Ler conteúdo bruto
let bytes = std::fs::read(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
// Decodificar encoding
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => {
String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
}
};
let mut avisos = ResumoAvisos::default();
let mut linhas_dados: Vec<Vec<String>> = Vec::new();
let mut numero_linha_arquivo: usize = 0;
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
for resultado in reader.records() {
numero_linha_arquivo += 1;
// Pular linhas antes ou na linha do cabeçalho
if linha_cabecalho > 0 && numero_linha_arquivo <= linha_cabecalho {
continue;
}
match resultado {
Ok(record) => {
let campos: Vec<String> = record.iter().map(|s| s.to_string()).collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s| s.trim().is_empty()) {
continue;
}
linhas_dados.push(campos);
}
Err(_) => {
avisos.linhas_malformadas += 1;
}
}
}
Ok(ResultadoCsv {
linhas: linhas_dados,
avisos,
})
}
+4
View File
@@ -0,0 +1,4 @@
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
+193
View File
@@ -0,0 +1,193 @@
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
use genpdf::{
elements::{Break, Paragraph},
fonts, style, Document, Element, SimplePageDecorator,
};
use std::path::Path;
/// Metadados do relatório.
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
/// Trait abstrata para geração de PDF.
/// Definida aqui para que o use case `exportar_pdf` dependa da abstração,
/// não da crate `genpdf` diretamente.
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
/// Implementação concreta usando `genpdf`.
pub struct GenpdfGenerator;
impl PdfGenerator for GenpdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans)
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
doc.set_minimal_conformance();
let mut decorator = SimplePageDecorator::new();
decorator.set_margins(10);
doc.set_page_decorator(decorator);
// Título
doc.push(
Paragraph::new("").styled_string(
"Relatório de Análise de Notas Fiscais",
style::Style::new().bold().with_font_size(16),
),
);
doc.push(Break::new(1));
// Metadados
doc.push(Paragraph::new(format!("Arquivo: {}", meta.nome_arquivo)));
if let Some(layout) = &meta.nome_layout {
doc.push(Paragraph::new(format!("Layout: {}", layout)));
}
doc.push(Paragraph::new(format!(
"Gerado em: {}",
meta.gerado_em.format("%d/%m/%Y %H:%M:%S")
)));
doc.push(Break::new(1));
// Totais
doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
);
doc.push(Paragraph::new(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
)));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
series_ordenadas.sort();
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
total,
formatar_valor_br(soma)
)));
}
doc.push(Break::new(1));
// Notas Faltantes
doc.push(
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
Some(f) if !f.is_empty() => f,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
serie,
faltantes.len()
)));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
}
doc.push(Break::new(1));
// Duplicatas
doc.push(
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
Some(d) if !d.is_empty() => d,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
serie,
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, serie, count
)));
}
}
// Renderizar PDF
doc.render_to_file(caminho_saida)
.map_err(|e| format!("Erro ao gerar PDF: {}", e))?;
Ok(())
}
}
/// Tenta carregar fontes DejaVu Sans do sistema.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
// Caminhos comuns no Linux, Windows e macOS
let candidatos_regular = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/Library/Fonts/Arial.ttf",
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(),
bold_italic: regular,
})
}
+56
View File
@@ -0,0 +1,56 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("comparador-notas").join("config.db")
}
/// Abre (ou cria) o banco de dados SQLite no caminho padrão.
///
/// Retorna a conexão aberta e pronta para uso, ou um erro descritivo.
/// Em caso de banco corrompido, renomeia para `.bak` e recria.
pub fn abrir_banco() -> Result<(Connection, bool), String> {
let caminho = caminho_banco();
abrir_banco_no_caminho(&caminho)
}
/// Versão testável que aceita um caminho explícito.
pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), String> {
// Criar diretório se não existir
if let Some(dir) = caminho.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Não foi possível criar diretório do banco: {}", e))?;
}
// Verificar se o arquivo existe e está corrompido
if caminho.exists() {
match Connection::open(caminho) {
Ok(conn) => {
// Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(e) => {
// Banco corrompido
drop(conn);
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
Err(_) => {
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
// Criar banco novo
let conn = Connection::open(caminho)
.map_err(|e| format!("Não foi possível criar banco de dados: {}", e))?;
Ok((conn, true)) // true = banco foi recriado (era corrompido)
}
@@ -0,0 +1,211 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result};
/// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
nome,
config.delimitador.to_string(),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
],
)?;
Ok(conn.last_insert_rowid())
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
],
)?;
Ok(conn.last_insert_rowid())
}
}
}
/// Atualiza um layout existente no banco.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout
.id()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("id ausente".to_string()))?;
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8
WHERE id = ?9",
params![
nome,
config.delimitador.to_string(),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
id,
],
)?;
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6
WHERE id = ?7",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
id,
],
)?;
}
}
Ok(())
}
/// Lista todos os layouts salvos.
pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
FROM layouts ORDER BY nome ASC",
)?;
let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row
.get::<_, Option<i64>>(8)?
.map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>(9)?
.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
},
})
}
})?
.collect();
layouts
}
/// Busca um layout pelo id.
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
FROM layouts WHERE id = ?1",
)?;
let mut results = stmt.query_map([id], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
},
})
}
})?;
results.next().transpose()
}
/// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
Ok(())
}
/// Verifica se já existe um layout com o nome fornecido.
pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1",
[nome],
|row| row.get(0),
)?;
Ok(count > 0)
}
+62
View File
@@ -0,0 +1,62 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 1;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
// Criar tabela de controle de versão se não existir
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);",
)?;
let versao_atual: i64 = conn
.query_row(
"SELECT versao FROM schema_version LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or(0);
if versao_atual < 1 {
migration_v1(conn)?;
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
} else {
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
}
}
Ok(())
}
/// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);",
)?;
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod connection;
pub mod layout_repository;
pub mod migrations;
+188
View File
@@ -0,0 +1,188 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use calamine::{open_workbook, Reader, Xlsx};
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Representa uma coordenada de célula (coluna base-0, linha base-0).
#[derive(Debug, Clone, Copy)]
pub struct Coordenada {
pub coluna: u32,
pub linha: u32,
}
/// Resultado da leitura de um arquivo XLSX.
pub struct ResultadoXlsx {
/// Linhas de dados, a partir da posição de início.
/// Cada linha é um vetor de strings (indexado pela posição da coluna de início).
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lista as abas disponíveis em um arquivo XLSX.
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?;
let workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec())
}
/// Lê os dados de uma aba XLSX a partir de uma linha de início.
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `nome_aba`: nome da aba a ser lida
/// - `linha_inicio`: número da linha de início dos dados (base 1, ex: 3 para "B3")
pub fn ler_xlsx(
caminho: &Path,
nome_aba: &str,
linha_inicio: u32,
) -> Result<ResultadoXlsx, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let mut avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
for (row_idx, row) in range.rows().enumerate() {
if row_idx < linha_inicio_base0 {
continue;
}
let campos: Vec<String> = row
.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) {
continue;
}
linhas.push(campos);
}
Ok(ResultadoXlsx { linhas, avisos })
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
///
/// Retorna `None` se a notação for inválida.
pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
let s = s.trim().to_uppercase();
if s.is_empty() {
return None;
}
let pos_numero = s.find(|c: char| c.is_ascii_digit())?;
let (letras, numeros) = s.split_at(pos_numero);
if letras.is_empty() || numeros.is_empty() {
return None;
}
// Converter letras para índice de coluna (base 0)
// A=0, B=1, ..., Z=25, AA=26, ...
let coluna = letras
.chars()
.try_fold(0u32, |acc, c| {
if c.is_ascii_uppercase() {
Some(acc * 26 + (c as u32 - 'A' as u32 + 1))
} else {
None
}
})?
.checked_sub(1)?;
let linha: u32 = numeros.parse().ok()?;
if linha == 0 {
return None;
}
Some(Coordenada { coluna, linha })
}
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata = std::fs::metadata(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsear_b3() {
let coord = parsear_letra_linha("B3").unwrap();
assert_eq!(coord.coluna, 1); // B = coluna 1 (base 0)
assert_eq!(coord.linha, 3);
}
#[test]
fn parsear_a1() {
let coord = parsear_letra_linha("A1").unwrap();
assert_eq!(coord.coluna, 0);
assert_eq!(coord.linha, 1);
}
#[test]
fn parsear_z1() {
let coord = parsear_letra_linha("Z1").unwrap();
assert_eq!(coord.coluna, 25);
}
#[test]
fn parsear_aa1() {
let coord = parsear_letra_linha("AA1").unwrap();
assert_eq!(coord.coluna, 26);
}
#[test]
fn parsear_invalido() {
assert!(parsear_letra_linha("").is_none());
assert!(parsear_letra_linha("3B").is_none());
assert!(parsear_letra_linha("123").is_none());
}
#[test]
fn parsear_minusculo() {
let coord = parsear_letra_linha("b3").unwrap();
assert_eq!(coord.coluna, 1);
}
}