Add cumulative flow diagram

This commit is contained in:
Frédéric Guillot 2014-11-16 20:51:59 -05:00
parent 4494566fc7
commit 8bf50d6a7f
30 changed files with 643 additions and 12 deletions

View File

@ -81,4 +81,48 @@ class Analytic extends Base
)));
}
}
/**
* Show cumulative flow diagram
*
* @access public
*/
public function cfd()
{
$project = $this->getProject();
$values = $this->request->getValues();
$from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
$to = $this->request->getStringParam('to', date('Y-m-d'));
if (! empty($values)) {
$from = $values['from'];
$to = $values['to'];
}
if ($this->request->isAjax()) {
$this->response->json(array(
'columns' => array_values($this->board->getColumnsList($project['id'])),
'metrics' => $this->projectDailySummary->getRawMetrics($project['id'], $from, $to),
'labels' => array(
'column' => t('Column'),
'day' => t('Date'),
'total' => t('Tasks'),
)
));
}
else {
$this->response->html($this->layout('analytic/cfd', array(
'values' => array(
'from' => $from,
'to' => $to,
),
'display_graph' => $this->projectDailySummary->countDays($project['id'], $from, $to) >= 2,
'project' => $project,
'date_format' => $this->config->get('application_date_format'),
'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => t('Cumulative flow diagram for "%s"', $project['name']),
)));
}
}
}

View File

@ -108,7 +108,9 @@ abstract class Base
*/
public function __destruct()
{
// $this->container['logger']->addDebug(var_export($this->container['db']->getLogMessages(), true));
// foreach ($this->container['db']->getLogMessages() as $message) {
// $this->container['logger']->addDebug($message);
// }
}
/**
@ -173,6 +175,7 @@ abstract class Base
{
$models = array(
'projectActivity', // Order is important
'projectDailySummary',
'action',
'project',
'webhook',

View File

@ -0,0 +1,28 @@
<?php
namespace Event;
/**
* Project daily summary listener
*
* @package event
* @author Frederic Guillot
*/
class ProjectDailySummaryListener extends Base
{
/**
* Execute the action
*
* @access public
* @param array $data Event data dictionary
* @return bool True if the action was executed or false when not executed
*/
public function execute(array $data)
{
if (isset($data['project_id'])) {
return $this->projectDailySummary->updateTotals($data['project_id'], date('Y-m-d'));
}
return false;
}
}

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -579,7 +579,7 @@ return array(
'Column removed successfully.' => 'Colonne supprimée avec succès.',
'Edit Project' => 'Modifier le projet',
'Github Issue' => 'Ticket Github',
'Not enough data to show the graph.' => 'Pas assez de données pour afficher le graphique',
'Not enough data to show the graph.' => 'Pas assez de données pour afficher le graphique.',
'Previous' => 'Précédent',
'The id must be an integer' => 'L\'id doit être un entier',
'The project id must be an integer' => 'L\'id du projet doit être un entier',
@ -592,4 +592,6 @@ return array(
'This value is required' => 'Cette valeur est obligatoire',
'This value must be numeric' => 'Cette valeur doit être numérique',
'Unable to create this task.' => 'Impossible de créer cette tâche',
'Cumulative flow diagram' => 'Diagramme de flux cumulé',
'Cumulative flow diagram for "%s"' => 'Diagramme de flux cumulé pour « %s »',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
// 'Cumulative flow diagram' => '',
// 'Cumulative flow diagram for "%s"' => '',
);

View File

@ -0,0 +1,181 @@
<?php
namespace Model;
use Core\Template;
use Event\ProjectDailySummaryListener;
/**
* Project daily summary
*
* @package model
* @author Frederic Guillot
*/
class ProjectDailySummary extends Base
{
/**
* SQL table name
*
* @var string
*/
const TABLE = 'project_daily_summaries';
/**
* Update daily totals for the project
*
* @access public
* @param integer $project_id Project id
* @param string $date Record date (YYYY-MM-DD)
* @return boolean
*/
public function updateTotals($project_id, $date)
{
return $this->db->transaction(function($db) use ($project_id, $date) {
$column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id');
foreach ($column_ids as $column_id) {
// This call will fail if the record already exists
// (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE)
$db->table(ProjectDailySummary::TABLE)->insert(array(
'day' => $date,
'project_id' => $project_id,
'column_id' => $column_id,
'total' => 0,
));
$db->table(ProjectDailySummary::TABLE)
->eq('project_id', $project_id)
->eq('column_id', $column_id)
->eq('day', $date)
->update(array(
'total' => $db->table(Task::TABLE)
->eq('project_id', $project_id)
->eq('column_id', $column_id)
->eq('is_active', Task::STATUS_OPEN)
->count()
));
}
});
}
/**
* Count the number of recorded days for the data range
*
* @access public
* @param integer $project_id Project id
* @param string $from Start date (ISO format YYYY-MM-DD)
* @param string $to End date
* @return integer
*/
public function countDays($project_id, $from, $to)
{
$rq = $this->db->execute(
'SELECT COUNT(DISTINCT day) FROM '.self::TABLE.' WHERE day >= ? AND day <= ?',
array($from, $to)
);
return $rq->fetchColumn(0);
}
/**
* Get raw metrics for the project within a data range
*
* @access public
* @param integer $project_id Project id
* @param string $from Start date (ISO format YYYY-MM-DD)
* @param string $to End date
* @return array
*/
public function getRawMetrics($project_id, $from, $to)
{
return $this->db->table(ProjectDailySummary::TABLE)
->columns(
ProjectDailySummary::TABLE.'.column_id',
ProjectDailySummary::TABLE.'.day',
ProjectDailySummary::TABLE.'.total',
Board::TABLE.'.title AS column_title'
)
->join(Board::TABLE, 'id', 'column_id')
->eq(ProjectDailySummary::TABLE.'.project_id', $project_id)
->gte('day', $from)
->lte('day', $to)
->findAll();
}
/**
* Get aggregated metrics for the project within a data range
*
* [
* ['Date', 'Column1', 'Column2'],
* ['2014-11-16', 2, 5],
* ['2014-11-17', 20, 15],
* ]
*
* @access public
* @param integer $project_id Project id
* @param string $from Start date (ISO format YYYY-MM-DD)
* @param string $to End date
* @return array
*/
public function getAggregatedMetrics($project_id, $from, $to)
{
$columns = $this->board->getColumnsList($project_id);
$column_ids = array_keys($columns);
$metrics = array(array(e('Date')) + $columns);
$aggregates = array();
// Fetch metrics for the project
$records = $this->db->table(ProjectDailySummary::TABLE)
->eq('project_id', $project_id)
->gte('day', $from)
->lte('day', $to)
->findAll();
// Aggregate by day
foreach ($records as $record) {
if (! isset($aggregates[$record['day']])) {
$aggregates[$record['day']] = array($record['day']);
}
$aggregates[$record['day']][$record['column_id']] = $record['total'];
}
// Aggregate by row
foreach ($aggregates as $aggregate) {
$row = array($aggregate[0]);
foreach ($column_ids as $column_id) {
$row[] = (int) $aggregate[$column_id];
}
$metrics[] = $row;
}
return $metrics;
}
/**
* Attach events to be able to record the metrics
*
* @access public
*/
public function attachEvents()
{
$events = array(
Task::EVENT_CREATE,
Task::EVENT_CLOSE,
Task::EVENT_OPEN,
Task::EVENT_MOVE_COLUMN,
);
$listener = new ProjectDailySummaryListener($this->container);
foreach ($events as $event_name) {
$this->event->attach($event_name, $listener);
}
}
}

View File

@ -309,8 +309,6 @@ class Task extends Base
*/
private function savePositions($moved_task_id, array $columns)
{
$this->db->startTransaction();
foreach ($columns as $column_id => $column) {
$position = 1;
@ -336,14 +334,11 @@ class Task extends Base
$position++;
if (! $result) {
$this->db->cancelTransaction();
return false;
}
}
}
$this->db->closeTransaction();
return true;
}

