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
16 changes: 9 additions & 7 deletions backend/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Controller } from '@nestjs/common';
import { Controller, Get } from '@nestjs/common';

// import { AppService } from './app.service.js';
import { AppService } from './app.service.js';

@Controller()
export class AppController {
// constructor(private readonly appService: AppService) {}
/*
TODO [LVL1]: Implement API endpoint "GET /" which outputs matrix generated by method generateMatrix and transformed using method matrixToString
The response should be JSON { matrix: <string> };
*/
constructor(private readonly appService: AppService) {}
/*
TODO [LVL2]: The endpoint should be able to accept `seed` numeric query parameter and it will cache the generated matrix based on this parameter
Hint: You can either test this from postman/insomnia, or implement logic around this also on frontend
You can use @apify/datastructures LRU Cache for the storage. @see https://github.com/apify/apify-shared-js/blob/master/packages/datastructures/src/lru_cache.ts
Don't forget to validate and convert the type of the seed param, it needs to be a number.
*/
@Get()
getMatrix(): { matrix: string } {
const matrix = this.appService.generateMatrix(80);
const matrixString = this.appService.matrixToString(matrix);
return { matrix: matrixString };
}
}
24 changes: 22 additions & 2 deletions backend/src/app.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,28 @@ describe('AppService', () => {
});

it('generates expected approximate ratios of cell types with ensureDistance=false', () => {
// TODO [LVL1]: Implement this test
expect(true).toBe(true);
const size = 50;
const matrix = appService.generateMatrix(size);

const allValues = matrix.flat();
const total = allValues.length;

const countDot = allValues.filter((v) => v === '.').length;
const countNumbers = allValues.filter((v) => typeof v === 'number').length;
const countSymbols = allValues.filter((v) => typeof v === 'string' && SYMBOLS.includes(v)).length;

const ratioDot = countDot / total;
expect(ratioDot).toBeGreaterThan(0.25);
expect(ratioDot).toBeLessThan(0.35);

const ratioNumbers = countNumbers / (total - countDot);
const ratioSymbols = countSymbols / (total - countDot);

expect(ratioNumbers).toBeGreaterThan(0.85);
expect(ratioNumbers).toBeLessThan(0.95);

expect(ratioSymbols).toBeGreaterThan(0.05);
expect(ratioSymbols).toBeLessThan(0.15);
});

// TODO [LVL2]: If you decided to implement seeding, test that specific `seed` always generates the same matrix
Expand Down
36 changes: 26 additions & 10 deletions backend/src/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ export type Matrix = (number | string)[][];

@Injectable()
export class AppService {
/*
TODO [LVL1]: Generate a square matrix of provided size, see comments
There should be 30% of cells in the matrix empty, these cells should have value `.` (dot)
The remaining 70% of cells should be divided to either numbers 1-9 (90%) or symbols from the `SYMBOLS` array (10%)
The matrix should then be returned as string where between cells are spaces ' ' and between rows are new lines '\n'
*/
/*
TODO [LVL2]: Use seedable random number generator to always generate the same matrix for same seed
*/
Expand All @@ -28,14 +22,36 @@ export class AppService {
. . . . . . .
*/
public generateMatrix(size: number, _seed?: number, _ensureDistance?: boolean): Matrix {
const matrix: Matrix = Array.from<string[][], string[]>(
{ length: size },
() => Array(size).fill('.') as string[],
);
const matrix: Matrix = Array.from<(string | number)[][], (string | number)[]>({ length: size }, () => {
return Array.from(
{
length: size,
},
() => {
return this.generateRandomSymbol();
},
);
});
return matrix;
}

public matrixToString(matrix: Matrix): string {
return matrix.map((row) => row.join(' ')).join('\n');
}

/**
* Generates a random symbol based on predefined probabilities.
* 30% of cells empty, and from the remaining 70% of cells, 90% should be numbers, and the rest symbols
*/
generateRandomSymbol(): string | number {
const emptyCellProbability = 0.3;
const randomValue = Math.random();
if (randomValue < emptyCellProbability) {
return '.';
}
if (randomValue < emptyCellProbability + 0.7 * 0.9) {
return Math.floor(Math.random() * 9) + 1;
}
return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)];
}
}
1 change: 1 addition & 0 deletions frontend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_BACKEND_API_URL=http://127.0.0.1:5001/
48 changes: 0 additions & 48 deletions frontend/cypress/e2e/level_2_interactions.cy.ts

This file was deleted.

2 changes: 2 additions & 0 deletions frontend/src/@types/styled.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ import 'styled-components';
declare module 'styled-components' {
export interface DefaultTheme {
baseColor: string;
buttonActiveColor: string;
matrixActiveColor: string;
}
}
25 changes: 19 additions & 6 deletions frontend/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import styled from 'styled-components';

import { Loader } from './components/loader.js';
Expand All @@ -8,26 +9,38 @@ import { useTimer } from './hooks/use_timer.js';
import { matrixToNeighborhoods } from './lib/matrix.js';

const AppContainer = styled.div`
display: flex;
gap: 3rem;
padding: 2rem;
color: ${(props) => props.theme.baseColor};
`;

export type SelectedNeighborhoodType = {
id: string;
rowIndex: number;
cellIndex: number;
};

