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
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
"omnisharp.enableRoslynAnalyzers": true,
"python.formatting.provider": "black",
"deno.enablePaths": ["./src/SIL.XForge.Scripture/ClientApp/e2e", "./scripts/"],
"deno.disablePaths": ["./scripts/db_tools/"],
"deno.disablePaths": ["./scripts/db_tools/", "./src"],
"typescript.tsdk": "./src/SIL.XForge.Scripture/ClientApp/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.preferences.importModuleSpecifier": "relative",
Expand Down
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ This repository contains three interconnected applications:
- Localizations that a Community Checker user might see should be created or edited in src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json. Only localizations that a Community Checker user will not see can be created or edited in src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json.
- Even if something is a system-wide feature that isn't specific to community checking functionality, it should still be placed in checking_en.json if a community checking user would POSSIBLY see it.

# Frontend styling

- Avoid making up and using hard-coded or new color values. Where feasible, use color values from [Material Design](https://material.angular.dev/guide/theming-your-components). For example, `--mat-sys-surface` and `--mat-sys-on-primary`. If you can't get close to what you want from Material Design colors, you can use a defined color in `_variables.scss` or `material-styles.scss`. Follow patterns in existing `_foo-theme.scss` files.

# Frontend user interface

- Use Sentence case for user interface elements, per Material Design. Do not use Title Case for user interface elements. For example, use "Project settings" rather than "Project Settings".

# Frontend testing

- Write unit tests for new components and services
Expand Down Expand Up @@ -95,11 +103,28 @@ This repository contains three interconnected applications:
`const buildEvent: EventMetric | undefined = buildEvents[0];`.
- Prefer to use `null` to express a deliberate absence of a value, and `undefined` to express a value that has not been set yet.
- Observables (including subclasses such as Subject or BehaviorSubject) should have names that end with a `$`.
- Do not use the `!` non-null assertion operator. For example, do not write `foo!.baz()`. If you need to dereference `foo`, first prove to the type system that `foo` is not null either by using a type guard or checking for null. You may use the `!` non-null assertion operator in spec test files.
- Do not use the `as` type assertion operator. For example, do not write `const foo: SomeType = someValue as SomeType`. If you need to treat `someValue` as `SomeType`, first prove to the type system that `someValue` is of type `SomeType` such as by using a type guard. You may use the `as` type assertion operator in spec test files.
- Do not use object property shorthand when creating objects. For example, do not write `const obj = { foo, bar };`. Instead, write `const obj = { foo: foo, bar: bar };`.
- Do not reorder existing fields and methods to comply with this, but when creating new fields and methods in TypeScript classes, use this order:
1. public static fields
2. @Input, @Output, and @ViewChild fields
3. public instance fields
4. non-public static and instance fields
5. constructor
6. getters and setters
7. ngOnInit
8. public static and instance methods
9. non-public static and instance methods

# Angular templates

- Use `@if {}` syntax rather than `*ngIf` syntax.

# C# language

- Do not use the `!` null-forgiving operator. Instead, check for null and cause a specific outcome if it is null, or make use of the `NotNullWhen` attribute. You may use the `!` null-forgiving operator in test files.

# Code

- All code that you write should be able to pass eslint linting tests for TypeScript, or csharpier for C#.
Expand All @@ -108,6 +133,8 @@ This repository contains three interconnected applications:
- It is better to explicitly check for and handle problems, or prevent problems from happening, than to assume problems will not happen.
- Corner-cases happen. They should be handled in code.
- Please don't change existing code without good justification. Existing code largely works and changing it will cause work for code review. Leave existing code as is when possible.
- Avoid magic numbers where not obvious. Use a named constant for the value instead, which can be defined right before usage.
- Don't write useless comments. For example, for field `translationEngineId`, comment "The translation engine ID." adds no additional information than the name of the field already says.

# Frontend code

Expand All @@ -117,3 +144,4 @@ This repository contains three interconnected applications:

- If you run frontend tests, run them in the `src/SIL.XForge.Scripture/ClientApp` directory with a command such as `npm run test:headless -- --watch=false --include '**/text.component.spec.ts' --include '**/settings.component.spec.ts'`
- If you need to run all frontend tests, you can run them in the `src/SIL.XForge.Scripture/ClientApp` directory with command `npm run test:headless -- --watch=false`
- If you run backend dotnet tests, run them in the repository root directory with a command such as `dotnet test`.
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@ export interface BuildDto extends ResourceDto {
state: BuildStates;
queueDepth: number;
additionalInfo?: ServalBuildAdditionalInfo;
/** The Serval deployment version that executed this build. */
deploymentVersion?: string;
/** Execution data from the Serval build, including training/pretranslation counts and language tags. */
executionData?: BuildExecutionData;
}

/** Execution data from a Serval translation build. */
export interface BuildExecutionData {
trainCount: number;
pretranslateCount: number;
sourceLanguageTag?: string;
targetLanguageTag?: string;
}

/** Additional information about a Serval build. */
export interface ServalBuildAdditionalInfo {
buildId: string;
corporaIds?: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,19 @@ describe('RemoteTranslationEngine', () => {
env.addCreateBuild();

when(env.mockedHttpClient.post<BuildDto>('translation/builds', JSON.stringify('project01'))).thenReturn(
of({ status: 200 })
of({
status: 200,
data: {
id: 'build01',
href: 'translation/builds/id:build01',
revision: 0,
engine: { id: 'engine01', href: 'translation/engines/id:engine01' },
percentCompleted: 0,
message: '',
state: BuildStates.Pending,
queueDepth: 0
}
})
);

await env.client.startTraining();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,21 @@ export class RemoteTranslationEngine implements InteractiveTranslationEngine {
}

private getEngine(projectId: string): Observable<EngineDto> {
return this.httpClient
.get<EngineDto>(`translation/engines/project:${projectId}`)
.pipe(map(res => res.data as EngineDto));
return this.httpClient.get<EngineDto>(`translation/engines/project:${projectId}`).pipe(
map(res => {
if (res.data == null) throw new Error('Unexpectedly received no engine data.');
return res.data;
})
);
}

private createBuild(engineId: string): Observable<BuildDto> {
return this.httpClient
.post<BuildDto>('translation/builds', JSON.stringify(engineId))
.pipe(map(res => res.data as BuildDto));
return this.httpClient.post<BuildDto>('translation/builds', JSON.stringify(engineId)).pipe(
map(res => {
if (res.data == null) throw new Error('Unexpectedly received no build data.');
return res.data;
})
);
}

private pollBuildProgress(locator: string, minRevision: number): Observable<ProgressStatus> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
@use 'sass:color';
@use '@angular/material' as mat;
@use '../../_variables' as vars;

/**
* Theme for the Serval Builds component.
*/
@mixin color($theme) {
$is-dark: mat.get-theme-type($theme) == dark;

// Status icon colors
--sf-serval-builds-status-userrequested: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 60))};
--sf-serval-builds-status-submittedtoserval: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 60))};
--sf-serval-builds-status-queued: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 60))};
--sf-serval-builds-status-pending: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 60))};
--sf-serval-builds-status-active: #{if($is-dark, #4fa3ff, #007fff)};
--sf-serval-builds-status-finishing: #{if($is-dark, #4fa3ff, #007fff)};
--sf-serval-builds-status-completed: #{if($is-dark, lightGreen, darkGreen)};
--sf-serval-builds-status-faulted: #{mat.get-theme-color($theme, error, if($is-dark, 70, 40))};
--sf-serval-builds-status-canceled: #{mat.get-theme-color($theme, neutral, if($is-dark, 60, 50))};