View File

@ -5,7 +5,25 @@ namespace Schema;
use PDO;
use Core\Security;
const VERSION = 34;
const VERSION = 35;
function version_35($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_summaries (
id INT NOT NULL AUTO_INCREMENT,
day CHAR(10) NOT NULL,
project_id INT NOT NULL,
column_id INT NOT NULL,
total INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB CHARSET=utf8
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
}
function version_34($pdo)
{

View File

@ -5,7 +5,24 @@ namespace Schema;
use PDO;
use Core\Security;
const VERSION = 15;
const VERSION = 16;
function version_16($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_summaries (
id SERIAL PRIMARY KEY,
day CHAR(10) NOT NULL,
project_id INTEGER NOT NULL,
column_id INTEGER NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
)
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
}
function version_15($pdo)
{

View File

@ -5,7 +5,24 @@ namespace Schema;
use Core\Security;
use PDO;
const VERSION = 34;
const VERSION = 35;
function version_35($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_summaries (
id INTEGER PRIMARY KEY,
day TEXT NOT NULL,
project_id INTEGER NOT NULL,
column_id INTEGER NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
)
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
}
function version_34($pdo)
{

View File

@ -0,0 +1,26 @@
<div class="page-header">
<h2><?= t('Cumulative flow diagram') ?></h2>
</div>
<?php if (! $display_graph): ?>
<p class="alert"><?= t('Not enough data to show the graph.') ?></p>
<?php else: ?>
<section id="analytic-cfd">
<div id="chart" data-url="<?= Helper\u('analytic', 'cfd', array('project_id' => $project['id'], 'from' => $values['from'], 'to' => $values['to'])) ?>"></div>
</section>
<?php endif ?>
<hr/>
<form method="post" class="form-inline" action="<?= Helper\u('analytic', 'cfd', array('project_id' => $project['id'])) ?>" autocomplete="off">
<?= Helper\form_csrf() ?>
<?= Helper\form_label(t('Start Date'), 'from') ?>
<?= Helper\form_text('from', $values, array(), array('required', 'placeholder="'.Helper\in_list($date_format, $date_formats).'"'), 'form-date') ?>
<?= Helper\form_label(t('End Date'), 'to') ?>
<?= Helper\form_text('to', $values, array(), array('required', 'placeholder="'.Helper\in_list($date_format, $date_formats).'"'), 'form-date') ?>
<input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
</form>

View File

@ -7,5 +7,8 @@
<li>
<?= Helper\a(t('User repartition'), 'analytic', 'users', array('project_id' => $project['id'])) ?>
</li>
<li>
<?= Helper\a(t('Cumulative flow diagram'), 'analytic', 'cfd', array('project_id' => $project['id'])) ?>
</li>
</ul>
</div>

View File

@ -33,7 +33,15 @@ ul.no-bullet li {
.pull-right {
text-align: right;
}/* links */
}
hr {
border: 0;
height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
/* links */
a {
color: #3366CC;
border: none;
@ -280,6 +288,10 @@ ul.form-errors li {
margin-right: 15px;
}
.form-inline .form-required {
display: none;
}
input.form-date {
width: 150px;
}

View File

@ -32,4 +32,11 @@ ul.no-bullet li {
.pull-right {
text-align: right;
}
}
hr {
border: 0;
height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}

View File

@ -121,6 +121,10 @@ ul.form-errors li {
margin-right: 15px;
}
.form-inline .form-required {
display: none;
}
input.form-date {
width: 150px;
}

View File

@ -10,11 +10,64 @@ Kanboard.Analytic = (function() {
else if (Kanboard.Exists("analytic-user-repartition")) {
Kanboard.Analytic.UserRepartition.Init();
}
else if (Kanboard.Exists("analytic-cfd")) {
Kanboard.Analytic.CFD.Init();
}
}
};
})();
Kanboard.Analytic.CFD = (function() {
function fetchData()
{
jQuery.getJSON($("#chart").attr("data-url"), function(data) {
drawGraph(data.metrics, data.labels, data.columns);
});
}
function drawGraph(metrics, labels, columns)
{
var series = prepareSeries(metrics, labels);
var svg = dimple.newSvg("#chart", 800, 380);
var chart = new dimple.chart(svg, series);
var x = chart.addCategoryAxis("x", labels['day']);
x.addOrderRule("Date");
chart.addMeasureAxis("y", labels['total']);
var s = chart.addSeries(labels['column'], dimple.plot.area);
s.addOrderRule(columns.reverse());
chart.addLegend(10, 10, 500, 30, "left");
chart.draw();
}
function prepareSeries(metrics, labels)
{
var series = [];
for (var i = 0; i < metrics.length; i++) {
var row = {};
row[labels['column']] = metrics[i]['column_title'];
row[labels['day']] = metrics[i]['day'];
row[labels['total']] = metrics[i]['total'];
series.push(row);
}
return series;
}
return {
Init: fetchData
};
})();
Kanboard.Analytic.TaskRepartition = (function() {
function fetchData()

View File

@ -279,11 +279,64 @@ Kanboard.Analytic = (function() {
else if (Kanboard.Exists("analytic-user-repartition")) {
Kanboard.Analytic.UserRepartition.Init();
}
else if (Kanboard.Exists("analytic-cfd")) {
Kanboard.Analytic.CFD.Init();
}
}
};
})();
Kanboard.Analytic.CFD = (function() {
function fetchData()
{
jQuery.getJSON($("#chart").attr("data-url"), function(data) {
drawGraph(data.metrics, data.labels, data.columns);
});
}
function drawGraph(metrics, labels, columns)
{
var series = prepareSeries(metrics, labels);
var svg = dimple.newSvg("#chart", 800, 380);
var chart = new dimple.chart(svg, series);
var x = chart.addCategoryAxis("x", labels['day']);
x.addOrderRule("Date");
chart.addMeasureAxis("y", labels['total']);
var s = chart.addSeries(labels['column'], dimple.plot.area);
s.addOrderRule(columns.reverse());
chart.addLegend(10, 10, 500, 30, "left");
chart.draw();
}
function prepareSeries(metrics, labels)
{
var series = [];
for (var i = 0; i < metrics.length; i++) {
var row = {};
row[labels['column']] = metrics[i]['column_title'];
row[labels['day']] = metrics[i]['day'];
row[labels['total']] = metrics[i]['total'];
series.push(row);
}
return series;
}
return {
Init: fetchData
};
})();
Kanboard.Analytic.TaskRepartition = (function() {
function fetchData()

61
scripts/create-sample-cfd.php Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env php
<?php
require __DIR__.'/../app/common.php';
use Model\ProjectDailySummary;
use Model\Task;
$pds = new ProjectDailySummary($container);
$taskModel = new Task($container);
for ($i = 1; $i <= 15; $i++) {
$task = array(
'title' => 'Task #'.$i,
'project_id' => 1,
'column_id' => 1,
);
$taskModel->create($task);
}
$pds->updateTotals(1, date('Y-m-d', strtotime('-7 days')));
$taskModel->movePosition(1, 1, 2, 1);
$taskModel->movePosition(1, 2, 2, 1);
$taskModel->movePosition(1, 3, 2, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-6 days')));
$taskModel->movePosition(1, 3, 3, 1);
$taskModel->movePosition(1, 4, 3, 1);
$taskModel->movePosition(1, 5, 3, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-5 days')));
$taskModel->movePosition(1, 5, 4, 1);
$taskModel->movePosition(1, 6, 4, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-4 days')));
$taskModel->movePosition(1, 7, 4, 1);
$taskModel->movePosition(1, 8, 4, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-3 days')));
$taskModel->movePosition(1, 9, 3, 1);
$taskModel->movePosition(1, 10, 2, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-2 days')));
$taskModel->create(array('title' => 'Random task', 'project_id' => 1));
$taskModel->movePosition(1, 11, 2, 1);
$taskModel->movePosition(1, 12, 4, 1);
$taskModel->movePosition(1, 13, 4, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-2 days')));
$taskModel->movePosition(1, 14, 3, 1);
$pds->updateTotals(1, date('Y-m-d', strtotime('-1 days')));
$taskModel->movePosition(1, 15, 4, 1);
$taskModel->movePosition(1, 16, 4, 1);
$taskModel->create(array('title' => 'Random task', 'project_id' => 1));
$pds->updateTotals(1, date('Y-m-d'));

View File

@ -0,0 +1,83 @@
<?php
require_once __DIR__.'/Base.php';
use Model\Project;
use Model\ProjectDailySummary;
use Model\Task;
class ProjectDailySummaryTest extends Base
{
public function testUpdateTotals()
{
$p = new Project($this->container);
$pds = new ProjectDailySummary($this->container);
$t = new Task($this->container);
$this->assertEquals(1, $p->create(array('name' => 'UnitTest')));
$this->assertEquals(0, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d')));
for ($i = 0; $i < 10; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 1)));
}
for ($i = 0; $i < 5; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4)));
}
$pds->updateTotals(1, date('Y-m-d', strtotime('-2days')));
for ($i = 0; $i < 15; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
}
for ($i = 0; $i < 25; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
}
$pds->updateTotals(1, date('Y-m-d', strtotime('-1 day')));
$this->assertNotFalse($t->close(1));
$this->assertNotFalse($t->close(2));
for ($i = 0; $i < 3; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
}
for ($i = 0; $i < 5; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
}
for ($i = 0; $i < 4; $i++) {
$this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4)));
}
$pds->updateTotals(1, date('Y-m-d'));
$this->assertEquals(3, $pds->countDays(3, date('Y-m-d', strtotime('-2days')), date('Y-m-d')));
$metrics = $pds->getAggregatedMetrics(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'));
$this->assertNotEmpty($metrics);
$this->assertEquals(4, count($metrics));
$this->assertEquals(5, count($metrics[0]));
$this->assertEquals('Backlog', $metrics[0][1]);
$this->assertEquals(date('Y-m-d', strtotime('-2days')), $metrics[1][0]);
$this->assertEquals(10, $metrics[1][1]);
$this->assertEquals(0, $metrics[1][2]);
$this->assertEquals(0, $metrics[1][3]);
$this->assertEquals(5, $metrics[1][4]);
$this->assertEquals(date('Y-m-d', strtotime('-1day')), $metrics[2][0]);
$this->assertEquals(10, $metrics[2][1]);
$this->assertEquals(25, $metrics[2][2]);
$this->assertEquals(15, $metrics[2][3]);
$this->assertEquals(5, $metrics[2][4]);
$this->assertEquals(date('Y-m-d'), $metrics[3][0]);
$this->assertEquals(8, $metrics[3][1]);
$this->assertEquals(30, $metrics[3][2]);
$this->assertEquals(18, $metrics[3][3]);
$this->assertEquals(9, $metrics[3][4]);
}
}