| """Tests for LegalConceptGraph, GCNLayer, GNNEncoder.""" |
| import sys |
| from pathlib import Path |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| import pytest |
| import torch |
| from unittest.mock import MagicMock, patch |
| from src.telern.config import TELENConfig, GraphConfig |
| from src.telern.concept_graph import ( |
| GCNLayer, GNNEncoder, LegalConceptGraph, extract_key_terms, |
| _law_matches_ref, CROSS_REF_PATTERNS, |
| ) |
|
|
|
|
| class TestGCNLayer: |
| @pytest.fixture |
| def layer(self): |
| return GCNLayer(64, 64, dropout=0.0) |
|
|
| def test_init(self, layer): |
| assert isinstance(layer.linear, torch.nn.Linear) |
| assert layer.linear.in_features == 64 |
| assert layer.linear.out_features == 64 |
|
|
| def test_forward(self, layer): |
| x = torch.randn(5, 64) |
| adj = torch.rand(5, 5) |
| adj = (adj + adj.T) / 2 |
| out = layer(x, adj) |
| assert out.shape == (5, 64) |
| assert not torch.isnan(out).any() |
|
|
| def test_forward_isolated_node(self, layer): |
| x = torch.randn(3, 64) |
| adj = torch.eye(3) |
| out = layer(x, adj) |
| assert out.shape == (3, 64) |
|
|
| def test_different_dims(self): |
| layer = GCNLayer(64, 128) |
| x = torch.randn(5, 64) |
| adj = torch.ones(5, 5) |
| out = layer(x, adj) |
| assert out.shape == (5, 128) |
|
|
|
|
| class TestGNNEncoder: |
| @pytest.fixture |
| def gnn(self): |
| return GNNEncoder(64, n_layers=3, dropout=0.0) |
|
|
| def test_init(self, gnn): |
| assert len(gnn.layers) == 3 |
| for layer in gnn.layers: |
| assert isinstance(layer, GCNLayer) |
|
|
| def test_forward(self, gnn): |
| x = torch.randn(5, 64) |
| adj = torch.ones(5, 5) / 5 |
| out = gnn(x, adj) |
| assert out.shape == (5, 64) |
| assert not torch.isnan(out).any() |
|
|
|
|
| class TestLegalConceptGraph: |
| @pytest.fixture |
| def config(self): |
| return TELENConfig( |
| hidden_dim=64, |
| graph=GraphConfig(hidden_dim=64, gnn_layers=2, gnn_dropout=0.0), |
| ) |
|
|
| @pytest.fixture |
| def graph(self, config): |
| return LegalConceptGraph(config) |
|
|
| def test_init_empty(self, graph): |
| assert graph.num_nodes == 0 |
| assert graph.node_embeddings is None |
|
|
| def test_add_nodes(self, graph): |
| emb = torch.randn(3, 64) |
| graph.add_nodes(["a", "b", "c"], emb) |
| assert graph.num_nodes == 3 |
| assert graph.node_embeddings.shape == (3, 64) |
|
|
| def test_add_nodes_multiple(self, graph): |
| graph.add_nodes(["a"], torch.randn(1, 64)) |
| graph.add_nodes(["b", "c"], torch.randn(2, 64)) |
| assert graph.num_nodes == 3 |
|
|
| def test_add_edges(self, graph): |
| graph.add_nodes(["a", "b", "c"], torch.randn(3, 64)) |
| graph.add_edges("semantic", [(0, 1, 0.8), (1, 2, 0.5)]) |
| assert len(graph.edges["semantic"]) == 2 |
|
|
| def test_build_adjacency(self, graph): |
| graph.add_nodes(["a", "b", "c"], torch.randn(3, 64)) |
| graph.add_edges("semantic", [(0, 1, 0.8)]) |
| adj = graph.build_adjacency() |
| assert adj.shape == (3, 3) |
| |
| assert adj[0, 1] > 0 |
| assert adj[1, 0] > 0 |
| assert torch.allclose(adj, adj.T) |
| assert torch.all(adj.diagonal() >= 1.0) |
|
|
| def test_build_adjacency_cached(self, graph): |
| graph.add_nodes(["a", "b"], torch.randn(2, 64)) |
| adj1 = graph.build_adjacency() |
| adj2 = graph.build_adjacency() |
| assert torch.allclose(adj1, adj2) |
|
|
| def test_build_adjacency_invalidated(self, graph): |
| graph.add_nodes(["a", "b"], torch.randn(2, 64)) |
| adj1 = graph.build_adjacency() |
| graph.add_edges("semantic", [(0, 1, 0.5)]) |
| adj2 = graph.build_adjacency() |
| |
| |
| assert adj2.shape == (2, 2) |
|
|
| def test_forward_empty(self, graph): |
| |
| |
| assert graph.node_embeddings is None |
| assert graph.num_nodes == 0 |
|
|
| def test_forward_two_nodes(self, graph): |
| graph.add_nodes(["a", "b"], torch.randn(2, 64)) |
| out = graph.forward() |
| assert out.shape == (2, 64) |
|
|
| def test_disabled_edge_types(self): |
| cfg = TELENConfig( |
| hidden_dim=64, |
| graph=GraphConfig( |
| hidden_dim=64, |
| use_cross_ref_edges=False, |
| use_agency_edges=False, |
| use_temporal_edges=False, |
| use_semantic_edges=False, |
| ), |
| ) |
| graph = LegalConceptGraph(cfg) |
| graph.add_nodes(["a", "b"], torch.randn(2, 64)) |
| graph.add_edges("semantic", [(0, 1, 0.5)]) |
| adj = graph.build_adjacency() |
| |
| assert adj[0, 1] == 0.0 |
|
|
| def test_out_of_bounds_edges_filtered(self, graph): |
| graph.add_nodes(["a", "b"], torch.randn(2, 64)) |
| graph.add_edges("semantic", [(0, 1, 0.5), (0, 5, 0.9)]) |
| adj = graph.build_adjacency() |
| assert adj.shape == (2, 2) |
| assert adj[0, 1] > 0 |
|
|
|
|
| class TestExtractKeyTerms: |
| def test_extract_key_terms(self): |
| import pandas as pd |
| df = pd.DataFrame({ |
| "title": ["Luật A", "Luật B", "Luật C", "Luật D", "Luật E", "Luật F"], |
| "text": [ |
| "quy định về thuế giá trị gia tăng hàng hóa", |
| "quy định về thuế thu nhập doanh nghiệp", |
| "xử phạt vi phạm hành chính giao thông đường bộ", |
| "quy định về bảo vệ môi trường và tài nguyên", |
| "quy định về thuế xuất nhập khẩu hàng hóa", |
| "quy định về an toàn thực phẩm và vệ sinh", |
| ], |
| }) |
| terms = extract_key_terms(df, max_terms=20) |
| assert len(terms) > 0 |
| assert len(terms) <= 20 |
| assert all(isinstance(t, str) for t in terms) |
|
|
|
|
| class TestLawMatchesRef: |
| def test_exact_match(self): |
| assert _law_matches_ref("12/2015/TT-BTC", "Thông tư 12/2015/TT-BTC") is True |
|
|
| def test_partial_match(self): |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| assert _law_matches_ref("12/2015/TT-BTC", "Thông tư 12/2015/TT-BTC") is True |
|
|
| def test_no_match(self): |
| assert _law_matches_ref("12/2015/TT-BTC", "Luật đất đai") is False |
|
|
|
|
| class TestCrossRefPatterns: |
| def test_patterns_compiled(self): |
| for pattern, etype in CROSS_REF_PATTERNS: |
| assert pattern.pattern != "" |
| assert etype in ("citation", "reference", "amendment", "replacement") |
|
|