// Problem/warning indicator
--sf-serval-builds-problem-color: #{if($is-dark, #dbdb00, #ffcc00)};
--sf-serval-builds-problem-border-color: var(--mat-table-background-color);

// Table
--sf-serval-builds-table-odd-row-background: #{if(
$is-dark,
var(--mat-sys-surface-container),
var(--mat-sys-surface-container-high)
)};
--sf-serval-builds-table-odd-row-expanded-background: #{if(
$is-dark,
var(--mat-sys-surface-container),
var(--mat-sys-surface-container-high)
)};
--sf-serval-builds-table-odd-row-detail-card-background: #{if(
$is-dark,
var(--mat-sys-surface-container-highest),
var(--mat-sys-surface-dim)
)};
--sf-serval-builds-table-even-row-detail-card-background: #{if(
$is-dark,
var(--mat-sys-surface-container-low),
var(--mat-sys-surface-container)
)};

// Links and text
--sf-serval-builds-project-link-color: #{mat.get-theme-color($theme, primary, if($is-dark, 70, 40))};
--sf-serval-builds-subdued-text: #{mat.get-theme-color($theme, neutral, if($is-dark, 70, 50))};
--sf-serval-builds-label-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 80, 50))};
--sf-serval-builds-duration-bar-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 80))};

// Deleted project indicator
--sf-serval-builds-deleted-project-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 60, 50))};
--sf-serval-builds-deleted-icon-color: #{mat.get-theme-color($theme, error, if($is-dark, 70, 40))};
}

