diff --git a/README.md b/README.md
index 7681815..41d1859 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,7 @@ UnitTCMS currently supports the following languages:
- English (en)
- Portuguese (pt-BR)
- Chinese (zh-CN)
+- Traditional Chinese (zh-TW)
- Japanese (ja)
If you would like to add support for another language, feel free to submit a pull request. For reference, you can see how Portuguese was added in [PR #260](https://github.com/kimatata/unittcms/pull/260).
diff --git a/backend/config/locale.js b/backend/config/locale.js
index e661d34..a5c5236 100644
--- a/backend/config/locale.js
+++ b/backend/config/locale.js
@@ -1 +1 @@
-export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja'];
+export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'];
diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js
index 894b859..00a9ba9 100644
--- a/backend/routes/cases/import.js
+++ b/backend/routes/cases/import.js
@@ -12,18 +12,15 @@ import editableMiddleware from '../../middleware/verifyEditable.js';
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
const fileFilter = (req, file, cb) => {
- const allowedFileTypes = ['.xlsx', '.xls'];
- const allowedMimeTypes = [
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'application/vnd.ms-excel',
- ];
+ // Accept Excel and JSON by extension. JSON mime types vary across browsers
+ // (application/json, text/plain, application/octet-stream), so rely on the extension.
+ const allowedFileTypes = ['.xlsx', '.xls', '.json'];
const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase());
- const mimetype = allowedMimeTypes.includes(file.mimetype);
- if (extname && mimetype) {
+ if (extname) {
return cb(null, true);
} else {
- cb(new Error('Only Excel files (.xlsx, .xls) are allowed!'));
+ cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!'));
}
};
@@ -66,61 +63,23 @@ export default function (sequelize) {
return res.status(400).json({ error: 'folderId is required' });
}
+ // Parse and validate the file before opening a transaction.
+ // Both parsers return the same { casesToCreate, stepsToCreate } shape,
+ // or throw an Error with a user-facing validation message.
+ let casesToCreate;
+ let stepsToCreate;
+ try {
+ const ext = path.extname(req.file.originalname).toLowerCase();
+ const parsed =
+ ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId);
+ casesToCreate = parsed.casesToCreate;
+ stepsToCreate = parsed.stepsToCreate;
+ } catch (error) {
+ return res.status(400).json({ error: error.message });
+ }
+
const t = await sequelize.transaction();
try {
- const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
- const sheetName = workbook.SheetNames[0];
- const worksheet = workbook.Sheets[sheetName];
- const jsonData = XLSX.utils.sheet_to_json(worksheet);
-
- let currentTitle = null;
- let previousTitle = null;
- let stepNo = 1;
- const casesToCreate = [];
- const stepsToCreate = [];
- for (const [index, row] of jsonData.entries()) {
- const errorMessage = _getRowValidationError(row, index);
- if (errorMessage) {
- return res.status(400).json({ error: errorMessage });
- }
-
- // Add step to the same case if the current row title is equal to previous row title
- // This handle cases with multiple steps :)
- currentTitle = row['title'].trim();
- previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
- if (casesToCreate.length > 0 && previousTitle === currentTitle) {
- stepNo += 1;
- stepsToCreate.push({
- caseIndex: casesToCreate.length - 1,
- stepNo: stepNo,
- step: row['step'] || '',
- result: row['expectedStepResult'] || '',
- });
- } else {
- stepNo = 1;
- casesToCreate.push({
- folderId: folderId,
- title: currentTitle,
- description: row['description'] || '',
- state: 0, // default state
- priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
- type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
- preConditions: row['preConditions'],
- expectedResults: row['expectedResults'],
- automationStatus: row['automationStatus']
- ? automationStatus.indexOf(row['automationStatus'])
- : automationStatus.indexOf('automation-not-required'),
- template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
- });
- stepsToCreate.push({
- caseIndex: casesToCreate.length - 1,
- stepNo: stepNo,
- step: row['step'] || '',
- result: row['expectedStepResult'] || '',
- });
- }
- }
-
// 'Manually' create cases, steps and caseStep association.
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
for (const stepData of stepsToCreate) {
@@ -147,6 +106,9 @@ export default function (sequelize) {
} catch (error) {
await t.rollback();
console.error(error);
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'Internal Server Error' });
+ }
}
}
);
@@ -154,6 +116,120 @@ export default function (sequelize) {
return router;
}
+// Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }.
+// Rows sharing the same title are grouped into a single case with multiple steps.
+function _parseExcelBuffer(buffer, folderId) {
+ const workbook = XLSX.read(buffer, { type: 'buffer' });
+ const sheetName = workbook.SheetNames[0];
+ const worksheet = workbook.Sheets[sheetName];
+ const jsonData = XLSX.utils.sheet_to_json(worksheet);
+
+ let currentTitle = null;
+ let previousTitle = null;
+ let stepNo = 1;
+ const casesToCreate = [];
+ const stepsToCreate = [];
+ for (const [index, row] of jsonData.entries()) {
+ const errorMessage = _getRowValidationError(row, index);
+ if (errorMessage) {
+ throw new Error(errorMessage);
+ }
+
+ // Add step to the same case if the current row title is equal to previous row title
+ // This handle cases with multiple steps :)
+ currentTitle = row['title'].trim();
+ previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
+ if (casesToCreate.length > 0 && previousTitle === currentTitle) {
+ stepNo += 1;
+ stepsToCreate.push({
+ caseIndex: casesToCreate.length - 1,
+ stepNo: stepNo,
+ step: row['step'] || '',
+ result: row['expectedStepResult'] || '',
+ });
+ } else {
+ stepNo = 1;
+ casesToCreate.push({
+ folderId: folderId,
+ title: currentTitle,
+ description: row['description'] || '',
+ state: 0, // default state
+ priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
+ type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
+ preConditions: row['preConditions'],
+ expectedResults: row['expectedResults'],
+ automationStatus: row['automationStatus']
+ ? automationStatus.indexOf(row['automationStatus'])
+ : automationStatus.indexOf('automation-not-required'),
+ template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
+ });
+ stepsToCreate.push({
+ caseIndex: casesToCreate.length - 1,
+ stepNo: stepNo,
+ step: row['step'] || '',
+ result: row['expectedStepResult'] || '',
+ });
+ }
+ }
+
+ return { casesToCreate, stepsToCreate };
+}
+
+// Parse a JSON buffer into { casesToCreate, stepsToCreate }.
+// Accepts the same shape produced by the JSON export (`/cases/download?type=json`):
+// either an array of case objects, or an object map keyed by id.
+// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the
+// target folder with the default `new` state.
+function _parseJsonBuffer(buffer, folderId) {
+ let parsed;
+ try {
+ parsed = JSON.parse(buffer.toString('utf-8'));
+ } catch {
+ throw new Error('Invalid JSON file');
+ }
+
+ const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null;
+ if (!rawCases || rawCases.length === 0) {
+ throw new Error('No cases found in JSON file');
+ }
+
+ const casesToCreate = [];
+ const stepsToCreate = [];
+ for (const [index, c] of rawCases.entries()) {
+ const errorMessage = _getJsonCaseValidationError(c, index);
+ if (errorMessage) {
+ throw new Error(errorMessage);
+ }
+
+ casesToCreate.push({
+ folderId: folderId,
+ title: String(c.title).trim(),
+ description: c.description || '',
+ state: 0, // default state
+ priority: priorities.indexOf(c.priority),
+ type: testTypes.indexOf(c.type),
+ preConditions: c.preConditions || '',
+ expectedResults: c.expectedResults || '',
+ automationStatus: c.automationStatus
+ ? automationStatus.indexOf(c.automationStatus)
+ : automationStatus.indexOf('automation-not-required'),
+ template: templates.indexOf(c.template),
+ });
+
+ const steps = Array.isArray(c.Steps) ? c.Steps : [];
+ steps.forEach((s, stepIndex) => {
+ stepsToCreate.push({
+ caseIndex: casesToCreate.length - 1,
+ stepNo: stepIndex + 1,
+ step: s?.step || '',
+ result: s?.expectedStepResult || s?.result || '',
+ });
+ });
+ }
+
+ return { casesToCreate, stepsToCreate };
+}
+
function _getRowValidationError(row, index) {
const requiredFields = ['title', 'priority', 'type', 'template'];
const rowNumber = index + 2;
@@ -198,3 +274,37 @@ function _getRowValidationError(row, index) {
return null;
}
+
+function _getJsonCaseValidationError(c, index) {
+ const caseNumber = index + 1;
+
+ if (!c || typeof c !== 'object') {
+ return `Case ${caseNumber} is not a valid object`;
+ }
+
+ const requiredFields = ['title', 'priority', 'type', 'template'];
+ for (const field of requiredFields) {
+ if (!c[field]) {
+ return `Case ${caseNumber} is missing required field: ${field}`;
+ }
+ }
+
+ // Values are human-readable labels, matching the JSON export format.
+ if (priorities.indexOf(c.priority) === -1) {
+ return `Case ${caseNumber} has invalid priority: ${c.priority}`;
+ }
+
+ if (testTypes.indexOf(c.type) === -1) {
+ return `Case ${caseNumber} has invalid type: ${c.type}`;
+ }
+
+ if (templates.indexOf(c.template) === -1) {
+ return `Case ${caseNumber} has invalid template: ${c.template}`;
+ }
+
+ if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) {
+ return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`;
+ }
+
+ return null;
+}
diff --git a/backend/routes/cases/import.test.js b/backend/routes/cases/import.test.js
new file mode 100644
index 0000000..2b39f62
--- /dev/null
+++ b/backend/routes/cases/import.test.js
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import request from 'supertest';
+import express from 'express';
+import { Sequelize } from 'sequelize';
+import casesImportRoute from './import.js';
+
+// mock of authentication / permission middleware
+vi.mock('../../middleware/auth.js', () => ({
+ default: () => ({
+ verifySignedIn: vi.fn((req, res, next) => {
+ req.userId = 1;
+ next();
+ }),
+ }),
+}));
+vi.mock('../../middleware/verifyEditable.js', () => ({
+ default: () => ({
+ verifyProjectDeveloperFromFolderId: vi.fn((req, res, next) => {
+ next();
+ }),
+ }),
+}));
+
+const mockCase = {
+ bulkCreate: vi.fn(),
+ belongsToMany: vi.fn(),
+};
+vi.mock('../../models/cases.js', () => ({
+ default: () => mockCase,
+}));
+
+const mockStep = {
+ create: vi.fn(),
+ belongsToMany: vi.fn(),
+};
+vi.mock('../../models/steps.js', () => ({
+ default: () => mockStep,
+}));
+
+const mockCaseStep = {
+ create: vi.fn(),
+};
+vi.mock('../../models/caseSteps.js', () => ({
+ default: () => mockCaseStep,
+}));
+
+describe('POST /import with JSON file', () => {
+ let app;
+ const sequelize = new Sequelize({ dialect: 'sqlite', logging: false });
+
+ beforeEach(() => {
+ app = express();
+ app.use(express.json());
+ app.use('/', casesImportRoute(sequelize));
+ vi.clearAllMocks();
+ vi.spyOn(sequelize, 'transaction').mockResolvedValue({
+ commit: vi.fn(),
+ rollback: vi.fn(),
+ });
+ });
+
+ const attachJson = (data) =>
+ request(app)
+ .post('/import?folderId=1')
+ .attach('file', Buffer.from(JSON.stringify(data)), 'cases.json');
+
+ it('creates cases from a JSON array, mapping labels to enum indexes', async () => {
+ mockCase.bulkCreate.mockResolvedValue([{ id: 101 }, { id: 102 }]);
+ mockStep.create.mockResolvedValue({ id: 201 });
+ mockCaseStep.create.mockResolvedValue({});
+
+ const response = await attachJson([
+ {
+ title: 'Text case',
+ priority: 'high',
+ type: 'functional',
+ template: 'text',
+ description: 'desc',
+ Steps: [],
+ },
+ {
+ title: 'Step case',
+ priority: 'medium',
+ type: 'acceptance',
+ template: 'step',
+ automationStatus: 'cannot-be-automated',
+ Steps: [
+ { step: 'do A', expectedStepResult: 'A done' },
+ { step: 'do B', expectedStepResult: 'B done' },
+ ],
+ },
+ ]);
+
+ expect(response.status).toBe(200);
+
+ const created = mockCase.bulkCreate.mock.calls[0][0];
+ expect(created).toHaveLength(2);
+ expect(created[0]).toMatchObject({
+ folderId: '1',
+ title: 'Text case',
+ state: 0,
+ priority: 1, // high
+ type: 4, // functional
+ template: 0, // text
+ automationStatus: 1, // default automation-not-required
+ });
+ expect(created[1]).toMatchObject({
+ title: 'Step case',
+ priority: 2, // medium
+ type: 5, // acceptance
+ template: 1, // step
+ automationStatus: 2, // cannot-be-automated
+ });
+
+ // Only the second case has steps -> 2 steps created
+ expect(mockStep.create).toHaveBeenCalledTimes(2);
+ expect(mockCaseStep.create).toHaveBeenCalledTimes(2);
+ });
+
+ it('accepts the object-map shape produced by the JSON export', async () => {
+ mockCase.bulkCreate.mockResolvedValue([{ id: 101 }]);
+
+ const response = await attachJson({
+ 1: { title: 'Mapped case', priority: 'low', type: 'other', template: 'text', Steps: [] },
+ });
+
+ expect(response.status).toBe(200);
+ expect(mockCase.bulkCreate.mock.calls[0][0]).toHaveLength(1);
+ });
+
+ it('returns 400 when a required field is missing', async () => {
+ const response = await attachJson([{ priority: 'high', type: 'functional', template: 'text' }]);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('missing required field: title');
+ expect(mockCase.bulkCreate).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 for an invalid priority label', async () => {
+ const response = await attachJson([
+ { title: 'Bad priority', priority: 'urgent', type: 'functional', template: 'text' },
+ ]);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('invalid priority');
+ });
+
+ it('returns 400 for malformed JSON', async () => {
+ const response = await request(app)
+ .post('/import?folderId=1')
+ .attach('file', Buffer.from('{ not json'), 'cases.json');
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid JSON file');
+ });
+
+ it('rejects unsupported file extensions', async () => {
+ const response = await request(app).post('/import?folderId=1').attach('file', Buffer.from('hello'), 'cases.txt');
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Only Excel');
+ });
+});
diff --git a/frontend/config/selection.ts b/frontend/config/selection.ts
index 8b11215..1e686e4 100644
--- a/frontend/config/selection.ts
+++ b/frontend/config/selection.ts
@@ -19,6 +19,7 @@ const locales: LocaleType[] = [
{ code: 'en', name: 'English' },
{ code: 'pt-BR', name: 'Português' },
{ code: 'zh-CN', name: '简体中文' },
+ { code: 'zh-TW', name: '繁體中文' },
{ code: 'ja', name: '日本語' },
];
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index d92a8aa..c4fe4a7 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -251,8 +251,9 @@
"select_tags": "Tags auswählen",
"import": "Importieren",
"import_cases": "Testfälle importieren",
- "import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
+ "import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) oder einer JSON-Datei (json) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
"download_template": "Vorlage herunterladen",
+ "download_json_sample": "JSON-Beispiel herunterladen",
"click_to_upload": "Zum Hochladen klicken",
"or_drag_and_drop": " oder per Drag & Drop",
"max_file_size": "Max. Dateigröße",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index c3f244e..b50cd96 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -251,8 +251,9 @@
"select_tags": "Select tags",
"import": "Import",
"import_cases": "Import Test Cases",
- "import_available": "You can import test cases from Excel file (xlsx, xls). The Excel file to be imported must follow the specified format.",
+ "import_available": "You can import test cases from an Excel file (xlsx, xls) or a JSON file (json). The file to be imported must follow the specified format.",
"download_template": "Download Template",
+ "download_json_sample": "Download JSON Sample",
"click_to_upload": "Click to upload",
"or_drag_and_drop": " or drag and drop",
"max_file_size": "Max. file size",
diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json
index 26e0f01..117ba17 100644
--- a/frontend/messages/ja.json
+++ b/frontend/messages/ja.json
@@ -251,8 +251,9 @@
"select_tags": "タグを選択",
"import": "インポート",
"import_cases": "テストケースのインポート",
- "import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。",
+ "import_available": "Excelファイル(xlsx, xls)またはJSONファイル(json)からテストケースをインポートできます。インポートするファイルは既定のフォーマットに従う必要があります。",
"download_template": "テンプレートをダウンロード",
+ "download_json_sample": "JSONサンプルをダウンロード",
"click_to_upload": "クリックしてアップロード",
"or_drag_and_drop": "またはドラッグアンドドロップ",
"max_file_size": "最大ファイルサイズ",
diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json
index eed385f..1a0c1fe 100644
--- a/frontend/messages/pt-BR.json
+++ b/frontend/messages/pt-BR.json
@@ -251,8 +251,9 @@
"select_tags": "Selecionar tags",
"import": "Importar",
"import_cases": "Importar Casos de Teste",
- "import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls). O arquivo Excel a ser importado deve seguir o formato especificado.",
+ "import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls) ou um arquivo JSON (json). O arquivo a ser importado deve seguir o formato especificado.",
"download_template": "Baixar Modelo",
+ "download_json_sample": "Baixar Exemplo JSON",
"click_to_upload": "Clique para enviar",
"or_drag_and_drop": " ou arraste e solte",
"max_file_size": "Tamanho máx. do arquivo",
diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json
index 7fa89e6..b49e329 100644
--- a/frontend/messages/zh-CN.json
+++ b/frontend/messages/zh-CN.json
@@ -251,8 +251,9 @@
"select_tags": "选择标签",
"import": "导入",
"import_cases": "导入测试用例",
- "import_available": "您可以从 Excel 文件 (xlsx, xls) 导入测试用例。导入的 Excel 文件必须遵循指定格式。",
+ "import_available": "您可以从 Excel 文件 (xlsx, xls) 或 JSON 文件 (json) 导入测试用例。导入的文件必须遵循指定格式。",
"download_template": "下载模板",
+ "download_json_sample": "下载 JSON 示例",
"click_to_upload": "点击上传",
"or_drag_and_drop": " 或拖拽文件到此处",
"max_file_size": "最大文件大小",
diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json
new file mode 100644
index 0000000..7cc841a
--- /dev/null
+++ b/frontend/messages/zh-TW.json
@@ -0,0 +1,432 @@
+{
+ "RunStatus": {
+ "new": "新建",
+ "inProgress": "進行中",
+ "underReview": "審核中",
+ "rejected": "已拒絕",
+ "done": "完成",
+ "closed": "已關閉"
+ },
+ "RunCaseStatus": {
+ "untested": "未測試",
+ "passed": "通過",
+ "failed": "失敗",
+ "retest": "重測",
+ "skipped": "略過"
+ },
+ "Priority": {
+ "critical": "嚴重",
+ "high": "高",
+ "medium": "中",
+ "low": "低"
+ },
+ "Type": {
+ "other": "其他",
+ "security": "安全",
+ "performance": "效能",
+ "accessibility": "無障礙",
+ "functional": "功能",
+ "acceptance": "驗收",
+ "usability": "易用性",
+ "smoke_sanity": "冒煙與健全性",
+ "compatibility": "相容性",
+ "destructive": "破壞性",
+ "regression": "迴歸",
+ "automated": "自動化",
+ "manual": "手動"
+ },
+ "ProjectDialog": {
+ "project": "專案",
+ "project_name": "專案名稱",
+ "project_detail": "專案詳情",
+ "public": "公開",
+ "private": "私有",
+ "if_you_make_public": "將專案設為公開將使其對非專案成員的使用者可見。",
+ "close": "關閉",
+ "create": "建立",
+ "update": "更新",
+ "please_enter": "請輸入專案名稱"
+ },
+ "Index": {
+ "get_started": "開始使用",
+ "demo": "示範",
+ "oss_tcms": "開源測試案例管理系統",
+ "integrate_and_manage": "整合並管理您所有的軟體測試。",
+ "oss_title": "開源",
+ "oss_detail": "UnitTCMS 是免費且開源的。應用程式支援自我託管,可部署於具有嚴格安全要求的環境中。",
+ "organize_title": "組織測試案例",
+ "organize_detail": "可以透過專案與資料夾來組織測試案例。現代化的 UI 框架使其運行迅速。",
+ "usability_title": "易用性",
+ "usability_detail": "定義好的測試案例可以在測試執行中反覆使用。測試執行與專案的狀態可以透過圖表直覺地檢視。",
+ "universal_title": "通用性",
+ "universal_detail": "多語言支援與深色模式讓任何人都能無障礙地使用此系統。",
+ "project_title": "以專案為基礎",
+ "project_subtitle": "以專案為單位管理測試案例與測試執行。我們的儀表板提供了每個專案中測試案例類型及其進度的概覽。這使您能夠即時監控專案狀態並進行高效率的管理。",
+ "case_management_title": "測試案例管理",
+ "case_management_subtitle": "在專案中建立資料夾,利用我們現代化且直覺的 UI 輕鬆定義測試案例。支援附件上傳,可對測試案例進行詳細說明,便於在整個團隊中共享資訊。",
+ "run_management_title": "測試執行管理",
+ "run_management_subtitle": "定義好的測試案例可以在測試執行中多次重複使用,實現高效率的測試循環。此外,您還可以視覺化地監控測試執行與專案的狀態。",
+ "member_management_title": "成員管理",
+ "member_management_subtitle": "透過在專案中新增或移除成員來支援團隊開發。您可以為每個成員指派角色並詳細設定權限。我們提供三種主要角色:「管理者」管理整個專案,「開發者」設計測試,「報告者」執行測試。"
+ },
+ "Header": {
+ "title": "開源測試案例管理系統",
+ "description": "整合並管理您所有的軟體測試",
+ "docs": "文件",
+ "roadmap": "路線圖",
+ "projects": "專案",
+ "admin": "管理後台",
+ "account": "帳戶",
+ "profile_settings": "個人設定",
+ "signup": "註冊",
+ "signin": "登入",
+ "signout": "登出",
+ "links": "連結",
+ "languages": "語言"
+ },
+ "Toast": {
+ "need_signed_in": "您需要先登入",
+ "session_expired": "工作階段已過期"
+ },
+ "Auth": {
+ "account": "帳戶",
+ "signup": "註冊",
+ "signin": "登入",
+ "or_signup": "還沒有帳號?前往註冊",
+ "or_signin": "已有帳號?前往登入",
+ "signin_as_guest": "或以訪客身分登入",
+ "email": "電子郵件",
+ "username": "使用者名稱",
+ "password": "密碼",
+ "confirm_password": "確認密碼",
+ "invalid_email": "無效的電子郵件地址",
+ "invalid_password": "密碼必須至少包含 8 個字元",
+ "invalid_locale": "無效的語言",
+ "username_empty": "使用者名稱不能為空",
+ "password_empty": "密碼不能為空",
+ "password_not_match": "密碼不一致",
+ "email_already_exist": "電子郵件已被註冊",
+ "email_not_exist": "找不到該電子郵件地址",
+ "signup_error": "註冊失敗",
+ "signin_error": "登入失敗",
+ "demo_page_warning": "這是一個示範環境,您輸入的任何資料都將被定期重置。",
+ "your_projects": "您的專案",
+ "public": "公開",
+ "private": "私有",
+ "not_own_any_projects": "您沒有任何專案。",
+ "find_projects": "探索專案",
+ "profile_settings": "個人設定",
+ "change_username": "變更使用者名稱",
+ "new_username": "新使用者名稱",
+ "update_username": "更新使用者名稱",
+ "username_updated": "使用者名稱更新成功",
+ "change_password": "變更密碼",
+ "current_password": "目前密碼",
+ "new_password": "新密碼",
+ "confirm_new_password": "確認新密碼",
+ "update_password": "更新密碼",
+ "password_updated": "密碼更新成功",
+ "change_locale": "變更語言",
+ "update_locale": "更新語言",
+ "locale_updated": "語言更新成功",
+ "change_avatar": "變更頭像",
+ "upload_avatar": "上傳頭像",
+ "remove_avatar": "移除頭像",
+ "avatar_updated": "頭像更新成功",
+ "avatar_removed": "頭像移除成功",
+ "max_file_size_5mb": "檔案大小上限:5MB",
+ "only_images_allowed": "僅允許上傳圖片檔案",
+ "current_password_incorrect": "目前密碼不正確",
+ "update_error": "更新失敗"
+ },
+ "Health": {
+ "health_check": "健康檢查",
+ "status": "狀態",
+ "ok": "正常",
+ "error": "錯誤",
+ "api_server": "API 伺服器",
+ "unittcms_version": "UnitTCMS 版本"
+ },
+ "Admin": {
+ "user_management": "使用者管理",
+ "avatar": "頭像",
+ "id": "ID",
+ "email": "電子郵件",
+ "username": "使用者名稱",
+ "role": "角色",
+ "administrator": "管理員",
+ "user": "使用者",
+ "no_users_found": "找不到使用者",
+ "quit_admin": "放棄管理員權限",
+ "close": "關閉",
+ "quit": "放棄",
+ "quit_confirm": "如果您放棄管理員權限,將無法再存取管理頁面。",
+ "role_changed": "角色已變更",
+ "lost_admin_auth": "已失去管理員權限",
+ "at_least": "至少需要保留一名管理員",
+ "reset_password": "重設密碼",
+ "reset": "重設",
+ "invalid_password": "密碼必須至少包含 8 個字元",
+ "password_not_match": "密碼不一致"
+ },
+ "Projects": {
+ "project_list": "專案列表",
+ "new_project": "新增專案",
+ "id": "ID",
+ "publicity": "公開性",
+ "public": "公開",
+ "private": "私有",
+ "name": "名稱",
+ "detail": "詳情",
+ "last_update": "最後更新",
+ "no_projects_found": "找不到專案"
+ },
+ "Project": {
+ "project": "專案",
+ "toggle_sidebar": "切換側邊欄",
+ "home": "首頁",
+ "test_cases": "測試案例",
+ "test_runs": "測試執行",
+ "members": "成員",
+ "settings": "設定"
+ },
+ "Home": {
+ "home": "首頁",
+ "Folders": "資料夾",
+ "test_cases": "測試案例",
+ "test_runs": "測試執行",
+ "progress": "進度",
+ "test_classification": "測試分類",
+ "by_type": "依測試類型",
+ "by_priority": "依測試優先級"
+ },
+ "Folders": {
+ "folder": "資料夾",
+ "new_folder": "新增資料夾",
+ "edit_folder": "編輯資料夾",
+ "delete_folder": "刪除資料夾",
+ "folder_name": "資料夾名稱",
+ "folder_detail": "資料夾詳情",
+ "close": "關閉",
+ "create": "建立",
+ "update": "更新",
+ "please_enter": "請輸入資料夾名稱",
+ "delete": "刪除",
+ "are_you_sure": "您確定要刪除此資料夾嗎?",
+ "no_folders_found": "找不到資料夾"
+ },
+ "Cases": {
+ "test_case_list": "測試案例列表",
+ "id": "ID",
+ "title": "標題",
+ "priority": "優先級",
+ "actions": "操作",
+ "delete_case": "刪除測試案例",
+ "delete": "刪除",
+ "close": "關閉",
+ "are_you_sure": "您確定要刪除測試案例嗎?",
+ "new_test_case": "新增",
+ "export": "匯出",
+ "status": "狀態",
+ "no_cases_found": "找不到測試案例",
+ "case_title": "測試案例標題",
+ "case_description": "測試案例描述",
+ "case_title_or_description": "測試案例標題或描述",
+ "create": "建立",
+ "please_enter": "請輸入測試案例標題",
+ "filter": "篩選",
+ "clear_all": "清除全部",
+ "apply": "套用",
+ "select_priorities": "選擇優先級",
+ "selected": "已選擇",
+ "type": "類型",
+ "select_types": "選擇類型",
+ "cases_selected": "個案例已選擇",
+ "select_action": "選擇操作",
+ "move": "移動",
+ "clone": "複製",
+ "cases_moved": "測試案例已移動",
+ "cases_cloned": "測試案例已複製",
+ "tags": "標籤",
+ "select_tags": "選擇標籤",
+ "import": "匯入",
+ "import_cases": "匯入測試案例",
+ "import_available": "您可以從 Excel 檔案 (xlsx, xls) 或 JSON 檔案 (json) 匯入測試案例。匯入的檔案必須遵循指定格式。",
+ "download_template": "下載範本",
+ "download_json_sample": "下載 JSON 範例",
+ "click_to_upload": "點擊上傳",
+ "or_drag_and_drop": " 或將檔案拖曳至此處",
+ "max_file_size": "檔案大小上限",
+ "cases_imported": "測試案例已匯入",
+ "create_more": "繼續建立"
+ },
+ "Case": {
+ "back_to_cases": "返回測試案例列表",
+ "updating": "更新中...",
+ "update": "更新",
+ "updated_test_case": "測試案例已更新",
+ "basic": "基本資訊",
+ "title": "標題",
+ "please_enter_title": "請輸入標題",
+ "description": "描述",
+ "test_case_description": "測試案例描述",
+ "priority": "優先級",
+ "type": "類型",
+ "template": "範本",
+ "test_detail": "測試詳情",
+ "preconditions": "前置條件",
+ "expected_result": "預期結果",
+ "step": "步驟",
+ "text": "文字",
+ "steps": "步驟",
+ "new_step": "新增步驟",
+ "details_of_the_step": "步驟詳情",
+ "delete_this_step": "刪除此步驟",
+ "insert_step": "插入步驟",
+ "attachments": "附件",
+ "delete": "刪除",
+ "download": "下載",
+ "delete_file": "刪除檔案",
+ "click_to_upload": "點擊上傳",
+ "or_drag_and_drop": " 或將檔案拖曳至此處",
+ "max_file_size": "檔案大小上限",
+ "are_you_sure_leave": "您確定要離開此頁面嗎?",
+ "tags": "標籤",
+ "create_tag": "建立標籤",
+ "max_tags_limit": "標籤數量上限",
+ "tag_already_exists": "標籤已存在",
+ "tag_created_and_added": "標籤已建立並新增",
+ "error_creating_tag": "建立標籤失敗",
+ "error_updating_test_case": "更新測試案例失敗",
+ "search_or_create_tag": "搜尋或建立標籤",
+ "no_tags_selected": "未選擇標籤"
+ },
+ "Runs": {
+ "run_list": "測試執行列表",
+ "run": "執行",
+ "new_run": "新增執行",
+ "edit_run": "編輯執行",
+ "delete_run": "刪除執行",
+ "id": "ID",
+ "name": "名稱",
+ "description": "描述",
+ "last_update": "最後更新",
+ "actions": "操作",
+ "run_name": "執行名稱",
+ "run_description": "執行描述",
+ "no_runs_found": "找不到測試執行",
+ "close": "關閉",
+ "create": "建立",
+ "update": "更新",
+ "please_enter": "請輸入執行名稱",
+ "are_you_sure": "您確定要刪除此測試執行嗎?",
+ "delete": "刪除"
+ },
+ "Run": {
+ "back_to_runs": "返回測試執行列表",
+ "updating": "更新中...",
+ "update": "更新",
+ "updated_test_run": "測試執行已更新",
+ "export": "匯出",
+ "progress": "進度",
+ "refresh": "重新整理",
+ "id": "ID",
+ "title": "標題",
+ "please_enter": "請輸入執行名稱",
+ "description": "描述",
+ "priority": "優先級",
+ "status": "狀態",
+ "actions": "操作",
+ "select_test_case": "選擇測試案例",
+ "test_case_selection": "測試案例選擇",
+ "include_in_run": "納入執行",
+ "exclude_from_run": "從執行中排除",
+ "no_cases_found": "找不到案例",
+ "are_you_sure_leave": "您確定要離開此頁面嗎?",
+ "type": "類型",
+ "test_detail": "測試詳情",
+ "steps": "步驟",
+ "preconditions": "前置條件",
+ "expected_result": "預期結果",
+ "details_of_the_step": "步驟詳情",
+ "close": "關閉",
+ "filter": "篩選",
+ "clear_all": "清除全部",
+ "apply": "套用",
+ "select_status": "選擇狀態",
+ "please_save": "請儲存變更",
+ "case_title_or_description": "測試案例標題或描述",
+ "selected": "已選擇",
+ "tags": "標籤",
+ "select_tags": "選擇標籤",
+ "no_case_selected": "未選擇測試案例",
+ "case_detail": "測試案例詳情",
+ "comments": "留言",
+ "history": "歷史"
+ },
+ "Comments": {
+ "comments": "留言",
+ "no_comments": "尚無留言",
+ "add_comment": "新增留言",
+ "save": "儲存",
+ "cancel": "取消",
+ "placeholder": "輸入留言...",
+ "not_included_in_run": "無法對未納入測試執行的測試案例進行留言",
+ "comment_added": "留言已新增",
+ "failed_to_add_comment": "新增留言失敗",
+ "comment_updated": "留言已更新",
+ "failed_to_update_comment": "更新留言失敗",
+ "comment_deleted": "留言已刪除",
+ "failed_to_delete_comment": "刪除留言失敗"
+ },
+ "Members": {
+ "member_management": "成員管理",
+ "avatar": "頭像",
+ "email": "電子郵件",
+ "username": "使用者名稱",
+ "role": "角色",
+ "manager": "管理者",
+ "developer": "開發者",
+ "reporter": "報告者",
+ "delete": "刪除",
+ "deleteMember": "刪除成員",
+ "no_members_found": "找不到成員",
+ "add_member": "新增成員",
+ "user_name_or_email": "使用者名稱或電子郵件",
+ "close": "關閉",
+ "add": "新增",
+ "are_you_sure": "您確定要刪除此成員嗎?",
+ "member_added": "成員已新增",
+ "role_changed": "成員角色已變更",
+ "member_deleted": "成員已刪除"
+ },
+ "Settings": {
+ "project_management": "專案管理",
+ "project_name": "專案名稱",
+ "project_detail": "專案詳情",
+ "project_owner": "專案擁有者",
+ "edit_project": "編輯專案",
+ "publicity": "公開性",
+ "public": "公開",
+ "private": "私有",
+ "delete_project": "刪除專案",
+ "delete": "刪除",
+ "close": "關閉",
+ "are_you_sure": "您確定要刪除此專案嗎?",
+ "tag_management": "標籤管理",
+ "tag_name": "標籤名稱",
+ "add_tag": "新增標籤",
+ "no_tags_available": "尚無標籤",
+ "delete_tag": "刪除標籤",
+ "are_you_sure_delete_tag": "您確定要刪除此標籤嗎?",
+ "tag_created": "標籤建立成功。",
+ "tag_updated": "標籤更新成功。",
+ "tag_deleted": "標籤刪除成功。",
+ "tag_error_empty": "標籤名稱不能為空。",
+ "tag_error_min_length": "標籤名稱長度至少為 3 個字元。",
+ "tag_error_max_length": "標籤名稱不能超過 20 個字元。",
+ "tag_error_create": "標籤建立失敗,請重試。",
+ "tag_error_update": "標籤更新失敗,請重試。",
+ "tag_error_delete": "標籤刪除失敗,請重試。"
+ }
+}
diff --git a/frontend/public/template/unittcms-import-sample.json b/frontend/public/template/unittcms-import-sample.json
new file mode 100644
index 0000000..2f1cc06
--- /dev/null
+++ b/frontend/public/template/unittcms-import-sample.json
@@ -0,0 +1,33 @@
+[
+ {
+ "title": "User can sign in with valid credentials",
+ "priority": "high",
+ "type": "functional",
+ "template": "text",
+ "automationStatus": "automation-not-required",
+ "description": "Verify that a registered user can sign in.",
+ "preConditions": "A user account already exists.",
+ "expectedResults": "The user is redirected to the projects page after signing in.",
+ "Steps": []
+ },
+ {
+ "title": "Create a new project",
+ "priority": "medium",
+ "type": "acceptance",
+ "template": "step",
+ "automationStatus": "cannot-be-automated",
+ "description": "Verify that a signed-in user can create a project.",
+ "preConditions": "The user is signed in.",
+ "expectedResults": "The new project appears in the project list.",
+ "Steps": [
+ {
+ "step": "Click the \"New project\" button.",
+ "expectedStepResult": "The project creation dialog opens."
+ },
+ {
+ "step": "Enter a project name and submit.",
+ "expectedStepResult": "The dialog closes and the project is created."
+ }
+ ]
+ }
+]
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx
index d771130..a929541 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx
@@ -73,6 +73,10 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
{messages.downloadTemplate}
+ |
+
+ {messages.downloadJsonSample}
+
{importError && }
@@ -101,6 +105,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
handleInput(e)}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
index 1e275d7..f922e15 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
@@ -53,6 +53,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
importCases: t('import_cases'),
importAvailable: t('import_available'),
downloadTemplate: t('download_template'),
+ downloadJsonSample: t('download_json_sample'),
clickToUpload: t('click_to_upload'),
orDragAndDrop: t('or_drag_and_drop'),
maxFileSize: t('max_file_size'),
diff --git a/frontend/src/i18n/routing.ts b/frontend/src/i18n/routing.ts
index c09ac0e..cfeb721 100644
--- a/frontend/src/i18n/routing.ts
+++ b/frontend/src/i18n/routing.ts
@@ -2,7 +2,7 @@ import { createNavigation } from 'next-intl/navigation';
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
- locales: ['de', 'en', 'pt-BR', 'zh-CN', 'ja'],
+ locales: ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'],
defaultLocale: 'en',
localePrefix: {
mode: 'always',
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index fa9af8c..a701392 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -4,5 +4,5 @@ import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
- matcher: ['/', '/(de|en|pt-BR|zh-CN|ja)/:path*'],
+ matcher: ['/', '/(de|en|pt-BR|zh-CN|zh-TW|ja)/:path*'],
};
diff --git a/frontend/types/case.ts b/frontend/types/case.ts
index 58f7f83..61ab313 100644
--- a/frontend/types/case.ts
+++ b/frontend/types/case.ts
@@ -102,6 +102,7 @@ type CasesMessages = {
importCases: string;
importAvailable: string;
downloadTemplate: string;
+ downloadJsonSample: string;
clickToUpload: string;
orDragAndDrop: string;
maxFileSize: string;
diff --git a/frontend/types/locale.ts b/frontend/types/locale.ts
index e1d2270..eb8af98 100644
--- a/frontend/types/locale.ts
+++ b/frontend/types/locale.ts
@@ -1,4 +1,4 @@
-export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'ja';
+export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'zh-TW' | 'ja';
export type LocaleType = {
code: LocaleCodeType;