Merge "Add a table data logger for ABT" into tm-dev
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.android.systemui.dump
|
||||||
|
|
||||||
|
import java.io.PrintWriter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for logging nice table data to be parsed (and pretty printed) in bugreports. The general
|
||||||
|
* idea here is to feed your nice, table-like data to this class, which embeds the schema and rows
|
||||||
|
* into the dumpsys, wrapped in a known start and stop tags. Later, one can build a simple parser
|
||||||
|
* and pretty-print this data in a table
|
||||||
|
*
|
||||||
|
* Note: Something should be said here about silently eating errors by filtering out malformed
|
||||||
|
* lines. Because this class is expected to be utilized only during a dumpsys, it doesn't feel
|
||||||
|
* most correct to throw an exception here (since an exception can often be the reason that this
|
||||||
|
* class is created). Because of this, [DumpsysTableLogger] will simply filter out invalid lines
|
||||||
|
* based solely on line length. This behavior might need to be revisited in the future.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* Assuming we have some data that would be logged to dumpsys like so:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* 1: field1=val1, field2=val2..., fieldN=valN
|
||||||
|
* //...
|
||||||
|
* M: field1M=val1M, ..., fieldNM
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* You can break the `field<n>` values out into a columns spec:
|
||||||
|
* ```
|
||||||
|
* val cols = [field1, field2,...,fieldN]
|
||||||
|
* ```
|
||||||
|
* And then take all of the historical data lines (1 through M), and break them out into their own
|
||||||
|
* lists:
|
||||||
|
* ```
|
||||||
|
* val rows = [
|
||||||
|
* [field10, field20,..., fieldN0],
|
||||||
|
* //...
|
||||||
|
* [field1M, field2M,..., fieldNM]
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Lastly, create a bugreport-unique section name, and use the table logger to write the data to
|
||||||
|
* dumpsys:
|
||||||
|
* ```
|
||||||
|
* val logger = DumpsysTableLogger(uniqueName, cols, rows)
|
||||||
|
* logger.printTableData(pw)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The expected output in the dumpsys would be:
|
||||||
|
* ```
|
||||||
|
* SystemUI TableSection START: <SectionName>
|
||||||
|
* version 1
|
||||||
|
* col1|col2|...|colN
|
||||||
|
* field10|field20|...|fieldN0
|
||||||
|
* //...
|
||||||
|
* field1M|field2M|...|fieldNM
|
||||||
|
* SystemUI TableSection END: <SectionName>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param sectionName A name for the table data section. Should be unique in the bugreport
|
||||||
|
* @param columns Definition for the columns of the table. This should be the same length as all
|
||||||
|
* data rows
|
||||||
|
* @param rows List of rows to be displayed in the table
|
||||||
|
*/
|
||||||
|
class DumpsysTableLogger(
|
||||||
|
private val sectionName: String,
|
||||||
|
private val columns: List<String>,
|
||||||
|
private val rows: List<Row>
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun printTableData(pw: PrintWriter) {
|
||||||
|
printSectionStart(pw)
|
||||||
|
printSchema(pw)
|
||||||
|
printData(pw)
|
||||||
|
printSectionEnd(pw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printSectionStart(pw: PrintWriter) {
|
||||||
|
pw.println(HEADER_PREFIX + sectionName)
|
||||||
|
pw.println("version $VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printSectionEnd(pw: PrintWriter) {
|
||||||
|
pw.println(FOOTER_PREFIX + sectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printSchema(pw: PrintWriter) {
|
||||||
|
pw.println(columns.joinToString(separator = SEPARATOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printData(pw: PrintWriter) {
|
||||||
|
val count = columns.size
|
||||||
|
rows
|
||||||
|
.filter { it.size == count }
|
||||||
|
.forEach { dataLine ->
|
||||||
|
pw.println(dataLine.joinToString(separator = SEPARATOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias Row = List<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DO NOT CHANGE! (but if you must...)
|
||||||
|
* 1. Update the version number
|
||||||
|
* 2. Update any consumers to parse the new version
|
||||||
|
*/
|
||||||
|
private const val HEADER_PREFIX = "SystemUI TableSection START: "
|
||||||
|
private const val FOOTER_PREFIX = "SystemUI TableSection END: "
|
||||||
|
private const val SEPARATOR = "|" // TBD
|
||||||
|
private const val VERSION = "1"
|
||||||
@@ -46,6 +46,34 @@ open class ConnectivityState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected open fun tableColumns(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
"connected",
|
||||||
|
"enabled",
|
||||||
|
"activityIn",
|
||||||
|
"activityOut",
|
||||||
|
"level",
|
||||||
|
"iconGroup",
|
||||||
|
"inetCondition",
|
||||||
|
"rssi",
|
||||||
|
"time")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun tableData(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
connected,
|
||||||
|
enabled,
|
||||||
|
activityIn,
|
||||||
|
activityOut,
|
||||||
|
level,
|
||||||
|
iconGroup,
|
||||||
|
inetCondition,
|
||||||
|
rssi,
|
||||||
|
sSDF.format(time)).map {
|
||||||
|
it.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun copyFrom(other: ConnectivityState) {
|
protected open fun copyFrom(other: ConnectivityState) {
|
||||||
connected = other.connected
|
connected = other.connected
|
||||||
enabled = other.enabled
|
enabled = other.enabled
|
||||||
|
|||||||
@@ -828,6 +828,8 @@ public class MobileSignalController extends SignalController<MobileState, Mobile
|
|||||||
+ (mMobileStatusHistoryIndex + STATUS_HISTORY_SIZE - i) + "): "
|
+ (mMobileStatusHistoryIndex + STATUS_HISTORY_SIZE - i) + "): "
|
||||||
+ mMobileStatusHistory[i & (STATUS_HISTORY_SIZE - 1)]);
|
+ mMobileStatusHistory[i & (STATUS_HISTORY_SIZE - 1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dumpTableData(pw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Box for QS icon info */
|
/** Box for QS icon info */
|
||||||
|
|||||||
@@ -162,6 +162,52 @@ internal class MobileState(
|
|||||||
builder.append("displayInfo=$telephonyDisplayInfo")
|
builder.append("displayInfo=$telephonyDisplayInfo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun tableColumns(): List<String> {
|
||||||
|
val columns = listOf("dataSim",
|
||||||
|
"networkName",
|
||||||
|
"networkNameData",
|
||||||
|
"dataConnected",
|
||||||
|
"roaming",
|
||||||
|
"isDefault",
|
||||||
|
"isEmergency",
|
||||||
|
"airplaneMode",
|
||||||
|
"carrierNetworkChangeMode",
|
||||||
|
"userSetup",
|
||||||
|
"dataState",
|
||||||
|
"defaultDataOff",
|
||||||
|
"showQuickSettingsRatIcon",
|
||||||
|
"voiceServiceState",
|
||||||
|
"isInService",
|
||||||
|
"serviceState",
|
||||||
|
"signalStrength",
|
||||||
|
"displayInfo")
|
||||||
|
|
||||||
|
return super.tableColumns() + columns
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun tableData(): List<String> {
|
||||||
|
val columns = listOf(dataSim,
|
||||||
|
networkName,
|
||||||
|
networkNameData,
|
||||||
|
dataConnected,
|
||||||
|
roaming,
|
||||||
|
isDefault,
|
||||||
|
isEmergency,
|
||||||
|
airplaneMode,
|
||||||
|
carrierNetworkChangeMode,
|
||||||
|
userSetup,
|
||||||
|
dataState,
|
||||||
|
defaultDataOff,
|
||||||
|
showQuickSettingsRatIcon(),
|
||||||
|
getVoiceServiceState(),
|
||||||
|
isInService(),
|
||||||
|
serviceState?.minLog() ?: "(null)",
|
||||||
|
signalStrength?.minLog() ?: "(null)",
|
||||||
|
telephonyDisplayInfo).map { it.toString() }
|
||||||
|
|
||||||
|
return super.tableData() + columns
|
||||||
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (javaClass != other?.javaClass) return false
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import android.content.Context;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.android.settingslib.SignalIcon.IconGroup;
|
import com.android.settingslib.SignalIcon.IconGroup;
|
||||||
|
import com.android.systemui.dump.DumpsysTableLogger;
|
||||||
|
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.BitSet;
|
import java.util.BitSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,20 +196,68 @@ public abstract class SignalController<T extends ConnectivityState, I extends Ic
|
|||||||
pw.println(" - " + mTag + " -----");
|
pw.println(" - " + mTag + " -----");
|
||||||
pw.println(" Current State: " + mCurrentState);
|
pw.println(" Current State: " + mCurrentState);
|
||||||
if (RECORD_HISTORY) {
|
if (RECORD_HISTORY) {
|
||||||
// Count up the states that actually contain time stamps, and only display those.
|
List<ConnectivityState> history = getOrderedHistoryExcludingCurrentState();
|
||||||
int size = 0;
|
for (int i = 0; i < history.size(); i++) {
|
||||||
for (int i = 0; i < HISTORY_SIZE; i++) {
|
pw.println(" Previous State(" + (i + 1) + "): " + mHistory[i]);
|
||||||
if (mHistory[i].time != 0) size++;
|
|
||||||
}
|
|
||||||
// Print out the previous states in ordered number.
|
|
||||||
for (int i = mHistoryIndex + HISTORY_SIZE - 1;
|
|
||||||
i >= mHistoryIndex + HISTORY_SIZE - size; i--) {
|
|
||||||
pw.println(" Previous State(" + (mHistoryIndex + HISTORY_SIZE - i) + "): "
|
|
||||||
+ mHistory[i & (HISTORY_SIZE - 1)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mHistory is a ring, so use this method to get the time-ordered (from youngest to oldest)
|
||||||
|
* list of historical states. Filters out any state whose `time` is `0`.
|
||||||
|
*
|
||||||
|
* For ease of compatibility, this list returns JUST the historical states, not the current
|
||||||
|
* state which has yet to be copied into the history
|
||||||
|
*
|
||||||
|
* @see #getOrderedHistory()
|
||||||
|
* @return historical states, ordered from newest to oldest
|
||||||
|
*/
|
||||||
|
List<ConnectivityState> getOrderedHistoryExcludingCurrentState() {
|
||||||
|
ArrayList<ConnectivityState> history = new ArrayList<>();
|
||||||
|
|
||||||
|
// Count up the states that actually contain time stamps, and only display those.
|
||||||
|
int size = 0;
|
||||||
|
for (int i = 0; i < HISTORY_SIZE; i++) {
|
||||||
|
if (mHistory[i].time != 0) size++;
|
||||||
|
}
|
||||||
|
// Print out the previous states in ordered number.
|
||||||
|
for (int i = mHistoryIndex + HISTORY_SIZE - 1;
|
||||||
|
i >= mHistoryIndex + HISTORY_SIZE - size; i--) {
|
||||||
|
history.add(mHistory[i & (HISTORY_SIZE - 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ordered history states, including the current yet-to-be-copied state. Useful for
|
||||||
|
* logging
|
||||||
|
*
|
||||||
|
* @see #getOrderedHistoryExcludingCurrentState()
|
||||||
|
* @return [currentState, historicalState...] array
|
||||||
|
*/
|
||||||
|
List<ConnectivityState> getOrderedHistory() {
|
||||||
|
ArrayList<ConnectivityState> history = new ArrayList<>();
|
||||||
|
// Start with the current state
|
||||||
|
history.add(mCurrentState);
|
||||||
|
history.addAll(getOrderedHistoryExcludingCurrentState());
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dumpTableData(PrintWriter pw) {
|
||||||
|
List<List<String>> tableData = new ArrayList<List<String>>();
|
||||||
|
List<ConnectivityState> history = getOrderedHistory();
|
||||||
|
for (int i = 0; i < history.size(); i++) {
|
||||||
|
tableData.add(history.get(i).tableData());
|
||||||
|
}
|
||||||
|
|
||||||
|
DumpsysTableLogger logger =
|
||||||
|
new DumpsysTableLogger(mTag, mCurrentState.tableColumns(), tableData);
|
||||||
|
|
||||||
|
logger.printTableData(pw);
|
||||||
|
}
|
||||||
|
|
||||||
final void notifyListeners() {
|
final void notifyListeners() {
|
||||||
notifyListeners(mCallbackHandler);
|
notifyListeners(mCallbackHandler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ public class WifiSignalController extends SignalController<WifiState, IconGroup>
|
|||||||
public void dump(PrintWriter pw) {
|
public void dump(PrintWriter pw) {
|
||||||
super.dump(pw);
|
super.dump(pw);
|
||||||
mWifiTracker.dump(pw);
|
mWifiTracker.dump(pw);
|
||||||
|
dumpTableData(pw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,6 +48,30 @@ internal class WifiState(
|
|||||||
.append(",subId=").append(subId)
|
.append(",subId=").append(subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun tableColumns(): List<String> {
|
||||||
|
val columns = listOf("ssid",
|
||||||
|
"isTransient",
|
||||||
|
"isDefault",
|
||||||
|
"statusLabel",
|
||||||
|
"isCarrierMerged",
|
||||||
|
"subId")
|
||||||
|
|
||||||
|
return super.tableColumns() + columns
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun tableData(): List<String> {
|
||||||
|
val data = listOf(ssid,
|
||||||
|
isTransient,
|
||||||
|
isDefault,
|
||||||
|
statusLabel,
|
||||||
|
isCarrierMerged,
|
||||||
|
subId).map {
|
||||||
|
it.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.tableData() + data
|
||||||
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (javaClass != other?.javaClass) return false
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.android.systemui.dump
|
||||||
|
|
||||||
|
import androidx.test.filters.SmallTest
|
||||||
|
|
||||||
|
import com.android.systemui.SysuiTestCase
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
|
||||||
|
@SmallTest
|
||||||
|
class DumpsysTableLoggerTest : SysuiTestCase() {
|
||||||
|
private val logger = DumpsysTableLogger(
|
||||||
|
TEST_SECTION_NAME,
|
||||||
|
TEST_COLUMNS,
|
||||||
|
TEST_DATA_VALID)
|
||||||
|
|
||||||
|
private val stringWriter = StringWriter()
|
||||||
|
private val printWriter = PrintWriter(stringWriter)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTableLogger_header() {
|
||||||
|
logger.printTableData(printWriter)
|
||||||
|
val lines = logLines(stringWriter)
|
||||||
|
|
||||||
|
val line1 = lines[0]
|
||||||
|
|
||||||
|
assertEquals("table logger header is incorrect",
|
||||||
|
HEADER_PREFIX + TEST_SECTION_NAME, line1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTableLogger_version() {
|
||||||
|
logger.printTableData(printWriter)
|
||||||
|
val lines = logLines(stringWriter)
|
||||||
|
|
||||||
|
val line2 = lines[1]
|
||||||
|
|
||||||
|
assertEquals("version probably shouldn't have changed",
|
||||||
|
"version $VERSION", line2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTableLogger_footer() {
|
||||||
|
logger.printTableData(printWriter)
|
||||||
|
val lines = logLines(stringWriter)
|
||||||
|
|
||||||
|
val footer = lines.last()
|
||||||
|
android.util.Log.d("evanevan", footer)
|
||||||
|
android.util.Log.d("evanevan", lines.toString())
|
||||||
|
|
||||||
|
assertEquals("table logger footer is incorrect",
|
||||||
|
FOOTER_PREFIX + TEST_SECTION_NAME, footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTableLogger_data_length() {
|
||||||
|
logger.printTableData(printWriter)
|
||||||
|
val lines = logLines(stringWriter)
|
||||||
|
|
||||||
|
// Header is 2 lines long, plus a line for the column defs so data is lines[3..last()-1]
|
||||||
|
val data = lines.subList(3, lines.size - 1)
|
||||||
|
assertEquals(TEST_DATA_LENGTH, data.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTableLogger_data_columns() {
|
||||||
|
logger.printTableData(printWriter)
|
||||||
|
val lines = logLines(stringWriter)
|
||||||
|
|
||||||
|
// Header is always 2 lines long so data is lines[2..last()-1]
|
||||||
|
val data = lines.subList(3, lines.size - 1)
|
||||||
|
|
||||||
|
data.forEach { dataLine ->
|
||||||
|
assertEquals(TEST_COLUMNS.size, dataLine.split(SEPARATOR).size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInvalidLinesAreFiltered() {
|
||||||
|
// GIVEN an invalid data row, by virtue of having an extra field
|
||||||
|
val invalidLine = List(TEST_COLUMNS.size) { col ->
|
||||||
|
"data${col}X"
|
||||||
|
} + "INVALID COLUMN"
|
||||||
|
val invalidData = TEST_DATA_VALID.toMutableList().also {
|
||||||
|
it.add(invalidLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN the table logger is created and asked to print the table
|
||||||
|
val tableLogger = DumpsysTableLogger(
|
||||||
|
TEST_SECTION_NAME,
|
||||||
|
TEST_COLUMNS,
|
||||||
|
invalidData)
|
||||||
|
|
||||||
|
tableLogger.printTableData(printWriter)
|
||||||
|
|
||||||
|
// THEN the invalid line is filtered out
|
||||||
|
val invalidString = invalidLine.joinToString(separator = SEPARATOR)
|
||||||
|
val logString = stringWriter.toString()
|
||||||
|
|
||||||
|
assertThat(logString).doesNotContain(invalidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logLines(sw: StringWriter): List<String> {
|
||||||
|
return sw.toString().split("\n").filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copying these here from [DumpsysTableLogger] so that we catch any accidental versioning change
|
||||||
|
private const val HEADER_PREFIX = "SystemUI TableSection START: "
|
||||||
|
private const val FOOTER_PREFIX = "SystemUI TableSection END: "
|
||||||
|
private const val SEPARATOR = "|" // TBD
|
||||||
|
private const val VERSION = "1"
|
||||||
|
|
||||||
|
const val TEST_SECTION_NAME = "TestTableSection"
|
||||||
|
const val TEST_DATA_LENGTH = 5
|
||||||
|
val TEST_COLUMNS = arrayListOf("col1", "col2", "col3")
|
||||||
|
val TEST_DATA_VALID = List(TEST_DATA_LENGTH) { row ->
|
||||||
|
List(TEST_COLUMNS.size) { col ->
|
||||||
|
"data$col$row"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user