export const App = () => {
useTimer(); // Do not remove this. If you reorganize your app, move it to the same level.

const { matrix, isPending } = useMatrix();

const { matrix, isPending, error } = useMatrix();
const neighborhoods = matrix ? matrixToNeighborhoods(matrix) : [];

// TODO [LVL1]: Handle the error from useMatrix however you want
const [selectedNeighborhood, setSelectedNeighborhood] = useState<SelectedNeighborhoodType | null>(null);

return (
<AppContainer>
{error && <div>Error: {error.message}</div>}
{isPending ? (
<Loader />
) : (
<>
<MatrixSection matrix={matrix} />
<NeighborhoodsSection neighborhoods={neighborhoods} />
<MatrixSection matrix={matrix} selectedNeighborhood={selectedNeighborhood} />
<NeighborhoodsSection
neighborhoods={neighborhoods}
selectedNeighborhood={selectedNeighborhood}
setSelectedNeighborhood={setSelectedNeighborhood}
/>
</>
)}
</AppContainer>
Expand Down
74 changes: 70 additions & 4 deletions frontend/src/components/matrix_section.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,87 @@
import { type FC } from 'react';
import styled from 'styled-components';

import type { SelectedNeighborhoodType } from '../app.tsx';
import type { Matrix } from '../lib/matrix.js';
import { SYMBOLS } from '../lib/matrix.js';
import { CellContent } from './cell_content.js';
import { PerformanceIndicator } from './performance_indicator.js';
import { TypeFilterButton } from './type_filter_button.js';

const MatrixSectionWrapper = styled.section``;
const MatrixSectionWrapper = styled.section`
flex-grow: 1;
--highlight-color: ${(props) => props.theme.matrixActiveColor};

header {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;

h1 {
font-weight: bold;
}
}

table {
width: 100%;
}
`;

function generateHighlightStyle(neighborhood: SelectedNeighborhoodType | null): string {
if (!neighborhood) return '';

const { rowIndex, cellIndex } = neighborhood;
const selectors: string[] = [];

for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
const i = rowIndex + di;
const j = cellIndex + dj;

if (i < 0 || j < 0) continue;

// nth-child starts at 1
const row = i + 1;
const col = j + 1;
selectors.push(`.matrix tr:nth-child(${row}) td:nth-child(${col})`);
}
}

return selectors.length ? `${selectors.join(', ')} { background-color: var(--highlight-color); }` : '';
}

type MatrixProps = {
matrix: Matrix | undefined;
selectedNeighborhood: SelectedNeighborhoodType | null;
};

export const MatrixSection: FC<MatrixProps> = ({ matrix }) => {
export const MatrixSection: FC<MatrixProps> = ({ matrix, selectedNeighborhood }) => {
if (!matrix) return <div />;

const highlightCss = generateHighlightStyle(selectedNeighborhood);

function getActiveCoordinates(neighborhood: SelectedNeighborhoodType | null): Set<string> {
if (!neighborhood) return new Set();

const coords = new Set<string>();
const { rowIndex, cellIndex } = neighborhood;

for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
const ni = rowIndex + di;
const nj = cellIndex + dj;
coords.add(`${ni}-${nj}`);
}
}

return coords;
}

const activeCoords = getActiveCoordinates(selectedNeighborhood);

return (
<MatrixSectionWrapper>
<style>{highlightCss}</style>
<header>
<h1>Apify FS Developer Interview Task</h1>
<div className="type-filter">
Expand All @@ -33,14 +97,16 @@ export const MatrixSection: FC<MatrixProps> = ({ matrix }) => {
<tr key={`matrix-row-${i}`}>
{row.map((cell, j) => {
const id = `${i}-${j}`;
const isActive = activeCoords.has(id);
const isSymbolCell = selectedNeighborhood?.id === id;

return (
<td
key={`matrix-cell-${id}`}
data-cy-mx-row-index={i}
data-cy-mx-cell-index={j}
data-cy-is-active-symbol={false}
data-cy-is-in-active-neighborhood={false}
data-cy-is-active-symbol={isSymbolCell}
data-cy-is-in-active-neighborhood={isActive}
>
<CellContent key={`cell-${id}`} content={cell} />
</td>
Expand Down
47 changes: 31 additions & 16 deletions frontend/src/components/neighborhood_button.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import { type FC } from 'react';
import clsx from 'clsx';
import React, { type FC } from 'react';
import styled from 'styled-components';

import { type Neighborhood, symbolToIcon } from '../lib/matrix.js';

const NeighborhoodButtonWrapper = styled.button`
cursor: not-allowed;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 4.5rem;
margin: 0.1rem;
cursor: pointer;

&.is-selected {
background-color: ${(props) => props.theme.buttonActiveColor};
}
`;

// TODO [LVL1]: Add value to the button as label next to the icon. The value is sum of numbers in it's neighborhood.
// TODO [LVL1]: When you click on the button, it should highlight the neighborhood in the matrix.
// TODO [LVL2]: When you click on the button, the highlighted area in the matrix should have highlighted borders.
export const NeighborhoodButton: FC<Neighborhood> = ({ symbol, rowIndex, cellIndex }) => {
const Icon = symbolToIcon[symbol];
return (
<NeighborhoodButtonWrapper
className="neighborhood"
data-cy-nh-row-index={rowIndex}
data-cy-nh-cell-index={cellIndex}
>
<Icon />
</NeighborhoodButtonWrapper>
);
};
export const NeighborhoodButton: FC<Neighborhood> = React.memo(
({ symbol, rowIndex, cellIndex, neighborhoodSum, isSelected, onSelect }) => {
const Icon = symbolToIcon[symbol];

const classNames = clsx('neighborhood', { 'is-selected': isSelected });

return (
<NeighborhoodButtonWrapper
className={classNames}
data-cy-nh-row-index={rowIndex}
data-cy-nh-cell-index={cellIndex}
onClick={onSelect}
>
<Icon />
{neighborhoodSum}
</NeighborhoodButtonWrapper>
);
},
);
Loading