@mixin theme($theme) {
@if mat.theme-has($theme, color) {
@include color($theme);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,82 +4,38 @@ import { getTestTranslocoModule } from 'xforge-common/test-utils';
import { DraftJobsExportService, SpreadsheetRow } from './draft-jobs-export.service';

describe('DraftJobsExportService', () => {
describe('createSpreadsheetRows', () => {
it('should export with correct headers and data', () => {
const env = new TestEnvironment();
const spreadsheetRows: SpreadsheetRow[] = (env.service as any).createSpreadsheetRows([
{
job: {
buildId: 'build-123',
startTime: new Date('2025-01-15T10:00:00Z'),
finishTime: new Date('2025-01-15T11:00:00Z'),
duration: 3600000 // 1 hour in milliseconds
},
projectId: 'project1',
projectName: 'Test Project',
status: 'Success',
userId: 'user123',
trainingBooks: [{ projectId: 'project1', books: ['GEN'] }],
translationBooks: [{ projectId: 'project1', books: ['MAT'] }]
}
]);

expect(spreadsheetRows.length).toEqual(1);
expect(spreadsheetRows[0]).toEqual({
servalBuildId: 'build-123',
startTime: '2025-01-15T10:00:00.000Z',
endTime: '2025-01-15T11:00:00.000Z',
durationMinutes: '60',
status: 'Success',
sfProjectId: 'project1',
projectName: 'Test Project',
sfUserId: 'user123',
trainingBooks: 'project1: GEN',
translationBooks: 'project1: MAT'
});
});
});

describe('createSpreadsheetRowsWithStatistics', () => {
describe('addStatistics', () => {
it('should append blank row and statistics rows with mean and max duration', () => {
const env = new TestEnvironment();
const testRows = [
const testRows: SpreadsheetRow[] = [
{
job: {
buildId: 'build-1',
startTime: new Date('2025-01-15T10:00:00Z'),
finishTime: new Date('2025-01-15T10:30:00Z'),
duration: 1800000 // 30 minutes in milliseconds
},
projectId: 'project1',
projectName: 'Project 1',
servalBuildId: 'build-1',
startTime: '2025-01-15T10:00:00.000Z',
endTime: '2025-01-15T10:30:00.000Z',
durationMinutes: '30',
status: 'Success',
userId: 'user1',
trainingBooks: [],
translationBooks: []
sfProjectId: 'project1',
projectName: 'Project 1',
sfUserId: 'user1',
trainingBooks: '',
translationBooks: ''
},
{
job: {
buildId: 'build-2',
startTime: new Date('2025-01-15T11:00:00Z'),
finishTime: new Date('2025-01-15T12:00:00Z'),
duration: 3600000 // 60 minutes in milliseconds
},
projectId: 'project1',
projectName: 'Project 1',
servalBuildId: 'build-2',
startTime: '2025-01-15T11:00:00.000Z',
endTime: '2025-01-15T12:00:00.000Z',
durationMinutes: '60',
status: 'Success',
userId: 'user2',
trainingBooks: [],
translationBooks: []
sfProjectId: 'project1',
projectName: 'Project 1',
sfUserId: 'user2',
trainingBooks: '',
translationBooks: ''
}
];

// SUT: create rows with the test data
const spreadsheetRows: SpreadsheetRow[] = (env.service as any).createSpreadsheetRowsWithStatistics(
testRows,
2700000,
3600000
);
const spreadsheetRows: SpreadsheetRow[] = (env.service as any).addStatistics(testRows, 2700000, 3600000);

// Should have 2 data rows + 1 blank row + 2 statistics rows = 5 total
expect(spreadsheetRows.length).toEqual(5);
Expand All @@ -102,7 +58,7 @@ describe('DraftJobsExportService', () => {
it('should handle empty rows array', () => {
const env = new TestEnvironment();
// SUT
const spreadsheetRows: SpreadsheetRow[] = (env.service as any).createSpreadsheetRowsWithStatistics([], 0, 0);
const spreadsheetRows: SpreadsheetRow[] = (env.service as any).addStatistics([], 0, 0);

// Should have only blank row + 2 statistics rows = 3 total
expect(spreadsheetRows.length).toEqual(3);
Expand All @@ -116,6 +72,16 @@ describe('DraftJobsExportService', () => {
expect(spreadsheetRows[2].startTime).toBe('0');
});
});

describe('getExportFilename', () => {
it('should use the provided filename prefix', () => {
const env = new TestEnvironment();
const dateRange = { start: new Date(2025, 0, 15), end: new Date(2025, 1, 28) };

const filename: string = (env.service as any).getExportFilename(dateRange, 'csv', 'my-prefix');
expect(filename).toBe('my-prefix_2025-01-15_2025-02-28.csv');
});
});
});

class TestEnvironment {
Expand Down
Loading
Loading