Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
node_modules
storybook-static

coverage/

.loki/report.html
.loki/report.json

/testplane-report
.testplane
.testplane

# stryker temp files
.stryker-tmp
reports/
9 changes: 9 additions & 0 deletions .strykerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**/__tests__/**
**/*.test.ts
**/mock/**
**/node_modules/**
**/dist/**
**/coverage/**
**/stryker-tmp/**
**/*.d.ts
src/
41 changes: 41 additions & 0 deletions json-server/article-analyzer/app/__tests__/graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { buildDense, buildSparse } from '../algorithm/graph';

describe('Построение графа', () => {
const articles = [
{ id: '1', title: 'A', authors: [], year: 2020, citations: ['2'] },
{ id: '2', title: 'B', authors: [], year: 2020, citations: ['1', '3'] },
{ id: '3', title: 'C', authors: [], year: 2020, citations: ['1'] },
];

test('Разреженное представление – проверка ветвей outDegree > 0 и =0', () => {
const graph = buildSparse(articles);
expect(graph.outDegree).toEqual([1, 2, 1]);
expect(graph.outLinks[0]).toEqual([1]);
expect(graph.outLinks[1]).toEqual([0, 2]);
expect(graph.outLinks[2]).toEqual([0]);
});

test('Плотное представление', () => {
const graph = buildDense(articles);
expect(graph.matrix[0][1]).toBe(1);
expect(graph.matrix[1][0]).toBe(1);
expect(graph.matrix[1][2]).toBe(1);
expect(graph.matrix[2][0]).toBe(1);
expect(graph.matrix[0][2]).toBe(0);
});

test('Игнорирование отсутствующих ссылок', () => {
const articlesMissing = [
{
id: '1',
title: 'A',
authors: [],
year: 2020,
citations: ['2', '4'],
},
{ id: '2', title: 'B', authors: [], year: 2020, citations: [] },
];
const graph = buildSparse(articlesMissing);
expect(graph.outLinks[0]).toEqual([1]); // только ссылка на '2'
});
});
98 changes: 98 additions & 0 deletions json-server/article-analyzer/app/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import fs from 'fs';
import { Readable } from 'stream';
import { loadArticlesFromFile } from '../algorithm/loader';

jest.mock('fs');

describe('Модуль загрузки JSON', () => {
beforeEach(() => {
jest.resetAllMocks();
});

test('Загрузка корректного файла', async () => {
const mockData = JSON.stringify([
{
id: '1',
title: 'Article 1',
authors: ['A'],
year: 2020,
citations: [],
},
]);
const mockStream = Readable.from([mockData]);
(fs.createReadStream as jest.Mock).mockReturnValue(mockStream);

const articles = await loadArticlesFromFile('valid.json');
expect(articles).toHaveLength(1);
expect(articles[0]).toMatchObject({ id: '1', title: 'Article 1' });
});

test('Загрузка несуществующего файла', async () => {
// Создаём поток, который сразу генерирует ошибку
const errorStream = new Readable();
errorStream._read = () => {};
process.nextTick(() => errorStream.emit('error', new Error('ENOENT')));
(fs.createReadStream as jest.Mock).mockReturnValue(errorStream);

await expect(loadArticlesFromFile('missing.json')).rejects.toThrow(
'File not found',
);
});

test('Загрузка файла с отсутствующим id', async () => {
const mockData = JSON.stringify([
{ title: 'No ID', authors: ['A'], year: 2020, citations: [] },
]);
const mockStream = Readable.from([mockData]);
(fs.createReadStream as jest.Mock).mockReturnValue(mockStream);

await expect(loadArticlesFromFile('no-id.json')).rejects.toThrow(
'Missing required field: id',
);
});

test('Загрузка файла с некорректным JSON (синтаксис)', async () => {
const mockData = '{ "id": 1, "title": "Broken" '; // невалидный JSON
const mockStream = Readable.from([mockData]);
(fs.createReadStream as jest.Mock).mockReturnValue(mockStream);

await expect(
loadArticlesFromFile('invalid-syntax.json'),
).rejects.toThrow('Invalid JSON format');
});

test('Граничные значения года: 1900 (допустимо)', async () => {
const mockData = JSON.stringify([
{
id: '1',
title: 'Old',
authors: ['A'],
year: 1900,
citations: [],
},
]);
const mockStream = Readable.from([mockData]);
(fs.createReadStream as jest.Mock).mockReturnValue(mockStream);

const articles = await loadArticlesFromFile('min-year.json');
expect(articles[0].year).toBe(1900);
});

test('Граничные значения года: 1899 (недопустимо)', async () => {
const mockData = JSON.stringify([
{
id: '1',
title: 'Too Old',
authors: ['A'],
year: 1899,
citations: [],
},
]);
const mockStream = Readable.from([mockData]);
(fs.createReadStream as jest.Mock).mockReturnValue(mockStream);

await expect(loadArticlesFromFile('invalid-year.json')).rejects.toThrow(
'Invalid year',
);
});
});
42 changes: 42 additions & 0 deletions json-server/article-analyzer/app/__tests__/pagerank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { buildDense, buildSparse } from '../algorithm/graph';
import { pagerankDense, pagerankSparse } from '../algorithm/pagerank';

