Compare commits
13 Commits
main
..
second_try
| Author | SHA1 | Date | |
|---|---|---|---|
| f62ce57279 | |||
| f497329169 | |||
| f55348f0b9 | |||
| 7ebf6dbf41 | |||
| 0b4532282d | |||
| d6c8fdb7ae | |||
| 03bb708e1f | |||
| 0c392e5c71 | |||
| 0fbd307749 | |||
| 76977b9e90 | |||
| 01ada2bf65 | |||
| 7532b938ed | |||
| 1932ad4f72 |
@@ -0,0 +1,67 @@
|
||||
name: Build and Package JRuby Rails App
|
||||
|
||||
#on:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# - second_try
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Java and JRuby
|
||||
run: |
|
||||
apt-get update && apt-get install -y wget curl libgmp-dev
|
||||
# Install Java (needed for JRuby and jpackage)
|
||||
apt-get install -y openjdk-17-jdk
|
||||
|
||||
# Download and install JRuby directly
|
||||
JRUBY_VERSION="9.4.6.0"
|
||||
curl -O https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz
|
||||
tar -xzf jruby-dist-${JRUBY_VERSION}-bin.tar.gz -C /opt/
|
||||
ln -s /opt/jruby-${JRUBY_VERSION}/bin/jruby /usr/local/bin/jruby
|
||||
ln -s /opt/jruby-${JRUBY_VERSION}/bin/bundle /usr/local/bin/bundle
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bundle install
|
||||
|
||||
- name: Build JAR with Jarbler
|
||||
run: |
|
||||
gem install jarbler
|
||||
jarble
|
||||
|
||||
- name: Generate Unique App Name with Git Hash
|
||||
id: vars
|
||||
run: |
|
||||
SHORT_HASH=$(git rev-parse --short HEAD)
|
||||
echo "app_name=custom-app-${SHORT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package with jpackage
|
||||
run: |
|
||||
# Find the generated jar file name dynamically
|
||||
JAR_FILE=$(ls build/*.jar | head -n 1)
|
||||
|
||||
# Run jpackage to bundle the application and runtime into an app-image directory
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name "${{ steps.vars.outputs.app_name }}" \
|
||||
--input "$(dirname "$JAR_FILE")" \
|
||||
--main-jar "$(basename "$JAR_FILE")" \
|
||||
--dest dist
|
||||
|
||||
- name: Compress Executable Package
|
||||
run: |
|
||||
cd dist
|
||||
tar -czvf "${{ steps.vars.outputs.app_name }}.tar.gz" "${{ steps.vars.outputs.app_name }}"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.vars.outputs.app_name }}
|
||||
path: dist/*.tar.gz
|
||||
@@ -3,7 +3,7 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, second_try ]
|
||||
|
||||
jobs:
|
||||
scan_ruby:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||
run: apt-get update && apt-get install --no-install-recommends -y libvips
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||
run: apt-get update && apt-get install --no-install-recommends -y libvips
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Setting up the application
|
||||
|
||||
## Create the database connections:
|
||||
|
||||
bin/rails generate model Joints --no-migration
|
||||
bin/rails generate model JointsInfo --no-migration
|
||||
bin/rails generate model SpecialTest --no-migration
|
||||
|
||||
|
||||
# Setup the views
|
||||
|
||||
bin/rails generate controller dashboard
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'yaml'
|
||||
|
||||
|
||||
## Debugging:
|
||||
# If the response should be filtered and it is not being filtered **and** you
|
||||
# are geting all the values for that column, check to make sure that you are
|
||||
# selecting from the allowed tables and columns.
|
||||
|
||||
|
||||
ALLOWED_TABLES = {
|
||||
"JointInfo" => ["Joint", "Osteokinematics"],
|
||||
"Joint" => ["Common Name", "Joint"]
|
||||
}
|
||||
|
||||
|
||||
class DashboardController < ApplicationController
|
||||
before_action :load_manifest
|
||||
|
||||
def index
|
||||
# Renders the main page
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @manifest }
|
||||
end
|
||||
end
|
||||
|
||||
# GET /dashboard/options?table=Joint&filter_col=joint_id&value=1
|
||||
def options
|
||||
# 1. Define what is allowed to be accessed
|
||||
|
||||
table_name = params[:table]
|
||||
col_name = params[:pluck_column]
|
||||
|
||||
# 2. Validate that the table and column exist in your allowlist
|
||||
if ALLOWED_TABLES.key?(table_name) && ALLOWED_TABLES[table_name].include?(col_name)
|
||||
table = table_name.constantize
|
||||
quoted_col = ActiveRecord::Base.connection.quote_column_name(col_name)
|
||||
|
||||
if params[:filter_column].present? && ALLOWED_TABLES[table_name].include?(params[:filter_column])
|
||||
puts "Filtering with 'filter_column'"
|
||||
options = table.select(Arel.sql(quoted_col)).where(params[:filter_column] => params[:value]).pluck(Arel.sql(quoted_col))
|
||||
else
|
||||
puts "***NO FILTERING***"
|
||||
options = table.distinct.pluck(Arel.sql(quoted_col))
|
||||
end
|
||||
|
||||
render json: options
|
||||
else
|
||||
render json: { error: "Invalid table or column requested" }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
# GET /dashboard/data?joint=Acromioclavicular&sub_joint=X
|
||||
def data
|
||||
results = {}
|
||||
|
||||
# Use the data_sources from YAML to aggregate data
|
||||
@manifest['data_sources'].each do |source|
|
||||
debugger
|
||||
table = source['table'].constantize
|
||||
# Find the filter value from the params that matches the filter_column
|
||||
# We assume the filter_column name in DB matches the filter ID in YAML
|
||||
filter_val = params[source['filter_column']]
|
||||
|
||||
record = table.find_by(source['filter_column'] => filter_val)
|
||||
results[source['key']] = record ? record.attributes : {}
|
||||
end
|
||||
|
||||
#debugger
|
||||
render json: results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_manifest
|
||||
@manifest = YAML.load_file(Rails.root.join('config', 'dashboard_manifest.yml'))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
module DashboardHelper
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class Joint < ApplicationRecord
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class JointInfo < ApplicationRecord
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class SpecialTest < ApplicationRecord
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,219 @@
|
||||
<h1 style="font-family: Arial, sans-serif;">Dynamic Dashboard</h1>
|
||||
|
||||
<div id="filter-area" style="margin-bottom: 20px; display: flex; gap: 15px; font-family: Arial, sans-serif;">
|
||||
<!-- Dropdowns injected here by JS -->
|
||||
</div>
|
||||
|
||||
<div id="results-area" style="display: flex; gap: 20px;">
|
||||
<!-- Grids injected here by JS -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dyn-grid { border-collapse: collapse; border: 2px solid #000; font-family: Arial; font-size: 12px; }
|
||||
.dyn-grid td { border: 1px solid #000; padding: 4px 8px; text-align: center; }
|
||||
.bg-dark-blue { background-color: #002060; color: white; font-weight: bold; }
|
||||
.bg-light-blue { background-color: #D9E1F2; font-weight: bold; }
|
||||
.bg-pale-yellow { background-color: #FFFFCC; }
|
||||
.bg-pale-green { background-color: #E2EFDA; }
|
||||
.bg-pale-orange { background-color: #FCE4D6; }
|
||||
</style>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
let manifest = {};
|
||||
let currentSelections = {};
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch(
|
||||
'/dashboard',
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
manifest = await resp.json();
|
||||
await renderRootFilters();
|
||||
}
|
||||
|
||||
async function renderRootFilters() {
|
||||
const rootFilters = manifest.filters.filter(f => f.depends_on === null);
|
||||
for (const f of rootFilters) {
|
||||
await createDropdown(f);
|
||||
}
|
||||
//rootFilters.forEach(f => await createDropdown(f));
|
||||
}
|
||||
|
||||
async function createDropdown(config) {
|
||||
const container = document.createElement("div");
|
||||
container.id = `container_${config.id}`;
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = config.label + ": ";
|
||||
label.style.fontWeight = "bold";
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.id = config.id;
|
||||
select.innerHTML = `<option value="">Select...</option>`;
|
||||
|
||||
// Marked callback as async
|
||||
select.addEventListener("change", async (e) => {
|
||||
const val = e.target.value;
|
||||
currentSelections[config.id] = val;
|
||||
|
||||
// 1. Clear dependent filters and data
|
||||
clearDependents(config.id);
|
||||
|
||||
// 2. Load options for children (Fixed with for...of)
|
||||
const children = manifest.filters.filter(f => f.depends_on === config.id);
|
||||
for (const child of children) {
|
||||
const options = await fetchOptions(child, val);
|
||||
populateDropdown(child.id, options);
|
||||
}
|
||||
|
||||
// 3. Update data grid
|
||||
updateGrid();
|
||||
});
|
||||
|
||||
container.appendChild(label);
|
||||
container.appendChild(select);
|
||||
document.getElementById("filter-area").appendChild(container);
|
||||
// Special handling for parent items:
|
||||
if (config.depends_on === null) {
|
||||
var options = await fetchOptions(config);
|
||||
populateDropdown(select.id, options)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOptions(config, value) {
|
||||
var params
|
||||
if (config.filter_column == undefined) {
|
||||
console.log(config + "Undefined filter col");
|
||||
params = new URLSearchParams({
|
||||
table: config.table,
|
||||
pluck_column: config.pluck_column,
|
||||
});
|
||||
} else {
|
||||
params = new URLSearchParams({
|
||||
table: config.table,
|
||||
pluck_column: config.pluck_column,
|
||||
filter_column: config.filter_column,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
const resp = await fetch(`/dashboard/options?${params.toString()}`);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
function populateDropdown(id, options) {
|
||||
const select = document.getElementById(id) || createMissingDropdown(id);
|
||||
select.innerHTML = `<option value="">Select...</option>`;
|
||||
options.forEach(opt => {
|
||||
select.innerHTML += `<option value="${opt}">${opt}</option>`;
|
||||
});
|
||||
// If this is the last dropdown, it should be visible now
|
||||
const container = document.getElementById(`container_${id}`);
|
||||
if(container) container.style.display = "block";
|
||||
}
|
||||
|
||||
function createMissingDropdown(id) {
|
||||
const config = manifest.filters.find(f => f.id === id);
|
||||
const container = document.createElement("div");
|
||||
container.id = `container_${id}`;
|
||||
container.style.display = "none"; // Hidden until parent is selected
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = config.label + ": ";
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.id = id;
|
||||
|
||||
// Marked callback as async
|
||||
select.addEventListener("change", async (e) => {
|
||||
currentSelections[id] = e.target.value;
|
||||
clearDependents(id);
|
||||
const children = manifest.filters.filter(f => f.depends_on === id);
|
||||
|
||||
// Fixed with for...of loop
|
||||
for (const child of children) {
|
||||
const opts = await fetchOptions(child, e.target.value);
|
||||
populateDropdown(child.id, opts);
|
||||
}
|
||||
updateGrid();
|
||||
});
|
||||
|
||||
container.appendChild(label);
|
||||
container.appendChild(select);
|
||||
document.getElementById("filter-area").appendChild(container);
|
||||
return select;
|
||||
}
|
||||
|
||||
function clearDependents(id) {
|
||||
// Recursively find all filters that depend on this ID and reset them
|
||||
manifest.filters.forEach(f => {
|
||||
if (f.depends_on === id) {
|
||||
const select = document.getElementById(f.id);
|
||||
if (select) select.innerHTML = `<option value="">Select...</option>`;
|
||||
const container = document.getElementById(`container_${f.id}`);
|
||||
if (container) container.style.display = "none";
|
||||
delete currentSelections[f.id];
|
||||
clearDependents(f.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateGrid() {
|
||||
const params = new URLSearchParams(currentSelections);
|
||||
const resp = await fetch(`/dashboard/data?${params.toString()}`);
|
||||
const data = await resp.json();
|
||||
renderLayout(data);
|
||||
}
|
||||
|
||||
function renderLayout(data) {
|
||||
const area = document.getElementById("results-area");
|
||||
area.innerHTML = "";
|
||||
|
||||
// Group layout by grid name (defaults to 'main')
|
||||
const grids = {};
|
||||
manifest.layout.forEach(item => {
|
||||
const gridId = item.grid || 'main';
|
||||
if (!grids[gridId]) grids[gridId] = [];
|
||||
grids[gridId].push(item);
|
||||
});
|
||||
|
||||
Object.keys(grids).forEach(gridId => {
|
||||
const table = document.createElement("table");
|
||||
table.className = "dyn-grid";
|
||||
|
||||
const layoutItems = grids[gridId];
|
||||
const maxRow = Math.max(...layoutItems.map(i => i.row));
|
||||
|
||||
for (let r = 0; r <= maxRow; r++) {
|
||||
const tr = document.createElement("tr");
|
||||
const rowItems = layoutItems.filter(i => i.row === r);
|
||||
|
||||
rowItems.forEach(item => {
|
||||
const td = document.createElement("td");
|
||||
td.className = item.class;
|
||||
td.colSpan = item.colspan || 1;
|
||||
td.rowSpan = item.rowspan || 1;
|
||||
|
||||
if (item.text) {
|
||||
td.textContent = item.text;
|
||||
} else if (item.value) {
|
||||
// Resolve nested JSON path (e.g., "main_info.joint")
|
||||
const parts = item.value.split('.');
|
||||
const val = parts.reduce((obj, key) => (obj && obj[key] !== undefined) ? obj[key] : '', data);
|
||||
td.textContent = val;
|
||||
}
|
||||
tr.appendChild(td);
|
||||
});
|
||||
table.appendChild(tr);
|
||||
}
|
||||
area.appendChild(table);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
# Filter Hierarchy: Defines the dropdowns and their dependencies
|
||||
filters:
|
||||
- id: "joint"
|
||||
label: "Select Joint"
|
||||
table: "Joint"
|
||||
pluck_column: "Joint"
|
||||
depends_on: null # Root level
|
||||
- id: "joint_info"
|
||||
label: "Select kinematics"
|
||||
table: "JointInfo" # A separate table in SQLite
|
||||
pluck_column: "Osteokinematics"
|
||||
depends_on: "joint" # Only appears after 'joint' is selected
|
||||
filter_column: "Joint" # Filter SubJoint where joint_id == selected joint value
|
||||
|
||||
# Data Mapping: Defines which tables to hit for the final grid
|
||||
data_sources:
|
||||
- table: "Joint"
|
||||
key: "main_info" # Nested JSON key
|
||||
filter_column: "joint"
|
||||
- table: "JointInfo" # MUST MATCH DATABASE TABLE
|
||||
key: "joint_details"
|
||||
filter_column: "Osteokinematics"
|
||||
|
||||
# Layout: Defines the visual grid
|
||||
# colors: dark_blue, light_blue, pale_yellow, pale_green, pale_orange
|
||||
layout:
|
||||
- { row: 0, col: 0, colspan: 3, rowspan: 1, text: "Joint Information", class: "bg-dark-blue" }
|
||||
- { row: 1, col: 0, colspan: 2, rowspan: 1, text: "Anatomical Name", class: "bg-light-blue" }
|
||||
- { row: 2, col: 0, colspan: 2, rowspan: 1, value: "main_info.Joint", class: "bg-pale-yellow" }
|
||||
- { row: 1, col: 2, colspan: 1, rowspan: 1, text: "Common Name", class: "bg-light-blue" }
|
||||
- { row: 2, col: 2, colspan: 1, rowspan: 1, value: "main_info.Common Name", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 0, colspan: 1, rowspan: 1, text: "Diarthrodial Joint Type", class: "bg-light-blue" }
|
||||
- { row: 4, col: 0, colspan: 1, rowspan: 1, value: "main_info.Diarthrodial joint", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 1, colspan: 1, rowspan: 1, text: "Alternate Name", class: "bg-light-blue" }
|
||||
- { row: 4, col: 1, colspan: 1, rowspan: 1, value: "main_info.Alternate Name", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 2, colspan: 1, rowspan: 1, text: "Planes of Freedom", class: "bg-light-blue" }
|
||||
- { row: 4, col: 2, colspan: 1, rowspan: 1, value: "main_info.# of Plains", class: "bg-pale-yellow" }
|
||||
- { row: 5, col: 0, colspan: 1, rowspan: 1, text: "Arthrokinematic Rule", class: "bg-light-blue"}
|
||||
- { row: 6, col: 0, colspan: 1, rowspan: 1, value: "joint_details.Arthrokinematic Rule", class: "bg-pale-yellow"}
|
||||
#- { row: 5, col: 0, colspan: 4, rowspan: 1, value: "main_info.notes", class: "bg-pale-green" }
|
||||
|
||||
# Ligaments Table (separate grid)
|
||||
#- { grid: "ligaments", row: 0, col: 0, colspan: 1, rowspan: 1, text: "Ligaments", class: "bg-dark-blue" }
|
||||
#- { grid: "ligaments", row: 0, col: 1, colspan: 1, rowspan: 1, value: ".list", class: "bg-pale-orange" }
|
||||
|
||||
|
||||
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=undefined&value=Glenohumeral
|
||||
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=Common%20Joint%20Name&value=Shoulder
|
||||
+5
-2
@@ -10,8 +10,11 @@ default: &default
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
adapter: sqlite3
|
||||
max_connections: 5
|
||||
timeout: 5000
|
||||
database: storage/anat_proj.db
|
||||
#readonly: true
|
||||
|
||||
# Warning: The database defined as "test" will be erased and
|
||||
# re-generated from your development database when you run "rake".
|
||||
|
||||
@@ -11,4 +11,8 @@ Rails.application.routes.draw do
|
||||
|
||||
# Defines the root path route ("/")
|
||||
# root "posts#index"
|
||||
root "dashboard#index"
|
||||
get "dashboard", to: "dashboard#index"
|
||||
get "dashboard/options", to: "dashboard#options"
|
||||
get "dashboard/data", to: "dashboard#data"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
require "test_helper"
|
||||
|
||||
class DashboardControllerTest < ActionDispatch::IntegrationTest
|
||||
test "options returns JSON" do
|
||||
get "/dashboard/options",
|
||||
params: {
|
||||
table: 'Joint',
|
||||
pluck_column: 'Joint'
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_equal "application/json", response.media_type
|
||||
end
|
||||
end
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# This model initially had no columns defined. If you add columns to the
|
||||
# model remove the "{}" from the fixture names and add the columns immediately
|
||||
# below each fixture, per the syntax in the comments below
|
||||
#
|
||||
one: {}
|
||||
# column: value
|
||||
#
|
||||
two: {}
|
||||
# column: value
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# This model initially had no columns defined. If you add columns to the
|
||||
# model remove the "{}" from the fixture names and add the columns immediately
|
||||
# below each fixture, per the syntax in the comments below
|
||||
#
|
||||
one: {}
|
||||
# column: value
|
||||
#
|
||||
two: {}
|
||||
# column: value
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# This model initially had no columns defined. If you add columns to the
|
||||
# model remove the "{}" from the fixture names and add the columns immediately
|
||||
# below each fixture, per the syntax in the comments below
|
||||
#
|
||||
one: {}
|
||||
# column: value
|
||||
#
|
||||
two: {}
|
||||
# column: value
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class JointTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class JointsInfoTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class SpecialTestTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
Reference in New Issue
Block a user