describe('PageRank', () => {
const articles = [
{ id: '1', title: 'A', authors: [], year: 2020, citations: ['2'] },
{ id: '2', title: 'B', authors: [], year: 2020, citations: ['1', '3'] },
{ id: '3', title: 'C', authors: [], year: 2020, citations: ['1'] },
];
const graphSparse = buildSparse(articles);
const graphDense = buildDense(articles);
const { n } = graphDense;

test('Сравнение двух реализаций (результаты должны совпадать)', () => {
const prSparse = pagerankSparse(graphSparse, 0.85, 20);
const prDense = pagerankDense(graphDense, 0.85, 20);
for (let i = 0; i < n; i += 1) {
expect(prSparse[i]).toBeCloseTo(prDense[i], 6);
}
});

test('iterations = 1 (минимальное допустимое)', () => {
expect(() => pagerankSparse(graphSparse, 0.85, 1)).not.toThrow();
});

test('iterations = 0 (недопустимо)', () => {
expect(() => pagerankSparse(graphSparse, 0.85, 0)).toThrow(
'Iterations must be >= 1',
);
});

test('damping = 0.5 (допустимо)', () => {
const pr = pagerankSparse(graphSparse, 0.5, 20);
expect(pr).toBeDefined();
});

test('damping = 1 (недопустимо)', () => {
expect(() => pagerankSparse(graphSparse, 1, 20)).toThrow(
'Damping must be in (0,1)',
);
});
});
21 changes: 21 additions & 0 deletions json-server/article-analyzer/app/__tests__/validator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { validateArticle } from '../algorithm/validator';

describe('Валидация статей', () => {
test.each([
[{ id: null, title: 'A' }, false], // нет id
[{ id: 1, title: 'A' }, false], // id не строка
[{ id: '1' }, false], // нет title
[{ id: '1', title: null }, false], // нет title
[{ id: '1', title: 123 }, false],
[{ id: '1', title: 'A', year: 2020 }, true],
[{ id: '1', title: 'A', year: '2020' }, false],
[{ id: '1', title: 'A', year: 1899 }, false],
[{ id: '1', title: 'A' }, true],
[{ id: '1', title: 'A', authors: ['John'] }, true],
[{ id: '1', title: 'A', authors: 'John' }, false],
[{ id: '1', title: 'A', citations: ['2'] }, true],
[{ id: '1', title: 'A', citations: '2' }, false],
])('validateArticle(%p) = %p', (input, expected) => {
expect(validateArticle(input)).toBe(expected);
});
});
44 changes: 44 additions & 0 deletions json-server/article-analyzer/app/algorithm/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Article, GraphDense, GraphSparse } from '../types';

/**
* Строит разреженное представление графа.
*/

export function buildSparse(articles: Article[]): GraphSparse {
const n = articles.length;

const idx = new Map(articles.map((a, i) => [a.id, i]));
const outLinks: number[][] = Array.from({ length: n }, () => []);
const inLinks: number[][] = Array.from({ length: n }, () => []);
const outDegree = Array(n).fill(0);

articles.forEach((a, i) => {
(a.citations || []).forEach((cid: any) => {
const j = idx.get(cid);
if (j === undefined) return; // ignoreMissing
outLinks[i].push(j);
inLinks[j].push(i);
outDegree[i] += 1;
});
});

return { outLinks, inLinks, outDegree, n };
}

/**
* Строит плотную матрицу смежности.
*/
export function buildDense(articles: Article[]): GraphDense {
const n = articles.length;
const idx = new Map(articles.map((a, i) => [a.id, i]));
const matrix = Array.from({ length: n }, () => Array(n).fill(0));

articles.forEach((a, i) =>
(a.citations || []).forEach((cid: any) => {
const j = idx.get(cid);
if (j !== undefined) matrix[i][j] = 1;
}),
);

return { matrix, n };
}
83 changes: 83 additions & 0 deletions json-server/article-analyzer/app/algorithm/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import fs, { createReadStream } from 'fs';
import path from 'path';
import { createInterface } from 'readline';
import { Article, RawArticle } from '../types';

/**
* Нормализует сырые данные статей к единому формату.
*/
export async function normalizeArticles(
rawArticles: RawArticle[],
): Promise<Article[]> {
return rawArticles.map((raw) => {
if (!raw.id) throw new Error('[103] Missing required field: id');
if (!raw.title) throw new Error('[103] Missing required field: title');
if (raw.year && (typeof raw.year !== 'number' || raw.year < 1900)) {
throw new Error('[103] Invalid year');
}
const year =
raw.year !== undefined && raw.year !== null
? raw.year
: new Date().getFullYear();
if (typeof year !== 'number' || year < 1900)
throw new Error(`[104] Invalid year: ${year}`);
if (raw.citations !== undefined && !Array.isArray(raw.citations))
throw new Error('[104] citations must be an array');
return {
id: String(raw.id),
title: String(raw.title),
year,
authors: Array.isArray(raw.authors) ? raw.authors : [],
citations: Array.isArray(raw.citations) ? raw.citations : [],
};
});
}

/**
* Загружает статьи из JSON-файла с поддержкой потоковой обработки.
* @param filePath - путь к файлу
* @returns массив статей в нормализованном формате
*/
export async function loadArticlesFromFile(
filePath: string,
isLoad: boolean = false,
): Promise<Article[]> {
if (isLoad) {
const abs = path.resolve(filePath);
if (!fs.existsSync(abs))
throw new Error(`[101] File not found: ${filePath}`);
let raw;
try {
raw = JSON.parse(fs.readFileSync(abs, 'utf8'));
} catch {
throw new Error(`[102] Invalid JSON: ${filePath}`);
}
if (!Array.isArray(raw)) throw new Error('[102] JSON must be an array');
return normalizeArticles(raw);
}

const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });

let buffer = '';
try {
// eslint-disable-next-line no-restricted-syntax
for await (const line of rl) {
buffer += line;
}
} catch (err) {
// Ошибка при чтении файла (например, файл не найден)
throw new Error('File not found');
}

let data;
try {
data = JSON.parse(buffer);
} catch (err) {
throw new Error('Invalid JSON format');
}

const articles = Array.isArray(data) ? data : data.articles;
// normalizeArticles выбросит собственную ошибку, если данные некорректны
return normalizeArticles(articles);
}
Loading
Loading